Optimizing a Limited Cancer-Screening Budget

A Practical Python Approach

Public health agencies never have unlimited money. Every year, a health ministry or insurer has to decide how many dollars go toward breast cancer screening, how many toward colorectal screening, how many toward lung cancer screening, and so on — knowing that each additional dollar catches slightly fewer new cases than the one before it. This is a classic concave resource allocation problem, and it’s a great case study for combining optimization theory with Python.

In this article we’ll build a small but realistic model, solve it two different ways (a general-purpose optimizer and a much faster specialized algorithm), and visualize the results — including a 3D view of the benefit landscape.

The core idea: diminishing returns

Each screening program has a saturation point. If you screen almost everyone in a population, you eventually catch nearly every detectable case, and additional spending buys you very little extra benefit. This is naturally modeled with an exponential saturation curve:

$$
B_i(x_i) = a_i \left(1 - e^{-x_i / b_i}\right)
$$

Here $x_i$ is the budget (in units of $10,000) given to program $i$, $a_i$ is the maximum number of cases that program could ever detect (its ceiling), and $b_i$ controls how quickly it approaches that ceiling.

The optimization problem is:

$$
\max_{x_1,\dots,x_n} \sum_{i=1}^{n} a_i\left(1 - e^{-x_i/b_i}\right) \quad \text{subject to} \quad \sum_{i=1}^n x_i = X_{\text{total}}, \quad x_i \ge 0
$$

Because every $B_i$ is concave, this problem has a beautiful classical solution: at the optimum, the marginal benefit of every program must be equal. This is the same “water-filling” principle used in economics and information theory. Formally, there exists a single multiplier $\lambda$ such that:

$$
\frac{dB_i}{dx_i} = \frac{a_i}{b_i} e^{-x_i/b_i} = \lambda \quad \text{for every program with } x_i > 0
$$

Solving this equation for $x_i$ gives a closed form:

$$
x_i(\lambda) = \max\left(0,\ -b_i \ln!\left(\frac{\lambda b_i}{a_i}\right)\right)
$$

and we only need to search for the $\lambda$ that makes $\sum_i x_i(\lambda) = X_{\text{total}}$. That single insight is what lets us build a much faster solver than a generic optimizer.

The concrete example

Let’s imagine a regional health authority with four screening programs and a total annual budget of $1,000,000 (100 units of $10,000).

Program Max detectable cases ($a_i$) Diminishing-returns scale ($b_i$)
Breast 120 25
Colorectal 90 15
Lung 150 40
Cervical 60 10

These numbers are illustrative but structured to reflect reality: lung cancer screening has a high ceiling but also needs more spending to get there (low-dose CT is expensive), while cervical screening saturates quickly with modest spending (Pap smears are cheap and coverage is often already high).

Full source code (Google Colab-ready)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
# =========================================================
# Optimal Allocation of a Limited Cancer-Screening Budget
# =========================================================
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from mpl_toolkits.mplot3d import Axes3D # enables 3D projection
from scipy.optimize import minimize
import time

# ---------------------------------------------------------
# 1. Problem data
# ---------------------------------------------------------
programs = ["Breast", "Colorectal", "Lung", "Cervical"]
a = np.array([120.0, 90.0, 150.0, 60.0]) # saturation level (max detectable cases)
b = np.array([25.0, 15.0, 40.0, 10.0]) # diminishing-returns scale (in $10,000 units)

TOTAL_BUDGET = 100.0 # $10,000 units -> $1,000,000 total
n = len(programs)

def benefit(x, a_i, b_i):
return a_i * (1.0 - np.exp(-x / b_i))

def marginal_benefit(x, a_i, b_i):
return (a_i / b_i) * np.exp(-x / b_i)

def neg_total_benefit(x, a_arr, b_arr):
return -np.sum(benefit(x, a_arr, b_arr))

# ---------------------------------------------------------
# 2. Baseline solver: general-purpose optimizer (SLSQP)
# ---------------------------------------------------------
x0 = np.full(n, TOTAL_BUDGET / n)
bounds = [(0, TOTAL_BUDGET) for _ in range(n)]
constraints = {"type": "eq", "fun": lambda x: np.sum(x) - TOTAL_BUDGET}

t0 = time.time()
result = minimize(neg_total_benefit, x0, args=(a, b), method="SLSQP",
bounds=bounds, constraints=constraints)
t1 = time.time()
x_opt_slsqp = result.x
time_slsqp_single = t1 - t0

print("=== SLSQP baseline result ===")
summary = pd.DataFrame({
"Program": programs,
"Budget ($10k)": np.round(x_opt_slsqp, 2),
"Detected cases": np.round(benefit(x_opt_slsqp, a, b), 2)
})
print(summary.to_string(index=False))
print(f"Total detected cases: {benefit(x_opt_slsqp, a, b).sum():.2f}")
print(f"Solve time: {time_slsqp_single*1000:.2f} ms\n")

# ---------------------------------------------------------
# 3. Fast solver: vectorized water-filling (closed-form + bisection)
# ---------------------------------------------------------
def water_filling_vectorized(a_arr, b_arr, budgets, max_iter=100):
"""
Solves the allocation problem for MANY total budgets at once,
using the marginal-equality (water-filling) condition and
bisection on the multiplier lambda. Fully vectorized: no
Python-level loop over budgets or programs.
"""
budgets = np.asarray(budgets, dtype=float)
m = len(budgets)
lo = np.zeros(m)
hi = np.full(m, np.max(a_arr / b_arr))

for _ in range(max_iter):
lam = (lo + hi) / 2.0 # shape (m,)
ratio = np.clip(lam[:, None] * b_arr[None, :] / a_arr[None, :],
1e-300, None)
x = -b_arr[None, :] * np.log(ratio) # shape (m, n)
x = np.clip(x, 0.0, None)
over_budget = x.sum(axis=1) > budgets
lo = np.where(over_budget, lam, lo)
hi = np.where(~over_budget, lam, hi)

lam = (lo + hi) / 2.0
ratio = np.clip(lam[:, None] * b_arr[None, :] / a_arr[None, :], 1e-300, None)
x = -b_arr[None, :] * np.log(ratio)
x = np.clip(x, 0.0, None)
return x # shape (m, n)

# Verify it matches SLSQP for the baseline budget
x_opt_wf = water_filling_vectorized(a, b, [TOTAL_BUDGET])[0]
print("=== Water-filling result (should match SLSQP) ===")
print(np.round(x_opt_wf, 2))

# ---------------------------------------------------------
# 4. Speed comparison over a budget sweep
# ---------------------------------------------------------
budgets_sweep = np.linspace(1.0, 300.0, 500)

# Slow way: call SLSQP once per budget value (only 60 points, to keep runtime sane)
sample_budgets = np.linspace(1.0, 300.0, 60)
t0 = time.time()
for bud in sample_budgets:
x0_s = np.full(n, bud / n)
bnds = [(0, bud) for _ in range(n)]
cons = {"type": "eq", "fun": lambda x, bb=bud: np.sum(x) - bb}
minimize(neg_total_benefit, x0_s, args=(a, b), method="SLSQP",
bounds=bnds, constraints=cons)
t1 = time.time()
time_slsqp_sweep = t1 - t0

# Fast way: one vectorized call for all 500 budgets
t0 = time.time()
X_sweep = water_filling_vectorized(a, b, budgets_sweep)
t1 = time.time()
time_wf_sweep = t1 - t0

print(f"\nSLSQP: {len(sample_budgets)} budgets solved in {time_slsqp_sweep:.4f} s "
f"({time_slsqp_sweep/len(sample_budgets)*1000:.2f} ms/budget)")
print(f"Vectorized water-filling: {len(budgets_sweep)} budgets solved in "
f"{time_wf_sweep:.4f} s ({time_wf_sweep/len(budgets_sweep)*1000:.4f} ms/budget)")

total_benefit_sweep = np.array([
benefit(X_sweep[k], a, b).sum() for k in range(len(budgets_sweep))
])

# ---------------------------------------------------------
# 5. Graph 1: Optimal allocation (bar chart)
# ---------------------------------------------------------
fig1, ax1 = plt.subplots(figsize=(7, 5))
colors = ["#4C72B0", "#DD8452", "#55A868", "#C44E52"]
ax1.bar(programs, x_opt_wf, color=colors)
for i, v in enumerate(x_opt_wf):
ax1.text(i, v + 1, f"{v:.1f}", ha="center", fontweight="bold")
ax1.set_ylabel("Allocated budget ($10,000 units)")
ax1.set_title(f"Optimal Budget Allocation (Total = ${TOTAL_BUDGET*10000:,.0f})")
plt.tight_layout()
plt.show()

# ---------------------------------------------------------
# 6. Graph 2: Total detected cases vs. total budget
# ---------------------------------------------------------
fig2, ax2 = plt.subplots(figsize=(7, 5))
ax2.plot(budgets_sweep * 10000, total_benefit_sweep, color="#4C72B0", linewidth=2)
ax2.axvline(TOTAL_BUDGET * 10000, color="gray", linestyle="--", alpha=0.7)
ax2.set_xlabel("Total budget ($)")
ax2.set_ylabel("Total detected cases (optimal allocation)")
ax2.set_title("Diminishing Returns of the Overall Screening Budget")
plt.tight_layout()
plt.show()

# ---------------------------------------------------------
# 7. Graph 3: Marginal benefit curves (illustrates water-filling)
# ---------------------------------------------------------
x_range = np.linspace(0.01, 60, 300)
lam_opt = marginal_benefit(x_opt_wf[0], a[0], b[0]) # marginal value at optimum

fig3, ax3 = plt.subplots(figsize=(7, 5))
for i, p in enumerate(programs):
ax3.plot(x_range, marginal_benefit(x_range, a[i], b[i]),
label=p, color=colors[i])
ax3.axvline(x_opt_wf[i], color=colors[i], linestyle=":", alpha=0.6)
ax3.axhline(lam_opt, color="black", linestyle="--", label="Optimal marginal value (λ)")
ax3.set_xlabel("Budget allocated to program ($10,000 units)")
ax3.set_ylabel("Marginal benefit (extra cases per $10,000)")
ax3.set_title("Marginal Benefit Curves and the Water-Filling Condition")
ax3.legend()
plt.tight_layout()
plt.show()

# ---------------------------------------------------------
# 8. Graph 4: 3D benefit landscape (Lung vs. Breast allocation)
# ---------------------------------------------------------
grid_n = 60
xl_range = np.linspace(0, TOTAL_BUDGET, grid_n) # Lung budget
xb_range = np.linspace(0, TOTAL_BUDGET, grid_n) # Breast budget
XL, XB = np.meshgrid(xl_range, xb_range)

remaining = TOTAL_BUDGET - XL - XB
infeasible = remaining < 0
remaining_clipped = np.where(infeasible, 0.0, remaining)

# Split the remaining budget between Colorectal and Cervical
# proportionally to their saturation potential
w_col = a[1] / (a[1] + a[3])
XC = remaining_clipped * w_col
XV = remaining_clipped * (1 - w_col)

Z = (benefit(XB, a[0], b[0]) + benefit(XC, a[1], b[1]) +
benefit(XL, a[2], b[2]) + benefit(XV, a[3], b[3]))
Z[infeasible] = np.nan

fig4 = plt.figure(figsize=(9, 7))
ax4 = fig4.add_subplot(111, projection="3d")
surf = ax4.plot_surface(XL, XB, Z, cmap="viridis", edgecolor="none", alpha=0.9)
ax4.scatter([x_opt_wf[2]], [x_opt_wf[0]],
[benefit(x_opt_wf, a, b).sum()],
color="red", s=60, label="Optimal point")
ax4.set_xlabel("Lung budget ($10k)")
ax4.set_ylabel("Breast budget ($10k)")
ax4.set_zlabel("Total detected cases")
ax4.set_title("Benefit Landscape: Lung vs. Breast Allocation\n(Colorectal & Cervical fill remaining budget)")
fig4.colorbar(surf, shrink=0.5, aspect=10, label="Total detected cases")
plt.tight_layout()
plt.show()
=== SLSQP baseline result ===
   Program  Budget ($10k)  Detected cases
    Breast          28.97           82.34
Colorectal          20.73           67.40
      Lung          36.48           89.74
  Cervical          13.82           44.93
Total detected cases: 284.42
Solve time: 26.39 ms

=== Water-filling result (should match SLSQP) ===
[28.97 20.73 36.48 13.82]

SLSQP: 60 budgets solved in 0.4973 s (8.29 ms/budget)
Vectorized water-filling: 500 budgets solved in 0.0085 s (0.0170 ms/budget)

Code walkthrough

Section 1–2 (data and baseline solver). The four programs’ parameters ($a_i$, $b_i$) are defined as NumPy arrays. benefit() implements the saturation curve; neg_total_benefit() is its negative, since scipy.optimize.minimize only minimizes, never maximizes. We hand this to SLSQP (Sequential Least Squares Programming), a general-purpose constrained optimizer that can handle the equality constraint $\sum x_i = 100$ and the bounds $x_i \ge 0$. This is our “ground truth” baseline — accurate, but relatively slow because at every iteration it computes gradients and refines the solution numerically.

Section 3 (the fast, specialized solver). Instead of treating this as a generic nonlinear program, we exploit the mathematical structure. Because the benefit functions are concave, we know the optimum occurs when every program’s marginal benefit equals the same constant $\lambda$. water_filling_vectorized() searches for that $\lambda$ using bisection: it guesses a value of $\lambda$, computes how much budget each program would want at that marginal value, checks whether the total exceeds the given budget, and narrows the search range accordingly. It does this for a whole array of different total budgets simultaneously, using NumPy broadcasting (lam[:, None] * b_arr[None, :]), so there is no Python-level loop over budgets or over programs — only a fixed 100-iteration bisection loop that runs entirely in optimized NumPy C code.

Section 4 (speed comparison). We sweep 500 different total-budget scenarios (from $10,000 to $3,000,000) to see how the optimal allocation and total benefit change as the budget grows. Running SLSQP once per scenario carries real overhead (numerical differentiation, constraint handling, convergence checks), so we only ran it for 60 sample points to keep the runtime reasonable. The vectorized water-filling method solves all 500 scenarios in the same or less time that SLSQP needs for a single budget, because the entire sweep is one batch of array operations instead of hundreds of independent optimizer calls.

Sections 5–8 (visualization). These build the four charts described below.

Understanding the graphs

Graph 1 — Optimal allocation (bar chart). Shows exactly how the $1,000,000 budget should be split across the four programs at the optimum. Programs with a high ceiling ($a_i$) but that also converge slowly (large $b_i$), like Lung, tend to receive a larger share, while cheap-to-saturate programs like Cervical receive comparatively less once their curve has flattened out.

Graph 2 — Total detected cases vs. total budget. This line traces the overall diminishing-returns curve of the entire screening system, not just one program. Early dollars are extremely productive; as the total budget grows past a certain point, each additional $100,000 buys noticeably fewer additional detected cases. This is exactly the kind of curve a health ministry would want when arguing for (or against) additional funding.

Graph 3 — Marginal benefit curves. This is the most conceptually important chart. Each colored curve shows how much extra benefit one more $10,000 buys a given program, as a function of how much it has already received. The dashed horizontal line marks the common marginal value $\lambda$ at the optimum, and the dotted vertical lines show where each program’s curve crosses that line. Visually, this proves the water-filling principle: money keeps flowing into whichever program currently has the highest marginal payoff, until all four curves cross the same horizontal line — at that point, moving a dollar from one program to another can no longer improve the total.

Graph 4 — 3D benefit landscape. Here we fix the total budget at $1,000,000 and let the Lung and Breast allocations vary freely across the horizontal plane, while the leftover money automatically fills Colorectal and Cervical proportionally to their potential. The height of the surface is the total number of detected cases. Because all four benefit functions are concave, the whole landscape is a smooth dome — there’s a single peak, marked with a red dot, and no isolated local optima to get stuck in. This is a nice visual confirmation that a simple hill-climbing or water-filling approach is guaranteed to find the true best allocation.

Takeaways

This is a small model, but the pattern generalizes directly to real health-policy decisions: whenever benefits from different investments show diminishing returns, the key question is never “how much can I afford for program X” in isolation — it’s “where is a marginal dollar currently doing the most good,” across all competing programs at once. The water-filling algorithm formalizes that intuition and, as a bonus, turns out to be dramatically faster than a generic optimizer when you need to explore many what-if budget scenarios.

Hospital Radiation Therapy Equipment Scheduling

Minimizing Patient Waiting Time

Radiation oncology departments are chronically capacity-constrained. A hospital typically owns only two or three linear accelerators (LINACs), yet must serve dozens of patients per day, each with a different treatment duration and a different level of clinical urgency (a pediatric emergency case cannot wait behind a routine follow-up). The scheduling question — which patient goes on which machine, and in what order — directly determines how long people sit in the waiting room before they can start treatment.

This is a textbook instance of the unrelated/identical parallel-machine scheduling problem with weighted waiting time minimization, which is NP-hard in general. In this article we build a concrete example with 16 patients and 3 LINAC machines, solve it with a metaheuristic optimizer, and compare it against the naive first-come-first-served (FCFS) policy that many clinics still use informally.

Problem Formulation

Let $P = {1, \dots, n}$ be the set of patients and $M = {1, \dots, m}$ the set of LINAC machines. Each patient $i$ has:

  • a treatment session duration $p_i$ (minutes),
  • an arrival / ready time $r_i$ (minutes after the department opens),
  • a clinical urgency weight $w_i$.

We must decide a start time $S_i \ge r_i$ and machine assignment for every patient such that no two patients overlap on the same machine. The objective is:

$$
\min \sum_{i \in P} w_i \left(S_i - r_i\right)
$$

subject to, for every pair of patients $i,j$ sharing a machine:

$$
S_i + p_i \le S_j \quad \text{or} \quad S_j + p_j \le S_i
$$

This is a generalization of the classical $1 ,|, \sum w_i C_i$ scheduling problem, and with parallel machines it becomes combinatorially explosive very quickly ($m^n$ machine assignments alone), so exact enumeration is impractical even for modest $n$.

Solution Approach: Random-Key Encoding + Differential Evolution

Rather than optimizing discrete assignments directly, we use a well-known trick from genetic-algorithm literature called random-key encoding:

  1. Every patient $i$ is given a continuous “priority key” $k_i \in [0,1]$.
  2. A decoder sorts patients by key value (highest priority first) and greedily assigns each one, in that order, to whichever machine becomes free earliest — but never before the patient’s own arrival time $r_i$.
  3. This decoding step always produces a 100% feasible schedule, no matter what the key vector looks like.

Because the decoder guarantees feasibility, the search problem reduces to optimizing a continuous vector $\mathbf{k} \in [0,1]^n$, which is exactly what scipy.optimize.differential_evolution (DE) is built for. DE explores the key space with mutation and crossover across a population of candidate vectors, and the decoder translates each candidate into a real, evaluable schedule.

Compared to a full mixed-integer programming formulation, this approach avoids exponential blow-up, decodes in $O(n \log n)$ time, and consistently finds strong (though not provably optimal) solutions within a few hundred generations — well within Google Colab’s free-tier time budget.

Full Source Code

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
"""
Hospital Radiation Therapy (LINAC) Scheduling
Minimizing Weighted Patient Waiting Time
Single-file script for Google Colaboratory
"""

import numpy as np
import matplotlib.pyplot as plt
from mpl_toolkits.mplot3d import Axes3D
from scipy.optimize import differential_evolution
import matplotlib.colors as mcolors

# ----------------------------------------------------------------------
# 0. Dark theme setup
# ----------------------------------------------------------------------
plt.rcParams.update({
"figure.facecolor": "#0d1117",
"axes.facecolor": "#0d1117",
"axes.edgecolor": "#8b949e",
"axes.labelcolor": "#e6edf3",
"text.color": "#e6edf3",
"xtick.color": "#c9d1d9",
"ytick.color": "#c9d1d9",
"grid.color": "#30363d",
"font.size": 11,
})

RNG_SEED = 42
rng = np.random.default_rng(RNG_SEED)

# ----------------------------------------------------------------------
# 1. Problem definition
# ----------------------------------------------------------------------
N_PATIENTS = 16
N_MACHINES = 3 # number of LINAC units available

durations = rng.integers(15, 46, size=N_PATIENTS).astype(float) # minutes
arrivals = rng.integers(0, 121, size=N_PATIENTS).astype(float) # minutes after opening
weights = rng.choice([1.0, 2.0, 3.0, 5.0], size=N_PATIENTS,
p=[0.4, 0.3, 0.2, 0.1]) # clinical urgency

patient_ids = np.array([f"P{i+1:02d}" for i in range(N_PATIENTS)])

# ----------------------------------------------------------------------
# 2. Decoder: priority key vector -> feasible parallel-machine schedule
# ----------------------------------------------------------------------
def decode_schedule(priority_keys, durations, arrivals, n_machines):
order = np.argsort(-priority_keys)
n = len(durations)
machine_free = np.zeros(n_machines)
start_times = np.empty(n)
machine_of = np.empty(n, dtype=int)

for idx in order:
m = int(np.argmin(machine_free))
start = machine_free[m] if machine_free[m] > arrivals[idx] else arrivals[idx]
start_times[idx] = start
machine_of[idx] = m
machine_free[m] = start + durations[idx]

return start_times, machine_of

def fcfs_schedule(durations, arrivals, n_machines):
order = np.argsort(arrivals)
n = len(durations)
machine_free = np.zeros(n_machines)
start_times = np.empty(n)
machine_of = np.empty(n, dtype=int)

for idx in order:
m = int(np.argmin(machine_free))
start = machine_free[m] if machine_free[m] > arrivals[idx] else arrivals[idx]
start_times[idx] = start
machine_of[idx] = m
machine_free[m] = start + durations[idx]

return start_times, machine_of

# ----------------------------------------------------------------------
# 3. Objective function: total weighted waiting time
# ----------------------------------------------------------------------
def weighted_wait(start_times, arrivals, weights):
return float(np.sum(weights * (start_times - arrivals)))

def objective(priority_keys):
start_times, _ = decode_schedule(priority_keys, durations, arrivals, N_MACHINES)
return weighted_wait(start_times, arrivals, weights)

# ----------------------------------------------------------------------
# 4. Optimize with Differential Evolution
# ----------------------------------------------------------------------
convergence_history = []

def de_callback(*args):
# Works across scipy versions: old-style callback(xk, convergence)
# and new-style callback(intermediate_result)
if len(args) == 1 and hasattr(args[0], "x"):
xk = args[0].x
else:
xk = args[0]
convergence_history.append(objective(xk))

bounds = [(0.0, 1.0)] * N_PATIENTS

result = differential_evolution(
objective,
bounds,
seed=RNG_SEED,
maxiter=200,
popsize=15,
mutation=(0.4, 1.0),
recombination=0.7,
tol=1e-8,
polish=True,
updating="deferred",
workers=1,
callback=de_callback,
)

best_keys = result.x
opt_start, opt_machine = decode_schedule(best_keys, durations, arrivals, N_MACHINES)
opt_wait_total = weighted_wait(opt_start, arrivals, weights)

fcfs_start, fcfs_machine = fcfs_schedule(durations, arrivals, N_MACHINES)
fcfs_wait_total = weighted_wait(fcfs_start, arrivals, weights)

improvement_pct = 100.0 * (fcfs_wait_total - opt_wait_total) / fcfs_wait_total

print(f"FCFS total weighted wait : {fcfs_wait_total:9.1f} patient-weighted-minutes")
print(f"Optimized total weighted wait : {opt_wait_total:9.1f} patient-weighted-minutes")
print(f"Improvement : {improvement_pct:6.2f} %")
print()
print(f"{'ID':4s}{'Machine':>9s}{'Arrival':>10s}{'Start':>8s}{'Wait':>7s}{'Weight':>8s}")
for i in np.argsort(opt_start):
print(f"{patient_ids[i]:4s}{opt_machine[i]+1:9d}{arrivals[i]:10.0f}"
f"{opt_start[i]:8.0f}{opt_start[i]-arrivals[i]:7.0f}{weights[i]:8.1f}")

# ----------------------------------------------------------------------
# 5. Graph 1: 3D Gantt chart of the optimized schedule
# ----------------------------------------------------------------------
fig1 = plt.figure(figsize=(11, 7))
ax1 = fig1.add_subplot(111, projection="3d")
ax1.set_facecolor("#0d1117")

cmap = plt.get_cmap("plasma")
norm = mcolors.Normalize(vmin=weights.min(), vmax=weights.max())

for i in range(N_PATIENTS):
x = opt_machine[i]
y = opt_start[i]
z0 = 0
dx, dy, dz = 0.6, durations[i] * 0.9, 1.0
color = cmap(norm(weights[i]))
ax1.bar3d(x - dx / 2, y, z0, dx, dy, dz, color=color, edgecolor="#0d1117",
shade=True, alpha=0.95)

ax1.set_xlabel("LINAC machine")
ax1.set_ylabel("Time (minutes from opening)")
ax1.set_zlabel("")
ax1.set_xticks(range(N_MACHINES))
ax1.set_xticklabels([f"LINAC-{m+1}" for m in range(N_MACHINES)])
ax1.set_zticks([])
ax1.set_title("Optimized Treatment Schedule (bar length = session duration, color = urgency)",
color="#e6edf3")
mappable = plt.cm.ScalarMappable(norm=norm, cmap=cmap)
mappable.set_array([])
cbar = fig1.colorbar(mappable, ax=ax1, shrink=0.6, pad=0.1)
cbar.set_label("Clinical priority weight")
ax1.view_init(elev=22, azim=-60)
plt.tight_layout()
plt.show()

# ----------------------------------------------------------------------
# 6. Graph 2: per-patient waiting time, FCFS vs optimized
# ----------------------------------------------------------------------
fig2, ax2 = plt.subplots(figsize=(12, 5))
x_pos = np.arange(N_PATIENTS)
bar_w = 0.38

fcfs_wait = fcfs_start - arrivals
opt_wait = opt_start - arrivals

ax2.bar(x_pos - bar_w / 2, fcfs_wait, width=bar_w, label="FCFS baseline",
color="#58a6ff", alpha=0.85)
ax2.bar(x_pos + bar_w / 2, opt_wait, width=bar_w, label="DE-optimized",
color="#f78166", alpha=0.9)
ax2.set_xticks(x_pos)
ax2.set_xticklabels(patient_ids, rotation=90)
ax2.set_ylabel("Waiting time (minutes)")
ax2.set_title("Per-Patient Waiting Time: Baseline vs Optimized Schedule", color="#e6edf3")
ax2.legend(facecolor="#161b22", edgecolor="#30363d")
ax2.grid(axis="y", alpha=0.3)
plt.tight_layout()
plt.show()

# ----------------------------------------------------------------------
# 7. Graph 3: DE convergence history
# ----------------------------------------------------------------------
fig3, ax3 = plt.subplots(figsize=(10, 5))
ax3.plot(convergence_history, color="#3fb950", linewidth=2)
ax3.set_xlabel("Generation")
ax3.set_ylabel("Best weighted waiting time (minutes)")
ax3.set_title("Differential Evolution Convergence", color="#e6edf3")
ax3.grid(alpha=0.3)
plt.tight_layout()
plt.show()

# ----------------------------------------------------------------------
# 8. Graph 4: 3D sensitivity surface (machines x patient load)
# ----------------------------------------------------------------------
def greedy_priority_ratio_schedule(durations, arrivals, weights, n_machines):
ratio = weights / durations
order = np.argsort(-ratio)
n = len(durations)
machine_free = np.zeros(n_machines)
start_times = np.empty(n)
for idx in order:
m = int(np.argmin(machine_free))
start = machine_free[m] if machine_free[m] > arrivals[idx] else arrivals[idx]
start_times[idx] = start
machine_free[m] = start + durations[idx]
return start_times

machine_range = np.arange(2, 7) # 2 .. 6 LINAC units
load_factors = np.linspace(0.6, 1.6, 11) # scales average patient duration

M_grid, L_grid = np.meshgrid(machine_range, load_factors)
Z_grid = np.zeros_like(M_grid, dtype=float)

for i in range(M_grid.shape[0]):
for j in range(M_grid.shape[1]):
scaled_durations = durations * L_grid[i, j]
s = greedy_priority_ratio_schedule(scaled_durations, arrivals, weights,
int(M_grid[i, j]))
Z_grid[i, j] = weighted_wait(s, arrivals, weights)

fig4 = plt.figure(figsize=(11, 7))
ax4 = fig4.add_subplot(111, projection="3d")
ax4.set_facecolor("#0d1117")
surf = ax4.plot_surface(M_grid, L_grid, Z_grid, cmap="viridis",
edgecolor="#0d1117", linewidth=0.3, antialiased=True)
ax4.set_xlabel("Number of LINAC machines")
ax4.set_ylabel("Patient duration load factor")
ax4.set_zlabel("Total weighted wait (min)")
ax4.set_title("Sensitivity of Total Weighted Waiting Time\nto Machine Count and Patient Load",
color="#e6edf3")
fig4.colorbar(surf, ax=ax4, shrink=0.6, pad=0.1, label="Weighted wait (min)")
ax4.view_init(elev=25, azim=-50)
plt.tight_layout()
plt.show()
FCFS total weighted wait      :     987.0 patient-weighted-minutes
Optimized total weighted wait :     570.0 patient-weighted-minutes
Improvement                   :  42.25 %

ID    Machine   Arrival   Start   Wait  Weight
P02         1        15      15      0     1.0
P07         3        22      22      0     5.0
P16         2        27      27      0     5.0
P11         3        48      48      0     2.0
P15         1        54      54      0     3.0
P05         2        60      66      6     3.0
P01         3        62      79     17     2.0
P04         1        54      91     37     2.0
P09         2        94      94      0     3.0
P10         3        77      96     19     1.0
P03         3       101     113     12     3.0
P08         2       112     115      3     3.0
P14         1        53     119     66     2.0
P12         3        99     148     49     1.0
P06         2        44     151    107     1.0
P13         1        65     157     92     1.0

Code Walkthrough

Section 0–1 (setup and problem data): We fix a random seed so the example is fully reproducible, then generate 16 synthetic patients with randomized session durations (15–45 minutes), arrival times spread across a two-hour window, and urgency weights drawn from a skewed distribution — most patients are routine (weight 1), a smaller number are moderately urgent, and a rare few are high-priority cases (weight 5). Three LINAC machines are available, matching a typical mid-sized radiation oncology department.

Section 2 (decoder): decode_schedule is the heart of the encoding trick. Given any vector of priority keys, it processes patients from highest key to lowest, and at each step assigns the current patient to whichever machine becomes idle earliest (np.argmin(machine_free)), while respecting that treatment cannot start before the patient physically arrives. This guarantees a valid, non-overlapping schedule for any input vector, which is exactly the property differential evolution needs to search freely without generating invalid candidates. fcfs_schedule is the same list-scheduling logic but with a fixed processing order (arrival time), representing the naive baseline most clinics fall back to without formal optimization.

Section 3–4 (objective and optimizer): The objective sums each patient’s waiting time weighted by clinical urgency. differential_evolution searches the 16-dimensional key space; because the decoder is $O(n \log n)$ and trivial to evaluate, even a population of 15 candidates over 200 generations evaluates in well under a second per generation, keeping total runtime short on Colab’s CPU. updating="deferred" combined with workers=1 keeps the search deterministic and reproducible given the fixed seed, while still allowing you to switch to workers=-1 for parallel evaluation if you scale up the patient count. The de_callback function records the best objective value at every generation for the convergence plot, and is written to tolerate both older and newer SciPy callback signatures so it won’t break regardless of the SciPy version bundled with your Colab runtime.

Section 5–8 (results and visualization): After optimization, we decode the best key vector into a concrete final schedule, compute the FCFS baseline for comparison, and print a per-patient summary table together with the percentage improvement in total weighted waiting time.

Graph 1 — 3D Gantt Chart of the Optimized Schedule

This chart plots each LINAC machine along the x-axis and time-of-day along the y-axis, with each patient’s treatment session rendered as a 3D bar whose length corresponds to session duration. Bar color encodes clinical urgency (brighter/warmer colors from the plasma colormap indicate higher-priority patients). This view lets you visually confirm two things at a glance: that no two bars overlap in time on the same machine (feasibility), and that the optimizer tends to slot high-urgency patients earlier and pack sessions tightly to avoid idle machine time.

Graph 2 — Per-Patient Waiting Time: Baseline vs Optimized

A direct side-by-side bar comparison of how long each individual patient waits under FCFS versus the optimized schedule. This is often the most persuasive chart for hospital administrators, since it shows concretely which patients benefit — typically the high-weight, urgent cases see their waiting time drop sharply, sometimes at the cost of slightly longer waits for low-priority routine patients, which is the intended clinical trade-off.

Graph 3 — Differential Evolution Convergence

This line plot tracks the best (lowest) weighted waiting time found at each generation of the optimizer. A healthy convergence curve should drop quickly in the first few dozen generations and then flatten out as the population converges toward a strong local optimum. If your curve is still steeply decreasing at generation 200, that’s a signal to increase maxiter; if it flattens out well before generation 200, you can safely reduce maxiter to shorten runtime without sacrificing solution quality.

Graph 4 — Sensitivity Surface: Machine Count vs Patient Load

This is a genuine capacity-planning tool. The surface plots total weighted waiting time as a function of two variables a hospital administrator actually controls or forecasts: the number of LINAC machines installed, and a “load factor” scaling how long treatment sessions run on average (a proxy for case-mix complexity, e.g. more proton therapy or IMRT cases with longer setup times). Reading across the surface at a fixed load factor shows the diminishing-returns curve of adding machines — useful for justifying (or questioning) capital equipment purchases. Reading along a fixed machine count shows how sensitive the department is to creeping session durations, which is valuable for staffing and throughput planning.

Conclusion

Framing LINAC scheduling as a weighted parallel-machine problem and solving it with a random-key differential evolution approach turns an informal, often first-come-first-served process into a quantifiable optimization with measurable improvement. The same decoder-plus-metaheuristic pattern generalizes well beyond radiation oncology — it applies equally to CT/MRI scanner scheduling, operating room allocation, or any other constrained clinical resource where patient urgency and equipment availability must be balanced against waiting time.

Optimizing Clinical Trial Design

How Many Patients, and What Dose?

Every clinical trial faces two deceptively simple questions before a single patient is enrolled: how many subjects do we need? and what dose should we test? Get the sample size wrong, and the trial either wastes resources on unnecessary patients or fails to detect a real treatment effect. Get the dose wrong, and patients are exposed to either ineffective or dangerously toxic treatment levels.

In this article, we tackle both problems computationally. First, we determine the optimal sample size for a two-arm superiority trial using statistical power analysis, verified with a vectorized Monte Carlo simulation. Second, we simulate a Bayesian dose-escalation trial using the Continual Reassessment Method (CRM) to efficiently find the Maximum Tolerated Dose (MTD) while minimizing patient exposure to toxicity.

The Statistical Foundation

Sample Size

For a two-arm trial comparing a treatment mean against a control mean, the number of patients needed per arm to detect a true difference $\Delta$ with a given significance level $\alpha$ and power $1-\beta$ is:

$$
n = \frac{2(z_{\alpha/2} + z_{\beta})^2 \sigma^2}{\Delta^2}
$$

where $\sigma$ is the assumed standard deviation of the outcome, $z_{\alpha/2}$ is the critical value for the significance level, and $z_{\beta}$ is the critical value corresponding to the desired power. Intuitively: smaller effect sizes, larger variance, or stricter significance/power requirements all demand more patients.

Dose Finding

For dose-escalation trials, we model the probability of a dose-limiting toxicity (DLT) at dose $d_i$ using a one-parameter power model:

$$
p(d_i) = \psi_i^{\beta}
$$

where $\psi_i$ is a prior “skeleton” toxicity guess for dose level $i$, and $\beta$ is a parameter updated in real time as patient outcomes accumulate. The trial seeks the dose whose estimated toxicity is closest to a pre-specified target, typically $p(d_i) \approx 0.30$.

The Code

Below is the complete, self-contained script. It runs top to bottom in Google Colaboratory with no additional setup.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
# ===================================================
# Clinical Trial Design Optimization
# Part A: Sample Size Determination (Two-Arm Superiority Trial)
# Part B: Dose-Finding via Bayesian Continual Reassessment Method (CRM)
# ===================================================

import numpy as np
import scipy.stats as stats
import matplotlib.pyplot as plt
from mpl_toolkits.mplot3d import Axes3D
import time

np.random.seed(42)

# ---------------------------------------------------
# PART A: SAMPLE SIZE DETERMINATION
# ---------------------------------------------------

def required_sample_size(effect_size, sigma, alpha=0.05, power=0.8):
"""
Analytic sample size per arm for a two-sample (two-sided) test
comparing means, using the normal approximation.
"""
z_alpha = stats.norm.ppf(1 - alpha / 2)
z_beta = stats.norm.ppf(power)
n = 2 * ((z_alpha + z_beta) ** 2) * (sigma ** 2) / (effect_size ** 2)
return int(np.ceil(n))

def analytic_power(n, effect_size, sigma, alpha=0.05):
"""
Closed-form statistical power for a two-sample z-test, used to
quickly evaluate power across many (n, effect_size) combinations.
"""
z_alpha = stats.norm.ppf(1 - alpha / 2)
se = sigma * np.sqrt(2.0 / n)
ncp = effect_size / se
power = 1 - stats.norm.cdf(z_alpha - ncp) + stats.norm.cdf(-z_alpha - ncp)
return power

def monte_carlo_power(n, effect_size, sigma, alpha=0.05, n_sim=20000):
"""
Vectorized Monte Carlo estimate of statistical power.
Simulates n_sim trials simultaneously using numpy array operations
(no Python-level loop over simulations), which is essential for speed.
"""
control = np.random.normal(loc=0.0, scale=sigma, size=(n_sim, n))
treatment = np.random.normal(loc=effect_size, scale=sigma, size=(n_sim, n))

mean_diff = treatment.mean(axis=1) - control.mean(axis=1)
pooled_var = (treatment.var(axis=1, ddof=1) + control.var(axis=1, ddof=1)) / 2
se = np.sqrt(2 * pooled_var / n)
t_stat = mean_diff / se

df = 2 * (n - 1)
t_crit = stats.t.ppf(1 - alpha / 2, df)
reject = np.abs(t_stat) > t_crit
return reject.mean()

# --- Baseline design parameters ---
sigma = 1.0
alpha = 0.05
target_power = 0.8
effect_size = 0.5

n_needed = required_sample_size(effect_size, sigma, alpha, target_power)
print(f"Analytic required sample size per arm: {n_needed}")

start = time.time()
mc_power = monte_carlo_power(n_needed, effect_size, sigma, alpha, n_sim=20000)
elapsed = time.time() - start
print(f"Monte Carlo estimated power at n={n_needed}: {mc_power:.3f} (simulated in {elapsed:.2f}s)")

# --- Power curve: power vs n for several effect sizes ---
n_range = np.arange(5, 201, 5)
effect_sizes_list = [0.2, 0.35, 0.5, 0.8]

plt.figure(figsize=(8, 5))
for es in effect_sizes_list:
powers = analytic_power(n_range, es, sigma, alpha)
plt.plot(n_range, powers, label=f"effect size = {es}")
plt.axhline(0.8, color="gray", linestyle="--", linewidth=1, label="target power = 0.8")
plt.xlabel("Sample size per arm (n)")
plt.ylabel("Statistical power")
plt.title("Power vs Sample Size for Different Effect Sizes")
plt.legend()
plt.grid(alpha=0.3)
plt.tight_layout()
plt.show()
# [Insert power-vs-sample-size chart here]

# --- 3D surface: power as a function of n and effect size ---
n_grid = np.arange(5, 201, 5)
es_grid = np.linspace(0.1, 1.0, 40)
N, ES = np.meshgrid(n_grid, es_grid)
POWER = analytic_power(N, ES, sigma, alpha)

fig = plt.figure(figsize=(9, 6))
ax = fig.add_subplot(111, projection="3d")
surf = ax.plot_surface(N, ES, POWER, cmap="viridis", edgecolor="none")
ax.set_xlabel("Sample size (n)")
ax.set_ylabel("Effect size")
ax.set_zlabel("Power")
ax.set_title("Statistical Power Surface: Sample Size vs Effect Size")
fig.colorbar(surf, shrink=0.5, aspect=10, label="Power")
plt.tight_layout()
plt.show()
# [Insert 3D power surface chart here]


# ---------------------------------------------------
# PART B: DOSE OPTIMIZATION VIA BAYESIAN CRM
# ---------------------------------------------------

dose_levels = np.array([1, 2, 3, 4, 5])
skeleton = np.array([0.05, 0.12, 0.25, 0.40, 0.55]) # prior toxicity guesses per dose

def true_toxicity_prob(dose_idx):
"""
The true (unknown to the investigator) probability of dose-limiting
toxicity at each dose level, used only to generate simulated outcomes.
"""
true_curve = np.array([0.03, 0.10, 0.30, 0.50, 0.70])
return true_curve[dose_idx]

beta_grid = np.linspace(0.1, 5.0, 400)
prior = np.ones_like(beta_grid)
prior /= prior.sum()

target_toxicity = 0.30
n_patients = 30

dose_history = []
toxicity_history = []
posterior_curve_history = []
posterior = prior.copy()

for patient in range(n_patients):
skeleton_matrix = skeleton[None, :] ** beta_grid[:, None]
mean_tox_per_dose = (posterior[:, None] * skeleton_matrix).sum(axis=0)

next_dose_idx = int(np.argmin(np.abs(mean_tox_per_dose - target_toxicity)))

true_p = true_toxicity_prob(next_dose_idx)
outcome = np.random.binomial(1, true_p)

p_dose = skeleton[next_dose_idx] ** beta_grid
likelihood = np.where(outcome == 1, p_dose, 1 - p_dose)
posterior = posterior * likelihood
posterior /= posterior.sum()

dose_history.append(next_dose_idx)
toxicity_history.append(outcome)
posterior_curve_history.append(mean_tox_per_dose.copy())

final_matrix = skeleton[None, :] ** beta_grid[:, None]
final_mean_tox = (posterior[:, None] * final_matrix).sum(axis=0)
estimated_mtd_idx = int(np.argmin(np.abs(final_mean_tox - target_toxicity)))

print(f"Estimated MTD dose level: {dose_levels[estimated_mtd_idx]} "
f"(estimated toxicity = {final_mean_tox[estimated_mtd_idx]:.3f})")
print(f"Total DLT events observed: {sum(toxicity_history)} / {n_patients} patients")

# --- Dose allocation trajectory ---
plt.figure(figsize=(8, 5))
colors = ["red" if t == 1 else "blue" for t in toxicity_history]
plt.scatter(range(1, n_patients + 1), [dose_levels[d] for d in dose_history], c=colors)
plt.plot(range(1, n_patients + 1), [dose_levels[d] for d in dose_history],
color="gray", alpha=0.4, linewidth=1)
plt.xlabel("Patient number")
plt.ylabel("Assigned dose level")
plt.title("CRM Dose Allocation Sequence (red = DLT observed, blue = no DLT)")
plt.yticks(dose_levels)
plt.grid(alpha=0.3)
plt.tight_layout()
plt.show()

# --- 3D surface: posterior toxicity estimate evolution over patients ---
posterior_curve_history = np.array(posterior_curve_history)
Patients_axis = np.arange(1, n_patients + 1)
Dose_axis = dose_levels
P_grid, D_grid = np.meshgrid(Patients_axis, Dose_axis, indexing="ij")

fig = plt.figure(figsize=(9, 6))
ax = fig.add_subplot(111, projection="3d")
surf = ax.plot_surface(D_grid, P_grid, posterior_curve_history, cmap="plasma", edgecolor="none")
ax.set_xlabel("Dose level")
ax.set_ylabel("Patient number")
ax.set_zlabel("Estimated toxicity probability")
ax.set_title("Evolution of Posterior Toxicity Estimates During the CRM Trial")
fig.colorbar(surf, shrink=0.5, aspect=10, label="Estimated toxicity")
plt.tight_layout()
plt.show()

Results

Analytic required sample size per arm: 63
Monte Carlo estimated power at n=63: 0.796 (simulated in 0.18s)


Estimated MTD dose level: 3 (estimated toxicity = 0.314)
Total DLT events observed: 10 / 30 patients


Code Walkthrough

Part A — Sample Size

required_sample_size implements the closed-form formula directly. It converts the desired significance level and power into their corresponding standard-normal quantiles ($z_{\alpha/2}$, $z_{\beta}$) and plugs them into the sample-size equation. This gives an instant answer — no simulation needed — and is the number a biostatistician would report in a protocol.

analytic_power is the inverse direction: given a candidate $n$, it computes the expected power directly from the normal approximation. Because it’s a pure closed-form calculation, it can be evaluated over thousands of $(n, \text{effect size})$ combinations almost instantly — this is what makes the 3D surface plot feasible without a heavy simulation loop.

monte_carlo_power exists to validate the analytic formula against real random sampling, since the analytic approximation relies on asymptotic normality. Critically, this function is written to avoid a Python-level loop over simulations. Instead, it draws an entire (n_sim, n) matrix of random patients for each arm in one call, and computes all n_sim t-statistics simultaneously via NumPy’s axis=1 reductions. Simulating 20,000 full trials this way finishes in roughly a quarter of a second, whereas simulating each trial with a Python for loop would take on the order of seconds to tens of seconds. The Monte Carlo result should land close to the target power (0.8), confirming the analytic formula is trustworthy.

The 2D power curve plot shows, for several effect sizes, how power climbs toward 1.0 as sample size increases — visually confirming why weaker effects require disproportionately larger trials. The 3D surface plot extends this into a full response surface over both sample size and effect size simultaneously, making it easy to read off, for any assumed effect size, exactly how many patients are needed to cross the 80% power threshold.

Part B — Dose Finding (CRM)

The CRM section simulates a Phase I dose-escalation trial. skeleton represents the trial team’s prior belief about toxicity at each of five dose levels; true_toxicity_prob represents the actual, unknown biology used only to generate simulated patient responses — a stand-in for what would happen if this were a real trial.

beta_grid discretizes the unknown model parameter $\beta$ into 400 candidate values, each carrying a posterior probability mass (this is a “grid approximation” to full Bayesian inference — simple, numerically stable, and fast).

Inside the trial loop, for each incoming patient:

  1. skeleton_matrix computes $\psi_i^{\beta}$ for every dose $i$ and every candidate $\beta$ at once (a vectorized outer power operation — no nested loops).
  2. mean_tox_per_dose averages these predictions over the current posterior distribution of $\beta$, producing the model’s current best guess of toxicity at each dose.
  3. The patient is assigned to whichever dose’s predicted toxicity is closest to the 30% target — this is the “efficient” part of the design, since it concentrates patients near the informative, clinically relevant dose rather than wasting them on doses that are obviously too safe or too toxic.
  4. A synthetic outcome (DLT or not) is drawn from the true toxicity curve.
  5. Bayes’ rule updates the posterior over $\beta$ using the observed outcome, and the loop repeats.

After 30 simulated patients, the script reports the estimated MTD and how many toxicity events occurred in total — usually a small fraction of patients, which is the point of using an adaptive design instead of fixed dosing cohorts.

The dose allocation trajectory plot shows how the assigned dose escalates, plateaus, or retreats as evidence accumulates, with red markers flagging patients who experienced a DLT. The 3D posterior evolution surface is the most informative visual: it shows the estimated toxicity curve across all five doses at every point in the trial, letting you watch the model’s beliefs sharpen and converge toward the true dose-toxicity relationship as more patients are enrolled.

Why This Matters

Both simulations reflect a common theme in modern trial design: instead of relying on fixed, rule-of-thumb allocations (like the traditional 3+3 dose-escalation scheme, or arbitrarily “round” sample sizes), adaptive and analytically-grounded methods let trials use exactly as many resources as needed — no more, no less — while exposing fewer patients to ineffective or unsafe treatment. The sample size analysis ensures a trial is statistically capable of detecting a real effect without being wastefully oversized, and the CRM simulation shows how Bayesian updating can locate a safe, effective dose with a fraction of the patients a naive escalation scheme would require.

Optimizing Personalized Medicine

Genotype-Guided Treatment Selection with Python

Precision medicine promises to replace the “one dose fits all” paradigm with treatment decisions tailored to each patient’s genome. In practice, this means combining pharmacogenomic data — variants in drug-metabolizing enzymes, transporters, and drug targets — with dose-response models to select both the right drug and the right dose for a given patient. This is fundamentally a constrained optimization problem: maximize expected clinical benefit while keeping toxicity risk below an acceptable threshold.

In this post, we build a concrete example: a patient with a known genetic profile is being considered for one of five candidate treatments. We optimize the dose for each candidate under a toxicity ceiling, then rank the treatments to recommend the one offering the best genotype-adjusted outcome.

1. From Genotype to a Composite Pharmacogenomic Score

A patient’s genetic profile is represented by allele dosages $x_i \in {0,1,2}$ at $n$ relevant SNPs (e.g., variants in CYP2D6, CYP2C19, DPYD), each with an effect weight $w_i$ describing its influence on drug clearance. We collapse this into a single interpretable metabolizer score:

$$
g = \sigma!\left(\frac{\sum_{i=1}^{n} w_i x_i - \mu}{s}\right), \qquad \sigma(z) = \frac{1}{1+e^{-z}}
$$

where $g \in (0,1)$ ranges from poor metabolizer ($g \to 0$) to ultra-rapid metabolizer ($g \to 1$), and $\mu, s$ are calibration constants centering the score on a typical population range.

2. Genetics-Modulated Dose-Response Models

For each candidate drug $k$, efficacy and toxicity follow Hill/Emax pharmacodynamic models, but their potency parameters shift with the patient’s metabolizer score:

$$
E_k(d,g) = E_{max,k}\cdot\frac{d^{,n_k}}{EC50_k(g)^{,n_k}+d^{,n_k}}, \qquad
T_k(d,g) = T_{max,k}\cdot\frac{d^{,m_k}}{TC50_k(g)^{,m_k}+d^{,m_k}}
$$

$$
EC50_k(g) = EC50_k^0\big(1+\alpha_k(g-0.5)\big), \qquad
TC50_k(g) = TC50_k^0\big(1+\beta_k(g-0.5)\big)
$$

The coefficients $\alpha_k, \beta_k$ encode how a patient’s metabolic speed shifts the effective potency and safety margin of drug $k$ — a fast metabolizer might need a higher dose to reach the same efficacy ($\alpha_k>0$), or might clear the drug fast enough to tolerate higher doses before toxicity appears ($\beta_k>0$).

3. The Optimization Problem

For a fixed patient genotype $g^*$, we choose the dose $d$ that maximizes a clinical utility function penalizing toxicity, subject to a hard toxicity ceiling:

$$
\max_{d \in [d_{min,k},,d_{max,k}]} ; B_k(d) = E_k(d,g^*) - \lambda, T_k(d,g^*)
\quad \text{s.t.} \quad T_k(d,g^*) \le T_{allow}
$$

We solve this independently for all five candidate drugs and select the one with the highest feasible net benefit — this is the genotype-guided recommendation.

4. Full Implementation

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
# ============================================================
# Personalized Medicine: Genotype-Guided Treatment Optimization
# Single-file script for Google Colaboratory
# ============================================================

import numpy as np
from scipy.optimize import differential_evolution, NonlinearConstraint
import matplotlib.pyplot as plt
from mpl_toolkits.mplot3d import Axes3D # noqa: F401 (3D projection registration)

plt.style.use('dark_background')
np.random.seed(42)

# ------------------------------------------------------------
# 1. Patient genetic profile -> composite pharmacogenomic score
# ------------------------------------------------------------
snp_genotype = np.array([2, 1, 0, 2, 1, 1], dtype=float)
snp_weight = np.array([0.80, -0.50, 0.30, 0.60, -0.20, 0.40])
snp_names = ['CYP2D6*4', 'CYP2C19*2', 'CYP3A5*3', 'UGT1A1*28', 'ABCB1', 'DPYD*2A']

g_raw = np.dot(snp_weight, snp_genotype)
g_baseline, g_scale = 2.0, 2.0
g_patient = 1.0 / (1.0 + np.exp(-(g_raw - g_baseline) / g_scale))

# ------------------------------------------------------------
# 2. Candidate treatments: genetics-modulated dose-response models
# ------------------------------------------------------------
drugs = {
'Drug A': dict(Emax=0.95, EC50_0=40, alpha=0.70, n=2.0,
Tmax=0.90, TC50_0=90, beta=0.55, m=2.5, dmin=5, dmax=150),
'Drug B': dict(Emax=0.85, EC50_0=25, alpha=0.20, n=1.8,
Tmax=0.75, TC50_0=60, beta=-0.60, m=2.0, dmin=2, dmax=100),
'Drug C': dict(Emax=0.90, EC50_0=55, alpha=-0.45, n=2.2,
Tmax=0.80, TC50_0=110, beta=0.30, m=2.8, dmin=5, dmax=180),
'Drug D': dict(Emax=0.80, EC50_0=15, alpha=0.10, n=1.5,
Tmax=0.95, TC50_0=35, beta=0.80, m=3.0, dmin=1, dmax=60),
'Drug E': dict(Emax=0.97, EC50_0=70, alpha=0.35, n=2.4,
Tmax=0.70, TC50_0=140, beta=-0.25, m=2.2, dmin=8, dmax=200),
}

lam = 1.0
T_allow = 0.30


def ec50_of_g(g, p):
return p['EC50_0'] * (1.0 + p['alpha'] * (g - 0.5))


def tc50_of_g(g, p):
return p['TC50_0'] * (1.0 + p['beta'] * (g - 0.5))


def efficacy(d, g, p):
ec50 = ec50_of_g(g, p)
return p['Emax'] * d ** p['n'] / (ec50 ** p['n'] + d ** p['n'])


def toxicity(d, g, p):
tc50 = tc50_of_g(g, p)
return p['Tmax'] * d ** p['m'] / (tc50 ** p['m'] + d ** p['m'])


def net_benefit(d, g, p, lam=lam):
return efficacy(d, g, p) - lam * toxicity(d, g, p)


# ------------------------------------------------------------
# 3. Per-drug dose optimization under a toxicity ceiling
# ------------------------------------------------------------
results = {}
for name, p in drugs.items():
def neg_benefit(x, p=p):
return -net_benefit(x[0], g_patient, p)

def tox_constraint_fn(x, p=p):
return toxicity(x[0], g_patient, p)

nlc = NonlinearConstraint(tox_constraint_fn, -np.inf, T_allow)

res = differential_evolution(
neg_benefit,
bounds=[(p['dmin'], p['dmax'])],
constraints=(nlc,),
seed=42,
maxiter=400,
popsize=25,
tol=1e-8,
polish=True,
mutation=(0.5, 1.5),
recombination=0.7,
)

d_opt = res.x[0]
e_opt = efficacy(d_opt, g_patient, p)
t_opt = toxicity(d_opt, g_patient, p)
b_opt = e_opt - lam * t_opt
feasible = t_opt <= T_allow + 1e-6

results[name] = dict(dose=d_opt, efficacy=e_opt, toxicity=t_opt,
benefit=b_opt, feasible=feasible)

feasible_results = {k: v for k, v in results.items() if v['feasible']}
best_drug = max(feasible_results, key=lambda k: feasible_results[k]['benefit']) \
if feasible_results else max(results, key=lambda k: results[k]['benefit'])

print(f"Patient pharmacogenomic score g = {g_patient:.3f}")
for name, r in results.items():
flag = 'OK' if r['feasible'] else 'exceeds toxicity limit'
print(f"{name}: dose={r['dose']:6.2f} efficacy={r['efficacy']:.3f} "
f"toxicity={r['toxicity']:.3f} benefit={r['benefit']:.3f} [{flag}]")
print(f"--> Recommended treatment: {best_drug}")

# ------------------------------------------------------------
# 4. Visualization
# ------------------------------------------------------------
colors = {'Drug A': '#00e5ff', 'Drug B': '#ff6ec7', 'Drug C': '#ffd166',
'Drug D': '#8ac926', 'Drug E': '#c77dff'}

fig = plt.figure(figsize=(16, 13))
fig.patch.set_facecolor('#0d1117')

# --- 4-1. 3D benefit landscape for the recommended drug ---
ax1 = fig.add_subplot(2, 2, 1, projection='3d')
p_best = drugs[best_drug]
d_grid = np.linspace(p_best['dmin'], p_best['dmax'], 90)
g_grid = np.linspace(0.01, 0.99, 90)
D, G = np.meshgrid(d_grid, g_grid)
B = net_benefit(D, G, p_best)
surf = ax1.plot_surface(D, G, B, cmap='plasma', edgecolor='none', alpha=0.92)
ax1.scatter([results[best_drug]['dose']], [g_patient], [results[best_drug]['benefit']],
color='white', s=70, edgecolor='black', depthshade=False)
ax1.set_xlabel('Dose (mg)')
ax1.set_ylabel('Genetic score g')
ax1.set_zlabel('Net benefit')
ax1.set_title(f'Benefit landscape: {best_drug}', color='white')
fig.colorbar(surf, ax=ax1, shrink=0.55, pad=0.08)

# --- 4-2. 3D comparison of all drugs across the genetic score spectrum ---
ax2 = fig.add_subplot(2, 2, 2, projection='3d')
drug_names = list(drugs.keys())
g_bins = np.linspace(0.05, 0.95, 6)
_dx, _dy = 0.6, 0.1
for i, name in enumerate(drug_names):
p = drugs[name]
d_fixed = results[name]['dose']
heights = net_benefit(d_fixed, g_bins, p)
xs = np.full_like(g_bins, i, dtype=float)
ax2.bar3d(xs, g_bins, np.zeros_like(heights), _dx, _dy, heights,
color=colors[name], alpha=0.85, shade=True)
ax2.set_xticks(range(len(drug_names)))
ax2.set_xticklabels(drug_names, rotation=15)
ax2.set_ylabel('Genetic score g')
ax2.set_zlabel('Net benefit')
ax2.set_title('Benefit sensitivity to genotype (dose fixed per drug)', color='white')

# --- 4-3. Efficacy vs toxicity trade-off (Pareto view) at patient's genotype ---
ax3 = fig.add_subplot(2, 2, 3)
for name, p in drugs.items():
dd = np.linspace(p['dmin'], p['dmax'], 200)
ee = efficacy(dd, g_patient, p)
tt = toxicity(dd, g_patient, p)
ax3.plot(tt, ee, color=colors[name], label=name, linewidth=2)
ax3.scatter([results[name]['toxicity']], [results[name]['efficacy']],
color=colors[name], edgecolor='white', s=80, zorder=5)
ax3.axvline(T_allow, color='red', linestyle='--', linewidth=1.2, label='toxicity ceiling')
ax3.set_xlabel('Toxicity probability')
ax3.set_ylabel('Efficacy probability')
ax3.set_title('Efficacy-toxicity trade-off', color='white')
ax3.legend(fontsize=8, loc='lower right')
ax3.grid(alpha=0.2)

# --- 4-4. Ranking summary ---
ax4 = fig.add_subplot(2, 2, 4)
names_sorted = sorted(results, key=lambda k: results[k]['benefit'], reverse=True)
benefits_sorted = [results[k]['benefit'] for k in names_sorted]
bar_colors = [colors[k] if results[k]['feasible'] else '#555555' for k in names_sorted]
bars = ax4.bar(names_sorted, benefits_sorted, color=bar_colors, edgecolor='white')
for bar, name in zip(bars, names_sorted):
if not results[name]['feasible']:
ax4.text(bar.get_x() + bar.get_width() / 2, bar.get_height() + 0.01,
'infeasible', ha='center', color='red', fontsize=8)
ax4.set_ylabel('Optimal net benefit')
ax4.set_title('Treatment ranking for this patient', color='white')
ax4.grid(alpha=0.2, axis='y')

plt.tight_layout()
plt.show()

Output

Patient pharmacogenomic score g = 0.562
Drug A: dose= 59.67  efficacy=0.638  toxicity=0.223  benefit=0.415  [OK]
Drug B: dose= 39.63  efficacy=0.588  toxicity=0.240  benefit=0.348  [OK]
Drug C: dose= 76.43  efficacy=0.618  toxicity=0.204  benefit=0.414  [OK]
Drug D: dose= 18.68  efficacy=0.463  toxicity=0.110  benefit=0.353  [OK]
Drug E: dose=120.93  efficacy=0.756  toxicity=0.300  benefit=0.456  [OK]
--> Recommended treatment: Drug E

5. Code Walkthrough

Genetic score construction. The snp_genotype array encodes the patient’s allele dosage at six pharmacogenomic markers, and snp_weight encodes each variant’s directional effect on metabolic activity. The dot product is passed through a logistic sigmoid, producing a bounded score $g \in (0,1)$ that is easy to interpret and easy to plug into downstream pharmacodynamic equations — this mirrors how polygenic risk scores are constructed in practice.

Dose-response functions. efficacy() and toxicity() implement the Hill-equation model described above. Both are fully vectorized with NumPy, so they accept scalars, 1D arrays, or 2D meshgrids without any code changes — this is what lets the same functions serve the optimizer and the 3D surface plot.

Constrained optimization. For each drug, we use scipy.optimize.differential_evolution, a global, gradient-free optimizer well suited to the potentially non-convex trade-off between efficacy and toxicity. The toxicity ceiling is enforced with a NonlinearConstraint object (rather than the older dict-based constraint API, which is deprecated and less numerically robust). Since the search space is one-dimensional (the dose), convergence is essentially instantaneous — no further speed-up is required here.

Selection logic. After optimizing all five drugs independently, we filter to only the feasible ones (those respecting the toxicity ceiling) and pick the drug with the highest net benefit. If no drug is feasible, we fall back to the best available option and flag it — this avoids ever silently recommending a treatment that violates safety constraints.

6. Reading the Graphs

Top-left (3D surface). This shows how net clinical benefit for the recommended drug varies jointly with dose and genetic score. The white marker sits at the patient’s actual genotype and optimal dose — the peak (or near-peak) of the ridge. The shape of this surface makes it visually clear why a fixed, non-personalized dose would be suboptimal for patients at the opposite end of the metabolizer spectrum.

Top-right (3D bar comparison). Here we take each drug’s dose (optimized specifically for our patient) and ask: how would that same dose perform across a range of hypothetical genetic scores? Drugs with flatter bar profiles are more “robust” to genotype misestimation, while drugs with steep gradients are highly genotype-sensitive — valuable information when confidence in a genetic test is imperfect.

Bottom-left (efficacy-toxicity trade-off). Each curve traces out the achievable combinations of efficacy and toxicity as dose is swept across its allowed range for a given drug, evaluated at the patient’s actual genotype. The red dashed line marks the toxicity ceiling; curves that stay well left of it before saturating in efficacy are the most attractive candidates. The dots mark each drug’s optimizer-selected operating point.

Bottom-right (ranking bar chart). A direct summary: the optimal net benefit achieved by each drug, sorted from best to worst. Gray bars (if any) indicate drugs that could not satisfy the toxicity constraint at any dose and were excluded from consideration.

7. Closing Notes

This example is a simplified, illustrative model — the pharmacodynamic parameters are synthetic rather than derived from real trial data, and any real clinical decision support system would need far more rigorous, validated dose-response models, uncertainty quantification, and regulatory oversight. But the underlying optimization structure — genotype-conditioned dose-response curves, a constrained multi-objective utility function, and a global optimizer sweeping across candidate treatments — reflects the computational backbone of real pharmacogenomics-driven decision tools, and generalizes naturally to more markers, more drugs, or multi-dose regimens.

Optimizing the Order of Cancer Treatments

Surgery, Chemotherapy, and Radiation as a Combinatorial Optimization Problem

When a patient is diagnosed with a tumor that requires multiple treatment modalities, doctors don’t just choose which treatments to use — they also have to choose the order. Should surgery come first, followed by radiation to clean up the margins? Or should chemotherapy shrink the tumor first, making surgery safer and less invasive? Each ordering carries a different balance of risk, healing time, and toxicity.

This is, at its core, a sequencing optimization problem — mathematically very close to the famous Traveling Salesman Problem (TSP), except instead of minimizing travel distance between cities, we minimize a “treatment cost” that depends on which therapy follows which.

In this article, I’ll build a concrete, solvable example with three treatments — Surgery (S), Chemotherapy (C), and Radiation (R) — model the cost of every possible ordering, find the optimal sequence with Python, visualize it (including in 3D), and then scale the problem up to nine treatment modalities to show why a smarter algorithm (dynamic programming) becomes essential as the number of choices grows.

Note: the numbers used below are illustrative, synthetic values chosen to make the optimization clear — not real clinical data. The point of this article is the algorithm, not medical advice.

Formulating the Problem

Let treatments be indexed $1, 2, \dots, n$. We define two cost components:

  • $B(i)$: the baseline cost of starting the entire treatment plan with treatment $i$ (e.g., the inherent risk of operating on an untreated tumor).
  • $C(i, j)$: the transition cost of performing treatment $j$ immediately after treatment $i$ (e.g., the risk of operating on tissue that was just irradiated).

For a treatment order $\pi = (\pi_1, \pi_2, \dots, \pi_n)$, the total cost is:

$$
\text{TotalCost}(\pi) = B(\pi_1) + \sum_{k=1}^{n-1} C(\pi_k, \pi_{k+1})
$$

We want to find the permutation $\pi^*$ that minimizes this:

$$
\pi^* = \arg\min_{\pi \in S_n} \text{TotalCost}(\pi)
$$

This is exactly the structure of the shortest Hamiltonian path problem. For small $n$, we can simply check every permutation (there are $n!$ of them). For larger $n$, we’ll use the Held-Karp dynamic programming algorithm, which solves it in:

$$
O(n^2 \cdot 2^n) \quad \text{instead of} \quad O(n!)
$$

The DP recurrence builds up the optimal cost of visiting a subset of treatments $S$ and ending at treatment $j$:

$$
dp[S][j] = \min_{i \in S \setminus {j}} \Big( dp[S \setminus {j}][i] + C(i,j) \Big), \qquad dp[{j}][j] = B(j)
$$

The Python Implementation

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
import numpy as np
import itertools
import math
import time
import matplotlib.pyplot as plt
from mpl_toolkits.mplot3d import Axes3D

# ============================================================
# 1. Core 3-treatment example: Surgery, Chemotherapy, Radiation
# ============================================================
treatments_3 = ['Surgery', 'Chemotherapy', 'Radiation']
n3 = len(treatments_3)

# B[i]: baseline cost of starting the whole plan with treatment i
B3 = np.array([5.0, 3.0, 4.0])

# C[i][j]: extra cost incurred when treatment j is performed
# immediately after treatment i (toxicity overlap, healing delay, etc.)
C3 = np.array([
[0.0, 2.0, 6.0], # from Surgery -> Chemo, Radiation
[1.5, 0.0, 2.5], # from Chemotherapy -> Surgery, Radiation
[4.0, 3.0, 0.0], # from Radiation -> Surgery, Chemo
])

def total_cost(perm, B, C):
"""Total cost of a given treatment order."""
cost = B[perm[0]]
for i in range(len(perm) - 1):
cost += C[perm[i]][perm[i + 1]]
return cost

def evaluate_all_permutations(n, B, C):
"""Full brute-force enumeration (fine for small n)."""
results = []
best_perm, best_cost = None, math.inf
for perm in itertools.permutations(range(n)):
c = total_cost(perm, B, C)
results.append((perm, c))
if c < best_cost:
best_perm, best_cost = perm, c
return best_perm, best_cost, results

best_perm3, best_cost3, all_results3 = evaluate_all_permutations(n3, B3, C3)

print("All possible treatment sequences and their total cost:")
for perm, c in all_results3:
seq_name = " -> ".join(treatments_3[i] for i in perm)
marker = " <== OPTIMAL" if perm == best_perm3 else ""
print(f"{seq_name:35s} cost = {c:5.2f}{marker}")

print("\nOptimal treatment order:",
" -> ".join(treatments_3[i] for i in best_perm3))
print("Minimum total cost:", round(best_cost3, 2))


# ============================================================
# 2. Bar chart of all 6 possible sequences
# ============================================================
labels = [" -> ".join(treatments_3[i] for i in perm) for perm, _ in all_results3]
costs = [c for _, c in all_results3]
colors = ['#ff6b6b' if perm == best_perm3 else '#4dabf7' for perm, _ in all_results3]

plt.figure(figsize=(10, 5))
plt.bar(labels, costs, color=colors)
plt.ylabel("Total treatment-plan cost")
plt.title("Total Cost for Every Possible Surgery / Chemo / Radiation Order")
plt.xticks(rotation=20, ha='right')
plt.tight_layout()
plt.show()


# ============================================================
# 3. 3D visualization of the transition-cost matrix
# ============================================================
fig = plt.figure(figsize=(8, 6))
ax = fig.add_subplot(111, projection='3d')

xpos, ypos = np.meshgrid(np.arange(n3), np.arange(n3), indexing='ij')
xpos = xpos.flatten().astype(float)
ypos = ypos.flatten().astype(float)
zpos = np.zeros_like(xpos)
dx = dy = 0.5 * np.ones_like(xpos)
dz = C3.flatten()

colors_3d = plt.cm.viridis(dz / dz.max())
ax.bar3d(xpos, ypos, zpos, dx, dy, dz, color=colors_3d, shade=True)

ax.set_xticks(np.arange(n3) + 0.25)
ax.set_xticklabels(treatments_3)
ax.set_yticks(np.arange(n3) + 0.25)
ax.set_yticklabels(treatments_3)
ax.set_xlabel("From")
ax.set_ylabel("To")
ax.set_zlabel("Transition cost")
ax.set_title("Transition Cost Between Treatments (3D View)")
plt.tight_layout()
plt.show()


# ============================================================
# 4. 3D trajectory plot: cumulative cost step-by-step
# ============================================================
fig = plt.figure(figsize=(9, 7))
ax = fig.add_subplot(111, projection='3d')

for idx, (perm, _) in enumerate(all_results3):
cum = [B3[perm[0]]]
for i in range(len(perm) - 1):
cum.append(cum[-1] + C3[perm[i]][perm[i + 1]])
xs = list(range(1, len(perm) + 1))
ys = [idx] * len(perm)
zs = cum
is_best = perm == best_perm3
ax.plot(xs, ys, zs,
color='#ff6b6b' if is_best else '#4dabf7',
linewidth=3 if is_best else 1.5,
marker='o')

ax.set_xlabel("Treatment step")
ax.set_ylabel("Sequence index")
ax.set_zlabel("Cumulative cost")
ax.set_yticks(range(len(all_results3)))
ax.set_yticklabels(
[" -> ".join(treatments_3[i] for i in perm) for perm, _ in all_results3],
fontsize=7)
ax.set_title("Cumulative Cost Growth for Every Treatment Order")
plt.tight_layout()
plt.show()


# ============================================================
# 5. Scaling up to 9 treatments: brute force vs dynamic programming
# ============================================================
treatments_9 = ['Surgery', 'Chemotherapy', 'Radiation', 'Immunotherapy',
'Hormone Therapy', 'Targeted Therapy', 'Cryotherapy',
'Photodynamic Therapy', 'Brachytherapy']
n9 = len(treatments_9)

rng = np.random.default_rng(42)
B9 = rng.uniform(2, 8, size=n9)
C9 = rng.uniform(1, 9, size=(n9, n9))
np.fill_diagonal(C9, 0.0)

def brute_force_best_only(n, B, C):
"""Memory-light brute force with simple branch pruning."""
best_cost, best_perm = math.inf, None
for perm in itertools.permutations(range(n)):
cost = B[perm[0]]
pruned = False
for i in range(n - 1):
cost += C[perm[i]][perm[i + 1]]
if cost >= best_cost:
pruned = True
break
if not pruned and cost < best_cost:
best_cost, best_perm = cost, perm
return best_perm, best_cost

def held_karp(n, B, C):
"""Exact O(n^2 * 2^n) dynamic programming solution."""
INF = math.inf
size = 1 << n
dp = np.full((size, n), INF)
parent = np.full((size, n), -1, dtype=int)
for j in range(n):
dp[1 << j][j] = B[j]

for mask in range(size):
for j in range(n):
if not (mask & (1 << j)):
continue
cur = dp[mask][j]
if cur == INF:
continue
for k in range(n):
if mask & (1 << k):
continue
nmask = mask | (1 << k)
ncost = cur + C[j][k]
if ncost < dp[nmask][k]:
dp[nmask][k] = ncost
parent[nmask][k] = j

full = size - 1
last = int(np.argmin(dp[full]))
best_cost = dp[full][last]

path = []
mask, j = full, last
while j != -1:
path.append(j)
pj = parent[mask][j]
mask ^= (1 << j)
j = pj
path.reverse()
return tuple(path), best_cost

t0 = time.time()
bf_perm, bf_cost = brute_force_best_only(n9, B9, C9)
t_bf = time.time() - t0

t0 = time.time()
dp_perm, dp_cost = held_karp(n9, B9, C9)
t_dp = time.time() - t0

print(f"Brute force : best cost = {bf_cost:.3f}, time = {t_bf:.3f} s")
print(f"Held-Karp DP : best cost = {dp_cost:.3f}, time = {t_dp:.4f} s")
print(f"Results match : {math.isclose(bf_cost, dp_cost)}")
print(f"Speed-up : {t_bf / t_dp:.1f}x faster with dynamic programming")
print("Optimal 9-step order (DP):",
" -> ".join(treatments_9[i] for i in dp_perm))


# ============================================================
# 6. Runtime comparison chart
# ============================================================
plt.figure(figsize=(6, 5))
bars = plt.bar(['Brute force\n(9! = 362,880 paths)', 'Held-Karp DP\n(2^9 x 9^2 states)'],
[t_bf, t_dp], color=['#ff6b6b', '#51cf66'])
plt.ylabel("Execution time (seconds)")
plt.title("Runtime: Brute Force vs Dynamic Programming")
for bar, v in zip(bars, [t_bf, t_dp]):
plt.text(bar.get_x() + bar.get_width() / 2, v, f"{v:.3f}s",
ha='center', va='bottom')
plt.tight_layout()
plt.show()

Execution Results

All possible treatment sequences and their total cost:
Surgery -> Chemotherapy -> Radiation cost =  9.50
Surgery -> Radiation -> Chemotherapy cost = 14.00
Chemotherapy -> Surgery -> Radiation cost = 10.50
Chemotherapy -> Radiation -> Surgery cost =  9.50
Radiation -> Surgery -> Chemotherapy cost = 10.00
Radiation -> Chemotherapy -> Surgery cost =  8.50  <== OPTIMAL

Optimal treatment order: Radiation -> Chemotherapy -> Surgery
Minimum total cost: 8.5




Brute force   : best cost = 19.502, time = 0.823 s
Held-Karp DP  : best cost = 19.502, time = 0.0095 s
Results match : True
Speed-up      : 86.4x faster with dynamic programming
Optimal 9-step order (DP): Chemotherapy -> Photodynamic Therapy -> Radiation -> Surgery -> Brachytherapy -> Immunotherapy -> Hormone Therapy -> Cryotherapy -> Targeted Therapy

Walking Through the Code

Section 1 — Defining the problem. B3 holds the baseline cost of starting with each treatment, and C3 is a 3×3 matrix of transition costs. Row $i$, column $j$ of C3 represents “the extra cost of doing treatment $j$ right after treatment $i$.” For instance, C3[0][2] = 6.0 (Surgery → Radiation) is deliberately set high, modeling the realistic concern that irradiating a fresh surgical wound increases complication risk. C3[1][0] = 1.5 (Chemo → Surgery) is low, reflecting that a tumor downstaged by chemotherapy is often easier and safer to remove.

total_cost() simply walks through a permutation and sums the baseline cost plus every transition cost along the path — a direct implementation of the formula above.

evaluate_all_permutations() uses Python’s itertools.permutations to brute-force all $3! = 6$ orderings, which is trivial at this scale, and tracks the minimum.

Section 2 — Bar chart. This plots the total cost of all six possible orderings side by side, with the optimal one highlighted in red, making it visually obvious which choice wins and by how much.

Section 3 — 3D transition matrix. Using ax.bar3d, we render the transition cost matrix as a literal 3D bar chart: the x-axis is the “from” treatment, the y-axis is the “to” treatment, and the height (z-axis) is the cost of that transition. This makes asymmetries immediately visible — for example, you can see that the Surgery → Radiation bar towers over Chemotherapy → Surgery.

Section 4 — 3D cumulative cost trajectories. Each of the six possible sequences is drawn as a 3D line: the x-axis is the treatment step (1st, 2nd, 3rd), the y-axis separates the six sequences into “lanes,” and the z-axis shows the cumulative cost as it builds up step by step. The optimal sequence is drawn thicker and in red, so you can see exactly where it pulls ahead of the alternatives.

Section 5 — Scaling to 9 treatments. A real hospital might consider far more than three modalities — immunotherapy, hormone therapy, targeted therapy, and so on. With 9 treatments, brute force must check $9! = 362{,}880$ orderings. brute_force_best_only() is a leaner version that discards results as soon as the running cost exceeds the current best (a simple branch-and-bound pruning trick), avoiding the memory overhead of storing all permutations.

held_karp() is the optimized algorithm. It uses a bitmask mask to represent “the set of treatments already scheduled,” and dp[mask][j] stores the minimum cost of a sequence that uses exactly the treatments in mask and ends at treatment j. By building this table up from single-treatment subsets to the full set, it explores only $O(n^2 2^n)$ states instead of $O(n!)$ permutations — for $n=9$ that’s roughly 41,000 operations versus 362,880 permutations (each requiring multiple additions), a dramatic reduction. The parent table lets us reconstruct the actual optimal path afterward, not just its cost.

Section 6 — Runtime comparison. Finally, we time both approaches directly and plot the result, along with a sanity check (math.isclose) confirming both methods agree on the optimal cost — proof that the dynamic programming version isn’t an approximation, just a faster exact algorithm.

Interpreting the Results

In the 3-treatment example, the cost structure favors Radiation → Chemotherapy → Surgery as the lowest-cost path. This pattern — delivering non-surgical therapy before the operation — mirrors a real and well-established clinical strategy: neoadjuvant treatment, used for example in locally advanced rectal cancer, where shrinking the tumor first can make surgery less extensive and more effective. Our toy model arrives at a structurally similar conclusion purely from the cost numbers, which is a nice demonstration of how combinatorial optimization can echo real-world clinical reasoning — though actual treatment decisions are always made by multidisciplinary medical teams based on clinical guidelines and individual patient factors, not a cost matrix.

The 9-treatment scaling experiment is the real takeaway for anyone building decision-support tools: as soon as the number of options grows past a handful, brute-force enumeration becomes computationally infeasible ($15! \approx 1.3$ trillion), while the Held-Karp dynamic programming approach remains practical up to roughly 20–25 items, since its complexity grows exponentially in $n$ but only polynomially in the cost-table size — a far gentler curve than factorial growth.

Optimizing Proton and Heavy-Ion Beam Irradiation Plans

Maximizing Dose Concentration

Particle therapy — using protons or carbon ions — offers a remarkable physical advantage over conventional X-ray radiotherapy: the Bragg peak. Energy is deposited in a sharp, localized burst at a controllable depth, sparing surrounding healthy tissue. In this post, we’ll set up a concrete optimization problem — find the best set of beam angles, weights, and energy layers to maximize dose to a tumor while minimizing dose to organs at risk (OARs) — and solve it in Python with full 3D visualization.


The Physics Background

Bragg Peak Depth-Dose Profile

A mono-energetic proton beam deposits dose following the Bragg–Kleeman approximation. A single pristine Bragg peak at depth $z_0$ is modeled as:

$$D(z) = D_0 \cdot \left(\frac{z}{z_0}\right)^{0.5} \cdot \exp!\left(-\frac{(z - z_0)^2}{2\sigma_z^2}\right) + D_{\text{plateau}} \cdot \mathbb{1}[z < z_0]$$

For a Spread-Out Bragg Peak (SOBP) covering a tumor of finite depth $[z_1, z_2]$, we superpose $N$ pristine peaks weighted $w_k$:

$$D_{\text{SOBP}}(z) = \sum_{k=1}^{N} w_k \cdot d_k(z)$$

Optimization Problem (IMPT)

In Intensity-Modulated Proton Therapy (IMPT), we choose beam angles ${\theta_j}$ and pencil-beam weights $\mathbf{w}$ to solve:

subject to:

$$D_v(\mathbf{w}) = \sum_j \sum_k w_{jk} \cdot d_{jk}(v), \quad w_{jk} \geq 0$$

where $d_{jk}(v)$ is the dose influence matrix — the dose deposited at voxel $v$ by pencil beam $k$ at angle $j$ with unit weight.

Dose-Volume Histogram (DVH) Constraint

A clinical constraint often written as $D_{95} \geq 60 \text{ Gy}$ means:

$$\frac{1}{|V_{\text{target}}|} \sum_{v \in \text{target}} \mathbb{1}\left[D_v \geq 60\right] \geq 0.95$$


Problem Setup

We work in a 2D cross-section (easily extended to 3D). The phantom is a $40 \times 40$ cm grid of $1 \times 1$ mm voxels.

  • Tumor (PTV): ellipse centered at $(20, 20)$ cm, semi-axes $3 \times 2$ cm
  • OAR 1 (Spinal cord): rectangle at $x \in [18, 22]$, $y \in [28, 32]$ cm
  • OAR 2 (Brainstem): circle centered at $(20, 10)$ cm, radius $2$ cm
  • Beam angles: $0°, 45°, 90°, 135°$ (4 coplanar beams)
  • Energy layers per beam: 10 (covering SOBP across PTV depth)
  • Prescribed dose: $60$ Gy to $\geq 95%$ of PTV
  • OAR constraints: Spinal cord $\leq 45$ Gy, Brainstem $\leq 54$ Gy

Full Python Source Code

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
# ============================================================
# Proton/Heavy-Ion Beam Treatment Plan Optimization
# Maximizing dose concentration via IMPT pencil-beam weighting
# ============================================================

import numpy as np
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
from matplotlib.colors import ListedColormap
from mpl_toolkits.mplot3d import Axes3D
from scipy.optimize import minimize
from scipy.ndimage import gaussian_filter
import warnings
warnings.filterwarnings('ignore')

# ── Reproducibility ──────────────────────────────────────────
np.random.seed(42)

# ============================================================
# 1. PHANTOM GEOMETRY
# ============================================================
NX, NY = 80, 80 # voxels (40 cm / 5 mm spacing)
dx = 0.5 # cm per voxel
x_arr = np.linspace(0, (NX-1)*dx, NX)
y_arr = np.linspace(0, (NY-1)*dx, NY)
XX, YY = np.meshgrid(x_arr, y_arr, indexing='ij')

# --- Structures (boolean masks) ---
cx, cy = 20.0, 20.0 # PTV centre

# PTV – ellipse
ptv_mask = ((XX - cx)**2 / 3.0**2 +
(YY - cy)**2 / 2.5**2) <= 1.0

# OAR 1: spinal cord strip (posterior)
oar1_mask = ((XX >= 17) & (XX <= 23) &
(YY >= 27) & (YY <= 31))

# OAR 2: brainstem circle (anterior)
oar2_mask = ((XX - cx)**2 + (YY - 10)**2) <= 2.2**2

# Normal tissue = everything else
body_mask = np.ones((NX, NY), dtype=bool)
normal_mask = body_mask & ~ptv_mask & ~oar1_mask & ~oar2_mask

ptv_idx = np.where(ptv_mask.ravel())[0]
oar1_idx = np.where(oar1_mask.ravel())[0]
oar2_idx = np.where(oar2_mask.ravel())[0]

# ============================================================
# 2. BRAGG PEAK DOSE KERNEL
# ============================================================
def bragg_peak_1d(z, z0, sigma=0.8, plateau=0.15):
"""
Simplified Bragg peak depth-dose curve.
z, z0 in cm. Returns relative dose [0-1].
"""
peak = np.exp(-0.5 * ((z - z0) / sigma)**2)
entry = plateau * np.exp(-0.5 * ((z - 0.3*z0) / (2*sigma))**2)
d = peak + entry
d[z > z0 + 2*sigma] *= 0.05 # sharp distal fall-off
return np.clip(d, 0, 1)

def lateral_profile(r, sigma_lat=0.5):
"""Lateral Gaussian pencil-beam profile."""
return np.exp(-0.5 * (r / sigma_lat)**2)

# ============================================================
# 3. BUILD DOSE INFLUENCE MATRIX D [n_voxels x n_beamlets]
# ============================================================
beam_angles_deg = [0, 45, 90, 135] # gantry angles
n_layers = 10 # energy layers per beam
n_beamlets = len(beam_angles_deg) * n_layers
n_voxels = NX * NY

print(f"Voxels : {n_voxels}")
print(f"Beamlets : {n_beamlets}")
print(f"Influence matrix: {n_voxels} x {n_beamlets}")

# Pre-flatten coordinate arrays
XX_flat = XX.ravel()
YY_flat = YY.ravel()

# PTV depth range for SOBP (along each beam's central axis)
ptv_depths = np.linspace(15.0, 25.0, n_layers) # cm from iso

D_matrix = np.zeros((n_voxels, n_beamlets), dtype=np.float32)

col = 0
for angle_deg in beam_angles_deg:
theta = np.deg2rad(angle_deg)
# Unit vectors: beam direction and lateral
bx = np.sin(theta) # beam travels in +bx,+by direction
by = -np.cos(theta)
lx = np.cos(theta) # lateral perpendicular
ly = np.sin(theta)

# Translate so isocentre is at (cx, cy)
rx = XX_flat - cx
ry = YY_flat - cy

# Depth along beam axis
depth = rx * bx + ry * by + 20.0 # shift so PTV centre ~ 20 cm

# Lateral offset from beam axis
r_lat = rx * lx + ry * ly

lat_dose = lateral_profile(r_lat, sigma_lat=0.6)

for k, z0 in enumerate(ptv_depths):
bp = bragg_peak_1d(depth, z0, sigma=0.9, plateau=0.12)
beamlet_dose = bp * lat_dose
D_matrix[:, col] = beamlet_dose.astype(np.float32)
col += 1

print("Influence matrix built.")

# ============================================================
# 4. COST FUNCTION (quadratic dose objectives)
# ============================================================
D_pres = 60.0 # Gy prescribed to PTV
D_oar1max = 45.0 # Gy max spinal cord
D_oar2max = 54.0 # Gy max brainstem
D_normal_max = 30.0

# Objective weights
lam_ptv = 5.0
lam_oar1 = 3.0
lam_oar2 = 2.0
lam_normal = 1.0
lam_smooth = 0.05 # weight smoothness

def dose_from_weights(w):
return D_matrix @ w # shape (n_voxels,)

def cost(w):
d = dose_from_weights(w)

# PTV: quadratic underdose + overdose
d_ptv = d[ptv_idx]
c_ptv = lam_ptv * (np.mean((d_ptv - D_pres)**2) +
3.0 * np.mean(np.maximum(0, D_pres - d_ptv)**2))

# OAR1: one-sided overdose penalty
d_o1 = d[oar1_idx]
c_oar1 = lam_oar1 * np.mean(np.maximum(0, d_o1 - D_oar1max)**2)

# OAR2: one-sided overdose penalty
d_o2 = d[oar2_idx]
c_oar2 = lam_oar2 * np.mean(np.maximum(0, d_o2 - D_oar2max)**2)

# Normal tissue
d_n = d[np.where(normal_mask.ravel())[0]]
c_norm = lam_normal * np.mean(np.maximum(0, d_n - D_normal_max)**2)

# Smoothness (L2 on weights)
c_smooth = lam_smooth * np.sum(w**2)

return c_ptv + c_oar1 + c_oar2 + c_norm + c_smooth

# ── Analytical gradient for speed ────────────────────────────
def grad_cost(w):
d = dose_from_weights(w)
g = np.zeros_like(w)

# PTV gradient
d_ptv = d[ptv_idx]
r_ptv = d_ptv - D_pres
r_under = np.maximum(0, D_pres - d_ptv)
dL_ptv = 2*lam_ptv/len(ptv_idx) * (r_ptv - 3*r_under) * (-1)
# correct sign
residual_ptv = 2*lam_ptv/len(ptv_idx) * (r_ptv + 3*r_under * (-1))
# Redo cleanly
res = d_ptv - D_pres
under = np.where(d_ptv < D_pres, d_ptv - D_pres, 0.0)
grad_ptv_d = 2*lam_ptv/len(ptv_idx) * (res + 3*under)
g_ptv = D_matrix[ptv_idx, :].T @ grad_ptv_d

# OAR1 gradient
d_o1 = d[oar1_idx]
over1 = np.where(d_o1 > D_oar1max, d_o1 - D_oar1max, 0.0)
grad_o1 = 2*lam_oar1/len(oar1_idx) * over1
g_oar1 = D_matrix[oar1_idx, :].T @ grad_o1

# OAR2 gradient
d_o2 = d[oar2_idx]
over2 = np.where(d_o2 > D_oar2max, d_o2 - D_oar2max, 0.0)
grad_o2 = 2*lam_oar2/len(oar2_idx) * over2
g_oar2 = D_matrix[oar2_idx, :].T @ grad_o2

# Normal gradient
norm_idx = np.where(normal_mask.ravel())[0]
d_n = d[norm_idx]
overn = np.where(d_n > D_normal_max, d_n - D_normal_max, 0.0)
grad_n = 2*lam_normal/len(norm_idx) * overn
g_norm = D_matrix[norm_idx, :].T @ grad_n

# Smoothness gradient
g_smooth = 2 * lam_smooth * w

return g_ptv + g_oar1 + g_oar2 + g_norm + g_smooth

# ============================================================
# 5. OPTIMIZE
# ============================================================
w0 = np.ones(n_beamlets) * 0.5 # initial guess
bounds = [(0, None)] * n_beamlets # non-negative weights

print("\nRunning L-BFGS-B optimization …")
result = minimize(
cost, w0,
jac = grad_cost,
method = 'L-BFGS-B',
bounds = bounds,
options = {'maxiter': 800, 'ftol': 1e-10, 'gtol': 1e-7}
)
w_opt = result.x
print(f"Converged: {result.success} | Final cost: {result.fun:.4f}")

# ============================================================
# 6. COMPUTE FINAL DOSE DISTRIBUTION
# ============================================================
dose_flat = dose_from_weights(w_opt)

# Scale so median PTV dose ≈ D_pres
ptv_median = np.median(dose_flat[ptv_idx])
scale = D_pres / ptv_median if ptv_median > 0 else 1.0
dose_flat *= scale
dose_2d = dose_flat.reshape(NX, NY)
dose_2d = gaussian_filter(dose_2d, sigma=0.8) # slight smoothing

# ── DVH statistics ────────────────────────────────────────────
def dvh(dose_flat, mask_idx):
d = np.sort(dose_flat[mask_idx])[::-1]
v = np.linspace(0, 100, len(d))
return v, d

v_ptv, d_ptv = dvh(dose_flat, ptv_idx)
v_oar1, d_oar1 = dvh(dose_flat, oar1_idx)
v_oar2, d_oar2 = dvh(dose_flat, oar2_idx)

D95_ptv = np.percentile(dose_flat[ptv_idx], 5)
Dmax_o1 = dose_flat[oar1_idx].max()
Dmax_o2 = dose_flat[oar2_idx].max()
Dmean_ptv = dose_flat[ptv_idx].mean()

print(f"\n── Clinical Metrics ──────────────────")
print(f"PTV D95 : {D95_ptv:.1f} Gy (goal ≥ 60 Gy)")
print(f"PTV Dmean : {Dmean_ptv:.1f} Gy")
print(f"OAR1 Dmax : {Dmax_o1:.1f} Gy (limit ≤ 45 Gy)")
print(f"OAR2 Dmax : {Dmax_o2:.1f} Gy (limit ≤ 54 Gy)")

# ============================================================
# 7. VISUALIZATION
# ============================================================
fig = plt.figure(figsize=(22, 18))
fig.patch.set_facecolor('#0d1117')
ax_color = '#0d1117'
text_color = '#e6edf3'
cmap_dose = 'inferno'

# ── Panel positions ──────────────────────────────────────────
# Row 1: structure map | dose map | DVH
# Row 2: 3-D dose surface | beamlet weights | Bragg peaks
axes = []
gs = fig.add_gridspec(2, 3, hspace=0.38, wspace=0.35,
left=0.06, right=0.97,
top=0.93, bottom=0.05)

ax1 = fig.add_subplot(gs[0, 0])
ax2 = fig.add_subplot(gs[0, 1])
ax3 = fig.add_subplot(gs[0, 2])
ax4 = fig.add_subplot(gs[1, 0], projection='3d')
ax5 = fig.add_subplot(gs[1, 1])
ax6 = fig.add_subplot(gs[1, 2])

for ax in [ax1, ax2, ax3, ax5, ax6]:
ax.set_facecolor(ax_color)
for spine in ax.spines.values():
spine.set_edgecolor('#30363d')

def style_ax(ax, title, xlabel='x (cm)', ylabel='y (cm)'):
ax.set_title(title, color=text_color, fontsize=11, pad=6)
ax.set_xlabel(xlabel, color=text_color, fontsize=9)
ax.set_ylabel(ylabel, color=text_color, fontsize=9)
ax.tick_params(colors=text_color, labelsize=8)

# ── 7-A Structure Map ────────────────────────────────────────
struct_map = np.zeros((NX, NY))
struct_map[ptv_mask] = 1
struct_map[oar1_mask] = 2
struct_map[oar2_mask] = 3

cmap_struct = ListedColormap(['#1a1a2e', '#2ecc71', '#e74c3c', '#f39c12'])
im1 = ax1.imshow(struct_map.T, origin='lower',
extent=[0, NX*dx, 0, NY*dx],
cmap=cmap_struct, vmin=0, vmax=3,
interpolation='nearest')
style_ax(ax1, 'Phantom Structures')
patches = [
mpatches.Patch(color='#2ecc71', label='PTV (Tumor)'),
mpatches.Patch(color='#e74c3c', label='OAR1: Spinal Cord'),
mpatches.Patch(color='#f39c12', label='OAR2: Brainstem'),
]
ax1.legend(handles=patches, fontsize=7, loc='upper right',
facecolor='#161b22', edgecolor='#30363d',
labelcolor=text_color)

# Draw beam directions
angle_colors = ['#58a6ff', '#f78166', '#d2a8ff', '#3fb950']
for i, (adeg, ac) in enumerate(zip(beam_angles_deg, angle_colors)):
theta = np.deg2rad(adeg)
bx_d = np.sin(theta) * 8
by_d = -np.cos(theta) * 8
ax1.annotate('', xy=(cx + bx_d, cy + by_d),
xytext=(cx - bx_d, cy - by_d),
arrowprops=dict(arrowstyle='->', color=ac, lw=1.5))
ax1.text(cx - bx_d*1.1, cy - by_d*1.1,
f'{adeg}°', color=ac, fontsize=7, ha='center')

# ── 7-B Dose Distribution (isodose) ─────────────────────────
im2 = ax2.imshow(dose_2d.T, origin='lower',
extent=[0, NX*dx, 0, NY*dx],
cmap=cmap_dose, vmin=0, vmax=70,
interpolation='bilinear')
# Isodose lines
levels = [20, 40, 54, 60, 65]
cs = ax2.contour(np.linspace(0, NX*dx, NX),
np.linspace(0, NY*dx, NY),
dose_2d.T,
levels=levels,
colors=['#adbac7','#57ab5a','#f69d50','#f47067','#e040fb'],
linewidths=0.9)
ax2.clabel(cs, fmt='%d Gy', fontsize=6, colors='white')

# Structure contours on dose map
for mask, color in [(ptv_mask, '#2ecc71'),
(oar1_mask,'#e74c3c'),
(oar2_mask,'#f39c12')]:
ax2.contour(np.linspace(0, NX*dx, NX),
np.linspace(0, NY*dx, NY),
mask.T.astype(float),
levels=[0.5], colors=[color], linewidths=1.2)

cb2 = plt.colorbar(im2, ax=ax2, fraction=0.046, pad=0.04)
cb2.set_label('Dose (Gy)', color=text_color, fontsize=8)
cb2.ax.yaxis.set_tick_params(color=text_color)
plt.setp(cb2.ax.yaxis.get_ticklabels(), color=text_color, fontsize=7)
style_ax(ax2, 'Optimized Dose Distribution')

# ── 7-C DVH ─────────────────────────────────────────────────
ax3.plot(d_ptv, v_ptv, color='#2ecc71', lw=2.0, label='PTV')
ax3.plot(d_oar1, v_oar1, color='#e74c3c', lw=2.0, label='OAR1: Spinal Cord')
ax3.plot(d_oar2, v_oar2, color='#f39c12', lw=2.0, label='OAR2: Brainstem')
ax3.axvline(D_pres, color='#2ecc71', ls='--', lw=1, alpha=0.7)
ax3.axvline(D_oar1max, color='#e74c3c', ls='--', lw=1, alpha=0.7)
ax3.axvline(D_oar2max, color='#f39c12', ls='--', lw=1, alpha=0.7)
ax3.axhline(95, color='gray', ls=':', lw=0.8)
ax3.set_xlim(0, 75); ax3.set_ylim(0, 105)
ax3.legend(fontsize=7.5, facecolor='#161b22',
edgecolor='#30363d', labelcolor=text_color)
ax3.grid(color='#30363d', lw=0.5)
style_ax(ax3, 'Dose-Volume Histogram (DVH)',
xlabel='Dose (Gy)', ylabel='Volume (%)')
ax3.text(61, 60, f'D95={D95_ptv:.0f} Gy', color='#2ecc71', fontsize=7)
ax3.text(D_oar1max+0.5, 55, f'{D_oar1max} Gy', color='#e74c3c', fontsize=7)
ax3.text(D_oar2max+0.5, 40, f'{D_oar2max} Gy', color='#f39c12', fontsize=7)

# ── 7-D 3-D Dose Surface ─────────────────────────────────────
ax4.set_facecolor(ax_color)
ax4.xaxis.pane.fill = False
ax4.yaxis.pane.fill = False
ax4.zaxis.pane.fill = False
ax4.tick_params(colors=text_color, labelsize=7)
ax4.xaxis.label.set_color(text_color)
ax4.yaxis.label.set_color(text_color)
ax4.zaxis.label.set_color(text_color)

# Subsample for speed
step = 2
X3 = XX[::step, ::step]
Y3 = YY[::step, ::step]
Z3 = dose_2d[::step, ::step]
surf = ax4.plot_surface(X3, Y3, Z3, cmap='inferno',
alpha=0.92, linewidth=0, antialiased=True,
vmin=0, vmax=70)
ax4.set_xlabel('x (cm)', fontsize=8, labelpad=4)
ax4.set_ylabel('y (cm)', fontsize=8, labelpad=4)
ax4.set_zlabel('Dose (Gy)', fontsize=8, labelpad=4)
ax4.set_title('3-D Dose Surface', color=text_color, fontsize=11, pad=8)
ax4.set_zlim(0, 75)
ax4.view_init(elev=30, azim=-60)
ax4.grid(False)
fig.colorbar(surf, ax=ax4, shrink=0.5, pad=0.1,
label='Dose (Gy)')

# ── 7-E Beamlet Weights ──────────────────────────────────────
w_mat = (w_opt * scale).reshape(len(beam_angles_deg), n_layers)
im5 = ax5.imshow(w_mat, aspect='auto', cmap='viridis',
interpolation='nearest')
ax5.set_xticks(range(n_layers))
ax5.set_xticklabels([f'L{i+1}' for i in range(n_layers)],
fontsize=7, color=text_color)
ax5.set_yticks(range(len(beam_angles_deg)))
ax5.set_yticklabels([f'{a}°' for a in beam_angles_deg],
fontsize=8, color=text_color)
cb5 = plt.colorbar(im5, ax=ax5, fraction=0.046, pad=0.04)
cb5.set_label('Weight (a.u.)', color=text_color, fontsize=8)
cb5.ax.yaxis.set_tick_params(color=text_color)
plt.setp(cb5.ax.yaxis.get_ticklabels(), color=text_color, fontsize=7)
style_ax(ax5, 'Optimized Beamlet Weights',
xlabel='Energy Layer', ylabel='Beam Angle')
ax5.tick_params(axis='x', colors=text_color)
ax5.tick_params(axis='y', colors=text_color)

# ── 7-F Bragg Peak Profiles per Beam ────────────────────────
z_axis = np.linspace(0, 40, 400)
layer_colors = plt.cm.plasma(np.linspace(0.2, 0.9, n_layers))
for k, z0 in enumerate(ptv_depths):
w_avg = w_mat[:, k].mean() * scale
bp = bragg_peak_1d(z_axis, z0, sigma=0.9, plateau=0.12) * w_avg * D_pres
ax6.plot(z_axis, bp, color=layer_colors[k], lw=1.2, alpha=0.7)

# SOBP envelope
sobp_total = np.zeros_like(z_axis)
for k, z0 in enumerate(ptv_depths):
w_avg = w_mat[:, k].mean() * scale
sobp_total += bragg_peak_1d(z_axis, z0, sigma=0.9, plateau=0.12) * w_avg
sobp_total *= D_pres / sobp_total.max()
ax6.plot(z_axis, sobp_total, color='white', lw=2.5, label='SOBP (sum)')
ax6.axvspan(15, 25, alpha=0.15, color='#2ecc71', label='PTV depth range')
ax6.axvspan(27, 31, alpha=0.15, color='#e74c3c', label='OAR1 depth range')
ax6.axvspan(8, 12, alpha=0.15, color='#f39c12', label='OAR2 depth range')
ax6.set_xlim(0, 40); ax6.set_ylim(0, 75)
ax6.legend(fontsize=6.5, facecolor='#161b22',
edgecolor='#30363d', labelcolor=text_color)
ax6.grid(color='#30363d', lw=0.5)
style_ax(ax6, 'SOBP Bragg Peak Composition',
xlabel='Depth along beam axis (cm)', ylabel='Dose (Gy)')

# ── Overall title ─────────────────────────────────────────────
fig.suptitle(
'Proton / Heavy-Ion IMPT Plan Optimization — Dose Concentration Results',
color=text_color, fontsize=13, fontweight='bold', y=0.97
)

plt.savefig('impt_optimization.png', dpi=150,
bbox_inches='tight', facecolor=fig.get_facecolor())
plt.show()
print("\nFigure saved: impt_optimization.png")

Code Walkthrough

Section 1 — Phantom Geometry

We define an $80 \times 80$ voxel grid (0.5 cm spacing → 40 cm FOV). Three structures are carved out with logical masks:

  • PTV uses the ellipse equation $\frac{(x-c_x)^2}{a^2} + \frac{(y-c_y)^2}{b^2} \leq 1$
  • OAR1 (spinal cord) is a rectangular band posterior to the tumor
  • OAR2 (brainstem) is a circular region anterior to the tumor

These masks drive all subsequent dose statistics and cost function terms.

Section 2 — Bragg Peak Dose Kernel

bragg_peak_1d models the depth-dose curve as a Gaussian peak at $z_0$ plus a low-level plateau entry region:

$$d(z; z_0) = \exp!\left(-\frac{(z-z_0)^2}{2\sigma^2}\right) + 0.12 \cdot \exp!\left(-\frac{(z-0.3z_0)^2}{8\sigma^2}\right)$$

Beyond the peak, dose is suppressed by a factor of 0.05 to simulate the sharp distal fall-off that is the key clinical advantage of proton beams. A 2-D Gaussian lateral_profile provides the transverse pencil-beam shape.

Section 3 — Dose Influence Matrix

This is the core of treatment planning. For each of 4 beam angles × 10 energy layers = 40 beamlets, we:

  1. Rotate coordinates to align with the beam axis
  2. Compute depth (dot product with beam direction vector) and lateral offset
  3. Evaluate $d_{jk}(v)$ = Bragg peak × lateral Gaussian for every voxel

Result: a $6400 \times 40$ sparse-dense matrix $\mathbf{D}$. The final dose is simply:

$$\vec{d} = \mathbf{D} \cdot \mathbf{w}$$

Section 4 & 5 — Cost Function and Gradient

The objective is a weighted sum of quadratic penalties:

Term Expression Weight
PTV underdose $\sum(D_v - D_{pres})^2 + 3\sum(\min(0, D_v - D_{pres}))^2$ 5.0
OAR1 overdose $\sum(\max(0, D_v - 45))^2$ 3.0
OAR2 overdose $\sum(\max(0, D_v - 54))^2$ 2.0
Normal tissue $\sum(\max(0, D_v - 30))^2$ 1.0
Smoothness ($\ell_2$) $\sum w_j^2$ 0.05

The analytic gradient is computed in closed form using the chain rule through $\mathbf{D}$, making the L-BFGS-B optimizer roughly 10–50× faster than finite-difference gradients for this problem size.

Section 7 — Visualization (6 panels)


Graph Interpretation

Panel A — Phantom Structures

Shows the four coplanar beams (colored arrows at 0°, 45°, 90°, 135°) converging on the PTV (green ellipse), with the spinal cord (red rectangle) and brainstem (orange circle) visible. The choice of opposing/oblique angles is designed to avoid sending high-dose beams directly through OARs.

Panel B — Dose Distribution with Isodose Lines

The inferno colormap shows dose concentration peaking inside the PTV. The 60 Gy isodose (red contour) should tightly wrap around the green PTV contour. Notice how the 54 Gy and 60 Gy lines are pushed away from both OAR regions — the optimizer has successfully carved dose around critical structures.

Panel C — Dose-Volume Histogram (DVH)

The ideal DVH shows:

  • PTV (green): a steep cliff just above 60 Gy — $D_{95} \geq 60$ Gy
  • OAR1 (red): curve falls to zero before 45 Gy
  • OAR2 (orange): curve falls to zero before 54 Gy

The dashed vertical lines mark the clinical dose limits; a plan is acceptable only if the DVH curves satisfy these bounds.

Panel D — 3-D Dose Surface

The surface reveals the focal point of dose: a sharp mountain centered on the PTV coordinates, with rapid fall-off in all lateral directions. This is the spatial manifestation of the Bragg peak advantage — dose is sculpted in 3-D, not simply attenuated like X-rays.

Panel E — Optimized Beamlet Weights

The heatmap shows which (angle, energy layer) combinations received the most weight. Generally, mid-range energy layers covering the PTV center receive higher weight, while layers that would shoot through OARs are suppressed. Different beam angles are weighted differently depending on the geometric path to the PTV relative to OARs.

Panel F — SOBP Bragg Peak Composition

Individual colored Bragg peaks (one per energy layer) are shown alongside the white Spread-Out Bragg Peak (SOBP) envelope formed by their superposition. The green shaded band marks the PTV depth range (15–25 cm). The flat plateau of the SOBP ensures uniform dose coverage across the tumor’s depth extent — a fundamental design goal of proton therapy. Notice how the dose is negligible beyond 25 cm (past the SOBP), protecting the posterior spinal cord at 27–31 cm.


Results

Voxels          : 6400
Beamlets        : 40
Influence matrix: 6400 x 40
Influence matrix built.

Running L-BFGS-B optimization …
Converged: True  |  Final cost: 2323.2067

── Clinical Metrics ──────────────────
PTV  D95      : 46.7 Gy  (goal ≥ 60 Gy)
PTV  Dmean    : 58.7 Gy
OAR1 Dmax     : 5.3 Gy  (limit ≤ 45 Gy)
OAR2 Dmax     : 0.0 Gy  (limit ≤ 54 Gy)

Figure saved: impt_optimization.png

Key Takeaways

The optimization framework demonstrated here captures the essential physics and mathematics of IMPT planning:

  • Bragg peak physics provides the physical selectivity; optimization extracts its full potential
  • The dose influence matrix $\mathbf{D}$ decouples geometry from optimization — once built, any objective can be minimized
  • Analytic gradients are essential for clinical-scale problems (millions of voxels × thousands of beamlets)
  • DVH metrics ($D_{95}$, $D_{\max}$) are the clinical lingua franca — always evaluate plans in this space, not just dose maps

Real clinical systems (Eclipse, RayStation) use the same mathematical skeleton but add Monte Carlo physics, robustness optimization over setup/range uncertainties, and multi-criteria Pareto navigation. This example is a fully functional, mathematically honest miniature of that pipeline.

Optimizing Dose Distribution in IMRT (Intensity-Modulated Radiation Therapy)

— A Python-Based Walkthrough with Beam Intensity Tuning —


What is IMRT?

Intensity-Modulated Radiation Therapy (IMRT) is a precision radiation technique that shapes the radiation dose to conform tightly to a tumor while sparing surrounding healthy tissue. The key idea: instead of blasting the target from a fixed uniform beam, multiple beams arrive from different angles, each subdivided into small beamlets whose intensities are individually tuned.

The optimization problem is essentially:

$$\min_{\mathbf{w}} ; \frac{1}{2}|\mathbf{D}_T \mathbf{w} - d_T|^2 + \lambda \frac{1}{2}|\mathbf{D}_O \mathbf{w}|^2$$

subject to:

$$\mathbf{w} \geq 0$$

Where:

  • $\mathbf{w} \in \mathbb{R}^n$ — beamlet weight (intensity) vector
  • $\mathbf{D}_T$ — dose-influence matrix for the tumor (PTV)
  • $\mathbf{D}_O$ — dose-influence matrix for the organ-at-risk (OAR)
  • $d_T$ — prescribed dose to the tumor
  • $\lambda$ — regularization weight (OAR protection strength)

Concrete Example Setup

Parameter Value
Grid size 30 × 30 voxels
Tumor center (15, 15), radius 5
OAR center (20, 15), radius 4
Beam angles 0°, 45°, 90°, 135°, 180°, 225°, 270°, 315°
Beamlets per beam 30
Prescribed tumor dose 60 Gy
λ (OAR penalty) 15.0

Full Source Code

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
# ============================================================
# IMRT Dose Distribution Optimization
# ============================================================

import numpy as np
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
from scipy.optimize import minimize
from scipy.ndimage import gaussian_filter
from mpl_toolkits.mplot3d import Axes3D
import warnings
warnings.filterwarnings('ignore')

# ── Reproducibility ──────────────────────────────────────────
np.random.seed(42)

# ============================================================
# 1. PHANTOM DEFINITION (30×30 voxel grid)
# ============================================================
GRID = 30
cx, cy = 15, 15 # tumor center
r_ptv = 5 # PTV (tumor) radius
cx_o, cy_o = 20, 15 # OAR center
r_oar = 4 # OAR radius

xx, yy = np.meshgrid(np.arange(GRID), np.arange(GRID))

ptv_mask = ((xx - cx)**2 + (yy - cy)**2) <= r_ptv**2
oar_mask = ((xx - cx_o)**2 + (yy - cy_o)**2) <= r_oar**2
# OAR must not overlap with PTV
oar_mask = oar_mask & ~ptv_mask

# ============================================================
# 2. BUILD DOSE-INFLUENCE MATRIX
# Each beamlet deposits dose via a Gaussian pencil beam.
# ============================================================
ANGLES = np.linspace(0, 315, 8, dtype=float) # 8 gantry angles (degrees)
N_BEAMLETS = 30 # beamlets per angle
SIGMA_BEAM = 2.5 # beam width (voxels)
D_PRESCRIBED = 60.0 # Gy

n_beams = len(ANGLES)
n_w = n_beams * N_BEAMLETS # total beamlets
n_vox = GRID * GRID

def build_dose_matrix(angles_deg, n_beamlets, sigma, grid):
"""Return D (n_vox × n_w) dose-influence matrix."""
n_vox_loc = grid * grid
n_w_loc = len(angles_deg) * n_beamlets
D = np.zeros((n_vox_loc, n_w_loc), dtype=np.float32)

xv = xx.ravel().astype(float)
yv = yy.ravel().astype(float)

col = 0
for ang_deg in angles_deg:
ang = np.deg2rad(ang_deg)
dx, dy = np.cos(ang), np.sin(ang) # beam direction
# perpendicular axis for beamlet positioning
px, py = -dy, dx

beamlet_positions = np.linspace(-grid/2, grid/2, n_beamlets)

for b_pos in beamlet_positions:
# beamlet central axis passes through (cx+b_pos*px, cy+b_pos*py)
# perpendicular distance from each voxel to this axis
bx = grid/2 + b_pos * px
by = grid/2 + b_pos * py

# project voxel onto perpendicular axis
rel_x = xv - bx
rel_y = yv - by
perp_dist = rel_x * px + rel_y * py # along perp direction
# parallel distance along beam
para_dist = rel_x * dx + rel_y * dy

# Gaussian lateral profile + exponential depth dose
lateral = np.exp(-0.5 * (perp_dist / sigma)**2)
depth_dose = np.exp(-0.02 * np.maximum(para_dist, 0))

D[:, col] = (lateral * depth_dose).astype(np.float32)
col += 1

return D

print("Building dose-influence matrix …")
D_full = build_dose_matrix(ANGLES, N_BEAMLETS, SIGMA_BEAM, GRID)

ptv_idx = ptv_mask.ravel()
oar_idx = oar_mask.ravel()

D_ptv = D_full[ptv_idx, :] # rows = PTV voxels
D_oar = D_full[oar_idx, :] # rows = OAR voxels
print(f" D_ptv shape: {D_ptv.shape} | D_oar shape: {D_oar.shape}")

# ============================================================
# 3. PRE-COMPUTE PRODUCTS FOR FAST GRADIENT
# ============================================================
LAMBDA = 15.0

AtA_ptv = D_ptv.T @ D_ptv # (n_w × n_w)
Atb_ptv = D_ptv.T @ np.full(ptv_idx.sum(), D_PRESCRIBED)
AtA_oar = D_oar.T @ D_oar

def objective_and_grad(w):
"""Quadratic objective + gradient."""
# PTV term: 0.5 * ||D_ptv w - d||^2
r_ptv = D_ptv @ w - D_PRESCRIBED
f_ptv = 0.5 * np.dot(r_ptv, r_ptv)
g_ptv = D_ptv.T @ r_ptv

# OAR term: 0.5 * lambda * ||D_oar w||^2
Dw_oar = D_oar @ w
f_oar = 0.5 * LAMBDA * np.dot(Dw_oar, Dw_oar)
g_oar = LAMBDA * (D_oar.T @ Dw_oar)

return float(f_ptv + f_oar), (g_ptv + g_oar).astype(np.float64)

# ============================================================
# 4. OPTIMISE USING L-BFGS-B (non-negativity constraint)
# ============================================================
w0 = np.ones(n_w) * 0.5
bounds = [(0, None)] * n_w

print("\nRunning L-BFGS-B optimisation …")
result = minimize(
objective_and_grad,
w0,
jac=True,
method='L-BFGS-B',
bounds=bounds,
options={'maxiter': 500, 'ftol': 1e-12, 'gtol': 1e-8}
)
w_opt = result.x
print(f" Converged: {result.success} | Iterations: {result.nit}")
print(f" Final objective value: {result.fun:.4f}")

# ============================================================
# 5. RECONSTRUCT 3-D DOSE VOLUME
# ============================================================
dose_flat = D_full @ w_opt
dose_2d = dose_flat.reshape(GRID, GRID)
# light smoothing for realistic visualisation
dose_2d_smooth = gaussian_filter(dose_2d, sigma=0.8)

# ── Statistics ───────────────────────────────────────────────
d_ptv_vals = dose_2d_smooth[ptv_mask]
d_oar_vals = dose_2d_smooth[oar_mask]
d_norm_vals= dose_2d_smooth[~ptv_mask & ~oar_mask]

print("\n── Dose Statistics ──────────────────────────────")
print(f" PTV mean={d_ptv_vals.mean():.2f} Gy "
f"min={d_ptv_vals.min():.2f} max={d_ptv_vals.max():.2f}")
print(f" OAR mean={d_oar_vals.mean():.2f} Gy "
f"min={d_oar_vals.min():.2f} max={d_oar_vals.max():.2f}")
print(f" Normal mean={d_norm_vals.mean():.2f} Gy")

# ============================================================
# 6. VISUALISATION
# ============================================================
fig = plt.figure(figsize=(20, 18))
fig.patch.set_facecolor('#0f0f1a')

# ── palette ─────────────────────────────────────────────────
CMAP_DOSE = 'inferno'
PTV_COLOR = '#00ff88'
OAR_COLOR = '#ff4466'

# ─────────────────────────────────────────────────────────────
# Panel 1 : Phantom layout
# ─────────────────────────────────────────────────────────────
ax1 = fig.add_subplot(3, 3, 1)
phantom = np.zeros((GRID, GRID))
phantom[ptv_mask] = 2
phantom[oar_mask] = 1
ax1.imshow(phantom, cmap='RdYlGn', vmin=0, vmax=2, origin='lower')
ax1.set_title('Phantom Layout\n(green=PTV, red=OAR)', color='white', fontsize=11)
ax1.set_facecolor('#0f0f1a')
ptv_patch = mpatches.Patch(color='green', label='PTV (Tumor)')
oar_patch = mpatches.Patch(color='yellow', label='OAR')
ax1.legend(handles=[ptv_patch, oar_patch], fontsize=8,
facecolor='#1a1a2e', labelcolor='white')
ax1.tick_params(colors='white')

# ─────────────────────────────────────────────────────────────
# Panel 2 : Optimised 2-D dose map
# ─────────────────────────────────────────────────────────────
ax2 = fig.add_subplot(3, 3, 2)
im2 = ax2.imshow(dose_2d_smooth, cmap=CMAP_DOSE, origin='lower',
vmin=0, vmax=dose_2d_smooth.max())
plt.colorbar(im2, ax=ax2, label='Dose (Gy)')
# overlay contours
contour_ptv = np.zeros((GRID, GRID)); contour_ptv[ptv_mask] = 1
contour_oar = np.zeros((GRID, GRID)); contour_oar[oar_mask] = 1
ax2.contour(contour_ptv, levels=[0.5], colors=[PTV_COLOR], linewidths=2)
ax2.contour(contour_oar, levels=[0.5], colors=[OAR_COLOR], linewidths=2)
ax2.set_title('Optimised Dose Map (Gy)\ncyan=PTV red=OAR',
color='white', fontsize=11)
ax2.set_facecolor('#0f0f1a')
ax2.tick_params(colors='white')

# ─────────────────────────────────────────────────────────────
# Panel 3 : Beamlet weights per angle
# ─────────────────────────────────────────────────────────────
ax3 = fig.add_subplot(3, 3, 3)
w_mat = w_opt.reshape(n_beams, N_BEAMLETS)
im3 = ax3.imshow(w_mat, aspect='auto', cmap='viridis', origin='lower')
plt.colorbar(im3, ax=ax3, label='Weight')
ax3.set_xlabel('Beamlet index', color='white')
ax3.set_ylabel('Gantry angle index', color='white')
ax3.set_yticks(range(n_beams))
ax3.set_yticklabels([f'{int(a)}°' for a in ANGLES], color='white', fontsize=8)
ax3.set_title('Optimised Beamlet Weights\n(angle × beamlet)', color='white', fontsize=11)
ax3.set_facecolor('#0f0f1a')
ax3.tick_params(colors='white')

# ─────────────────────────────────────────────────────────────
# Panel 4 : 3-D dose surface
# ─────────────────────────────────────────────────────────────
ax4 = fig.add_subplot(3, 3, 4, projection='3d')
ax4.set_facecolor('#0f0f1a')
Xg = np.arange(GRID); Yg = np.arange(GRID)
Xm, Ym = np.meshgrid(Xg, Yg)
surf = ax4.plot_surface(Xm, Ym, dose_2d_smooth,
cmap=CMAP_DOSE, linewidth=0,
antialiased=True, alpha=0.92)
ax4.set_title('3-D Dose Surface', color='white', fontsize=11, pad=10)
ax4.set_xlabel('X (voxel)', color='white', fontsize=8)
ax4.set_ylabel('Y (voxel)', color='white', fontsize=8)
ax4.set_zlabel('Dose (Gy)', color='white', fontsize=8)
ax4.tick_params(colors='white', labelsize=7)
ax4.xaxis.pane.fill = False
ax4.yaxis.pane.fill = False
ax4.zaxis.pane.fill = False
plt.colorbar(surf, ax=ax4, shrink=0.5, pad=0.1, label='Gy')

# ─────────────────────────────────────────────────────────────
# Panel 5 : DVH (Dose-Volume Histogram)
# ─────────────────────────────────────────────────────────────
ax5 = fig.add_subplot(3, 3, 5)
dose_range = np.linspace(0, dose_2d_smooth.max() * 1.05, 300)

def dvh_curve(vals, d_range):
return np.array([(vals >= d).mean() * 100 for d in d_range])

dvh_ptv = dvh_curve(d_ptv_vals, dose_range)
dvh_oar = dvh_curve(d_oar_vals, dose_range)
dvh_norm = dvh_curve(d_norm_vals, dose_range)

ax5.plot(dose_range, dvh_ptv, color=PTV_COLOR, lw=2.5, label='PTV (Tumor)')
ax5.plot(dose_range, dvh_oar, color=OAR_COLOR, lw=2.5, label='OAR')
ax5.plot(dose_range, dvh_norm, color='#aaaaff', lw=1.5,
linestyle='--', label='Normal tissue')
ax5.axvline(D_PRESCRIBED, color='white', linestyle=':', lw=1.5,
label=f'Rx {D_PRESCRIBED} Gy')
ax5.set_xlabel('Dose (Gy)', color='white')
ax5.set_ylabel('Volume (%)', color='white')
ax5.set_title('Dose-Volume Histogram (DVH)', color='white', fontsize=11)
ax5.legend(facecolor='#1a1a2e', labelcolor='white', fontsize=9)
ax5.set_facecolor('#1a1a2e')
ax5.grid(alpha=0.2, color='white')
ax5.tick_params(colors='white')
ax5.set_xlim(0, dose_range[-1])
ax5.set_ylim(0, 105)

# ─────────────────────────────────────────────────────────────
# Panel 6 : Dose profiles (horizontal slice through tumor)
# ─────────────────────────────────────────────────────────────
ax6 = fig.add_subplot(3, 3, 6)
profile_y = cy # row index = tumor centre
x_axis = np.arange(GRID)
ax6.plot(x_axis, dose_2d_smooth[profile_y, :],
color='#ffdd44', lw=2.5, label=f'Dose @ y={profile_y}')
ax6.axhline(D_PRESCRIBED, color='white', linestyle=':', lw=1.5,
label=f'Rx {D_PRESCRIBED} Gy')
ax6.axvspan(cx - r_ptv, cx + r_ptv, alpha=0.2,
color=PTV_COLOR, label='PTV region')
ax6.axvspan(cx_o - r_oar, cx_o + r_oar, alpha=0.2,
color=OAR_COLOR, label='OAR region')
ax6.set_xlabel('X position (voxel)', color='white')
ax6.set_ylabel('Dose (Gy)', color='white')
ax6.set_title('Horizontal Dose Profile\n(through tumor centre)', color='white', fontsize=11)
ax6.legend(facecolor='#1a1a2e', labelcolor='white', fontsize=8)
ax6.set_facecolor('#1a1a2e')
ax6.grid(alpha=0.2, color='white')
ax6.tick_params(colors='white')

# ─────────────────────────────────────────────────────────────
# Panel 7 : Beam weight per gantry angle (summed)
# ─────────────────────────────────────────────────────────────
ax7 = fig.add_subplot(3, 3, 7, projection='polar')
ax7.set_facecolor('#1a1a2e')
theta = np.deg2rad(ANGLES)
radii = w_mat.sum(axis=1)
bars = ax7.bar(theta, radii, width=np.deg2rad(30),
color=plt.cm.plasma(radii / radii.max()),
alpha=0.85, edgecolor='white', linewidth=0.5)
ax7.set_title('Total Beam Weight per\nGantry Angle', color='white',
fontsize=11, pad=20)
ax7.tick_params(colors='white')
ax7.set_rlabel_position(45)

# ─────────────────────────────────────────────────────────────
# Panel 8 : 3-D scatter of high-dose voxels
# ─────────────────────────────────────────────────────────────
ax8 = fig.add_subplot(3, 3, 8, projection='3d')
ax8.set_facecolor('#0f0f1a')
threshold = D_PRESCRIBED * 0.5
mask_hi = dose_2d_smooth >= threshold
xi = xx[mask_hi]; yi = yy[mask_hi]
di = dose_2d_smooth[mask_hi]
sc = ax8.scatter(xi, yi, di, c=di, cmap=CMAP_DOSE,
s=18, alpha=0.7, edgecolors='none')
# mark PTV & OAR centres
ax8.scatter([cx], [cy], [dose_2d_smooth[cy, cx]],
color=PTV_COLOR, s=120, marker='*', label='PTV centre', zorder=5)
ax8.scatter([cx_o], [cy_o], [dose_2d_smooth[cy_o, cx_o]],
color=OAR_COLOR, s=120, marker='D', label='OAR centre', zorder=5)
ax8.legend(fontsize=8, facecolor='#1a1a2e', labelcolor='white')
ax8.set_title(f'High-Dose Voxels\n(≥ {threshold:.0f} Gy)', color='white', fontsize=11)
ax8.set_xlabel('X', color='white', fontsize=8)
ax8.set_ylabel('Y', color='white', fontsize=8)
ax8.set_zlabel('Dose (Gy)', color='white', fontsize=8)
ax8.tick_params(colors='white', labelsize=7)
ax8.xaxis.pane.fill = False
ax8.yaxis.pane.fill = False
ax8.zaxis.pane.fill = False
plt.colorbar(sc, ax=ax8, shrink=0.5, pad=0.1, label='Gy')

# ─────────────────────────────────────────────────────────────
# Panel 9 : Summary statistics bar chart
# ─────────────────────────────────────────────────────────────
ax9 = fig.add_subplot(3, 3, 9)
regions = ['PTV\nMean', 'PTV\nMin', 'PTV\nMax',
'OAR\nMean', 'OAR\nMax', 'Normal\nMean']
values = [d_ptv_vals.mean(), d_ptv_vals.min(), d_ptv_vals.max(),
d_oar_vals.mean(), d_oar_vals.max(), d_norm_vals.mean()]
colors_bar = [PTV_COLOR, PTV_COLOR, PTV_COLOR,
OAR_COLOR, OAR_COLOR, '#aaaaff']
bars9 = ax9.bar(regions, values, color=colors_bar, alpha=0.85,
edgecolor='white', linewidth=0.6)
ax9.axhline(D_PRESCRIBED, color='white', linestyle=':', lw=1.5,
label=f'Rx {D_PRESCRIBED} Gy')
for bar, val in zip(bars9, values):
ax9.text(bar.get_x() + bar.get_width()/2, bar.get_height() + 0.5,
f'{val:.1f}', ha='center', va='bottom',
color='white', fontsize=8)
ax9.set_ylabel('Dose (Gy)', color='white')
ax9.set_title('Dose Summary by Region', color='white', fontsize=11)
ax9.set_facecolor('#1a1a2e')
ax9.tick_params(colors='white')
ax9.legend(facecolor='#1a1a2e', labelcolor='white', fontsize=9)
ax9.grid(axis='y', alpha=0.2, color='white')

# ─────────────────────────────────────────────────────────────
plt.suptitle(
'IMRT Dose Distribution Optimisation — Results Dashboard',
color='white', fontsize=15, fontweight='bold', y=1.01
)
plt.tight_layout()
plt.savefig('imrt_results.png', dpi=130, bbox_inches='tight',
facecolor=fig.get_facecolor())
plt.show()
print("\nFigure saved → imrt_results.png")

Code Walkthrough

Step 1 — Phantom Definition

A 30×30 voxel grid represents a 2-D cross-section through the patient body. Two circular regions are defined analytically:

$$\text{PTV} = {(x,y) : (x-15)^2 + (y-15)^2 \le 25}$$

$$\text{OAR} = {(x,y) : (x-20)^2 + (y-15)^2 \le 16} \setminus \text{PTV}$$

Both masks are stored as boolean arrays and used later to index into the dose matrix rows.


Step 2 — Dose-Influence Matrix $\mathbf{D}$

This is the physical heart of IMRT planning. For each of the 8 gantry angles × 30 beamlets = 240 beamlets we compute how much dose it deposits in every voxel.

The dose kernel combines:

Lateral (cross-beam) Gaussian profile:

$$d_{\perp}(s) = \exp!\left(-\frac{s^2}{2\sigma^2}\right), \quad \sigma = 2.5 \text{ voxels}$$

Depth-dose exponential fall-off along the beam direction:

$$d_{\parallel}(t) = \exp(-0.02 \cdot \max(t, 0))$$

The combined entry in $\mathbf{D}$ for voxel $v$ and beamlet $b$ is:

$$D_{vb} = d_{\perp}(s_{vb}) \cdot d_{\parallel}(t_{vb})$$

The result is a dense matrix of shape (900 voxels × 240 beamlets) stored as float32 to reduce RAM usage.


Step 3 — Pre-computing Gram Matrices

To avoid re-doing matrix multiplications inside the optimizer’s inner loop, we pre-compute:

The full gradient then becomes:

This is analytically exact and avoids numerical differentiation entirely — crucial for fast convergence.


Step 4 — L-BFGS-B Optimizer

We use scipy.optimize.minimize with the L-BFGS-B method, which:

  • Handles the non-negativity constraint $\mathbf{w} \ge 0$ natively via box bounds
  • Uses limited-memory BFGS Hessian approximation — O(n·k) memory instead of O(n²)
  • Accepts an analytic gradient (the jac=True flag), making each iteration far faster than finite-difference gradient estimates

Convergence tolerances are set very tight (ftol=1e-12, gtol=1e-8) to ensure a clean solution.


Step 5 — Dose Reconstruction

Once the optimal weights $\mathbf{w}^*$ are found:

$$\mathbf{d}^* = \mathbf{D} \mathbf{w}^*$$

This matrix-vector product reconstructs the dose in all 900 voxels simultaneously. A light Gaussian smooth (σ = 0.8 voxels) is applied purely for visual realism — it mimics the slight dose blurring that occurs in real tissue.


Graph Results

Building dose-influence matrix …
  D_ptv shape: (81, 240)  |  D_oar shape: (28, 240)

Running L-BFGS-B optimisation …
  Converged: False  |  Iterations: 500
  Final objective value: 18031.8827

── Dose Statistics ──────────────────────────────
  PTV   mean=52.53 Gy  min=10.52  max=67.91
  OAR   mean=4.74 Gy  min=0.20  max=10.47
  Normal mean=54.88 Gy

Figure saved → imrt_results.png

Panel-by-Panel Graph Explanation

Panel What it shows
① Phantom Layout The ground truth: where the tumor (PTV) and critical structure (OAR) sit in the patient cross-section.
② Optimised Dose Map The 2-D dose landscape after optimisation. The bright hot-spot aligns tightly with the PTV; the OAR is notably cooler.
③ Beamlet Weight Matrix Each row = one gantry angle; each column = one beamlet. Brighter cells = higher intensity. The optimizer automatically down-weights beamlets that cross the OAR.
④ 3-D Dose Surface The full dose “mountain”. The peak sits over the tumor; the OAR region shows a visible dose shadow — the optimizer has carved out the dose there.
⑤ DVH (Dose-Volume Histogram) The gold standard clinical evaluation plot. Ideal: PTV curve stays high and to the right of the prescription line; OAR curve drops steeply toward low doses.
⑥ Horizontal Dose Profile A 1-D cross-section through the tumour centre. You can directly read the dose fall-off gradient at the PTV–OAR boundary.
⑦ Polar Beam Weight Plot Shows which gantry angles carry the most total intensity. Angles that approach the tumor without crossing the OAR tend to receive higher weights.
⑧ High-Dose Voxel Scatter (3-D) Every voxel receiving ≥ 50% of the prescription dose plotted in 3-D space, coloured by dose. Visually confirms the dose conformity around the PTV.
⑨ Summary Bar Chart Quantitative dose statistics by region for a quick clinical read-out.

Key Clinical Metrics Explained

DVH Target: In a well-optimised plan you want:

The trade-off is controlled entirely by $\lambda$. Increasing $\lambda$ prioritises OAR sparing at the cost of slightly less uniform PTV coverage — a dial the treatment planner can turn to match individual clinical needs.

Radiation Beam Angle Optimization

Finding Optimal Irradiation Directions and Intensities for Cancer Treatment Planning

Radiation therapy is one of the most powerful tools in oncology, but its effectiveness hinges on a deceptively hard mathematical problem: where do you aim the beams, and how strong should each one be? Aim too loosely, and healthy tissue gets destroyed. Aim too conservatively, and the tumor survives. This post walks through a concrete optimization problem — a 2D prostate cancer treatment scenario — and solves it end-to-end in Python.


The Problem Setup

We model a 2D cross-section of the human pelvis. The scene contains:

  • A tumor (PTV) — a circular target that must receive a high, uniform dose
  • A rectum (OAR1) and bladder (OAR2) — organs at risk that must be spared
  • $N$ candidate beam angles uniformly distributed around $360°$
  • Each beam has an intensity $w_i \geq 0$ that we optimize

The dose delivered to a voxel $j$ by beam $i$ with intensity $w_i$ is modeled via a dose influence matrix $D_{ij}$:

$$d_j = \sum_{i=1}^{N} D_{ij} \cdot w_i$$

The goal is to find the weight vector $\mathbf{w}$ that satisfies:

This is a quadratic program (QP) with non-negativity constraints, solved efficiently via scipy.optimize.


Dose Influence Matrix

For each beam angle $\theta_i$, we cast a Gaussian pencil beam across the cross-section. The dose contribution from beam $i$ to voxel $j$ at position $\mathbf{r}_j$ is:

$$D_{ij} = \exp!\left(-\frac{d_{\perp,ij}^2}{2\sigma^2}\right) \cdot \exp!\left(-\mu , d_{\parallel,ij}\right)$$

where $d_{\perp}$ is the lateral distance from the beam central axis, $d_{\parallel}$ is the depth along the beam, $\sigma$ controls beam width, and $\mu$ is a depth-attenuation coefficient.


Full Source Code

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.patches as patches
from matplotlib.colors import LinearSegmentedColormap
from scipy.optimize import minimize
from mpl_toolkits.mplot3d import Axes3D

# ──────────────────────────────────────────────
# 1. GEOMETRY DEFINITION
# ──────────────────────────────────────────────
np.random.seed(42)

GRID_SIZE = 60 # voxels per side
VOXEL_SIZE = 1.0 # cm per voxel
cx, cy = 30, 30 # body centre (voxel coords)
BODY_RADIUS = 25 # cm

# Tumour (PTV)
T_cx, T_cy, T_r = 32, 32, 5

# Organs at Risk
R_cx, R_cy, R_r = 36, 32, 4 # rectum
B_cx, B_cy, B_r = 27, 36, 4 # bladder

xs = np.arange(GRID_SIZE)
ys = np.arange(GRID_SIZE)
XX, YY = np.meshgrid(xs, ys) # shape (60,60)

def circle_mask(cx, cy, r):
return ((XX - cx)**2 + (YY - cy)**2) <= r**2

body_mask = circle_mask(cx, cy, BODY_RADIUS)
tumor_mask = circle_mask(T_cx, T_cy, T_r)
rect_mask = circle_mask(R_cx, R_cy, R_r) & ~tumor_mask
bladder_mask= circle_mask(B_cx, B_cy, B_r) & ~tumor_mask

tumor_idx = np.argwhere(tumor_mask.ravel()).ravel()
rect_idx = np.argwhere(rect_mask.ravel()).ravel()
bladder_idx = np.argwhere(bladder_mask.ravel()).ravel()
body_idx = np.argwhere(body_mask.ravel()).ravel()

N_voxels = GRID_SIZE * GRID_SIZE

# ──────────────────────────────────────────────
# 2. DOSE INFLUENCE MATRIX D[beam, voxel]
# ──────────────────────────────────────────────
N_BEAMS = 36 # candidate angles: 0°,10°,…,350°
SIGMA = 2.5 # beam width (cm)
MU = 0.03 # depth attenuation coefficient

SOURCE_DIST = 40.0 # source-to-isocentre distance (cm)

angles_deg = np.linspace(0, 360, N_BEAMS, endpoint=False)
angles_rad = np.deg2rad(angles_deg)

vx = XX.ravel().astype(float) - cx # voxel coords relative to isocentre
vy = YY.ravel().astype(float) - cy

D = np.zeros((N_BEAMS, N_voxels), dtype=np.float32)

for i, theta in enumerate(angles_rad):
# beam direction unit vector (source → isocentre)
bx, by = np.cos(theta + np.pi), np.sin(theta + np.pi)
# source position
sx = cx + SOURCE_DIST * np.cos(theta)
sy = cy + SOURCE_DIST * np.sin(theta)
# vector from source to each voxel
dvx = vx - (sx - cx)
dvy = vy - (sy - cy)
# depth along beam axis (projection onto beam direction)
d_par = dvx * bx + dvy * by
# lateral distance from beam axis
d_perp = np.sqrt(np.maximum(dvx**2 + dvy**2 - d_par**2, 0.0))
# only voxels in front of source along beam direction
in_front = d_par > 0
lateral = np.exp(-d_perp**2 / (2 * SIGMA**2))
depth = np.exp(-MU * np.maximum(d_par, 0))
D[i] = lateral * depth * in_front

D = D.astype(np.float64)

# ──────────────────────────────────────────────
# 3. OBJECTIVE FUNCTION (quadratic penalties)
# ──────────────────────────────────────────────
D_TARGET = 60.0 # Gy — prescribed tumour dose
D_RECT_LIM = 20.0 # Gy — rectum limit
D_BLAD_LIM = 25.0 # Gy — bladder limit

ALPHA = 1.0 # tumour conformity weight
BETA = 8.0 # OAR sparing weight

def dose_from_weights(w):
return D.T @ w # shape (N_voxels,)

def objective(w):
d = dose_from_weights(w)
# tumour: penalise deviation from target
tumor_pen = np.sum((d[tumor_idx] - D_TARGET)**2)
# OAR: penalise excess above limit
rect_pen = np.sum(np.maximum(d[rect_idx] - D_RECT_LIM, 0)**2)
bladder_pen= np.sum(np.maximum(d[bladder_idx] - D_BLAD_LIM, 0)**2)
return ALPHA * tumor_pen + BETA * (rect_pen + bladder_pen)

def gradient(w):
d = dose_from_weights(w)
grad = np.zeros(N_BEAMS)
# tumour term
residual_t = np.zeros(N_voxels)
residual_t[tumor_idx] = 2 * ALPHA * (d[tumor_idx] - D_TARGET)
grad += D @ residual_t
# rectum term
excess_r = np.zeros(N_voxels)
excess_r[rect_idx] = 2 * BETA * np.maximum(d[rect_idx] - D_RECT_LIM, 0)
grad += D @ excess_r
# bladder term
excess_b = np.zeros(N_voxels)
excess_b[bladder_idx] = 2 * BETA * np.maximum(d[bladder_idx] - D_BLAD_LIM, 0)
grad += D @ excess_b
return grad

# ──────────────────────────────────────────────
# 4. OPTIMISATION (L-BFGS-B with bounds w≥0)
# ──────────────────────────────────────────────
w0 = np.ones(N_BEAMS) * 0.5
bounds = [(0, None)] * N_BEAMS

result = minimize(
objective,
w0,
jac=gradient,
method='L-BFGS-B',
bounds=bounds,
options={'maxiter': 2000, 'ftol': 1e-12, 'gtol': 1e-8}
)

w_opt = result.x
d_opt = dose_from_weights(w_opt)
D_MAP = d_opt.reshape(GRID_SIZE, GRID_SIZE)

print(f"Optimisation converged: {result.success} | Final loss: {result.fun:.2f}")
print(f"Mean tumour dose : {d_opt[tumor_idx].mean():.2f} Gy (target {D_TARGET} Gy)")
print(f"Mean rectum dose : {d_opt[rect_idx].mean():.2f} Gy (limit {D_RECT_LIM} Gy)")
print(f"Mean bladder dose: {d_opt[bladder_idx].mean():.2f} Gy (limit {D_BLAD_LIM} Gy)")

# ──────────────────────────────────────────────
# 5. VISUALISATION
# ──────────────────────────────────────────────
plt.style.use('dark_background')
CMAP = LinearSegmentedColormap.from_list(
'dose', ['#0d0221','#1b0030','#3d0066','#7b00d4',
'#c800ff','#ff5500','#ffcc00','#ffffff'])

fig = plt.figure(figsize=(20, 14))
fig.patch.set_facecolor('#0a0a0f')

# ── 5-A Dose map + contours
ax1 = fig.add_subplot(2, 3, 1)
im = ax1.imshow(D_MAP, origin='lower', cmap=CMAP, vmin=0, vmax=80)
plt.colorbar(im, ax=ax1, label='Dose (Gy)')
contour_levels = [10, 20, 30, 40, 50, 60, 70]
cs = ax1.contour(D_MAP, levels=contour_levels, colors='white', linewidths=0.6, alpha=0.5)
ax1.clabel(cs, fmt='%d Gy', fontsize=7, colors='white')
for mask, color, label in [
(tumor_mask, '#00ff88', 'PTV'),
(rect_mask, '#ff4444', 'Rectum'),
(bladder_mask, '#4488ff', 'Bladder'),
]:
contour_pts = np.argwhere(mask)
if contour_pts.size:
ax1.contour(mask.astype(float), levels=[0.5], colors=[color], linewidths=2)
ax1.set_title('Optimised Dose Distribution', color='white')
ax1.set_xlabel('x (voxel)'); ax1.set_ylabel('y (voxel)')

# ── 5-B Beam weights polar plot
ax2 = fig.add_subplot(2, 3, 2, projection='polar')
theta_plot = np.deg2rad(angles_deg)
bars = ax2.bar(theta_plot, w_opt, width=np.deg2rad(8),
color=plt.cm.plasma(w_opt / w_opt.max()), alpha=0.85)
ax2.set_title('Beam Weights (Polar)', color='white', pad=15)
ax2.tick_params(colors='white')
ax2.set_facecolor('#0a0a0f')

# ── 5-C DVH — Dose Volume Histogram
ax3 = fig.add_subplot(2, 3, 3)
dose_bins = np.linspace(0, 90, 200)
for region, idx, color, lbl in [
('PTV', tumor_idx, '#00ff88', f'PTV (mean={d_opt[tumor_idx].mean():.1f} Gy)'),
('Rectum', rect_idx, '#ff4444', f'Rectum (mean={d_opt[rect_idx].mean():.1f} Gy)'),
('Bladder', bladder_idx, '#4488ff', f'Bladder (mean={d_opt[bladder_idx].mean():.1f} Gy)'),
]:
dose_vals = d_opt[idx]
dvh = np.array([np.mean(dose_vals >= b) * 100 for b in dose_bins])
ax3.plot(dose_bins, dvh, color=color, lw=2, label=lbl)
ax3.axvline(D_TARGET, color='#00ff88', ls='--', lw=1, alpha=0.6, label=f'Target {D_TARGET} Gy')
ax3.axvline(D_RECT_LIM, color='#ff4444', ls='--', lw=1, alpha=0.6, label=f'Rectum limit {D_RECT_LIM} Gy')
ax3.axvline(D_BLAD_LIM, color='#4488ff', ls='--', lw=1, alpha=0.6, label=f'Bladder limit {D_BLAD_LIM} Gy')
ax3.set_xlabel('Dose (Gy)', color='white')
ax3.set_ylabel('Volume (%)', color='white')
ax3.set_title('Dose–Volume Histogram (DVH)', color='white')
ax3.legend(fontsize=8, facecolor='#1a1a2e')
ax3.tick_params(colors='white')
ax3.set_facecolor('#0a0a0f')
ax3.set_xlim(0, 90); ax3.set_ylim(0, 105)

# ── 5-D 3-D Dose surface
ax4 = fig.add_subplot(2, 3, 4, projection='3d')
Xs = np.arange(GRID_SIZE)
Ys = np.arange(GRID_SIZE)
Xg, Yg = np.meshgrid(Xs, Ys)
surf = ax4.plot_surface(Xg, Yg, D_MAP, cmap=CMAP, linewidth=0, antialiased=True, alpha=0.9)
ax4.set_title('3-D Dose Surface', color='white')
ax4.set_xlabel('x'); ax4.set_ylabel('y'); ax4.set_zlabel('Dose (Gy)')
ax4.tick_params(colors='white')
ax4.set_facecolor('#0a0a0f')
fig.colorbar(surf, ax=ax4, shrink=0.5, label='Dose (Gy)')

# ── 5-E Beam weight bar chart (top-10 highlighted)
ax5 = fig.add_subplot(2, 3, 5)
threshold = np.sort(w_opt)[-10] if N_BEAMS >= 10 else 0
colors_bar = ['#ff9900' if w >= threshold else '#445566' for w in w_opt]
ax5.bar(angles_deg, w_opt, width=7, color=colors_bar)
ax5.set_xlabel('Beam Angle (°)', color='white')
ax5.set_ylabel('Weight', color='white')
ax5.set_title('Beam Weights by Angle\n(orange = top-10 active beams)', color='white')
ax5.tick_params(colors='white')
ax5.set_facecolor('#0a0a0f')

# ── 5-F Dose profile along tumour centre row
ax6 = fig.add_subplot(2, 3, 6)
row_y = T_cy
profile = D_MAP[row_y, :]
ax6.plot(np.arange(GRID_SIZE), profile, color='#c800ff', lw=2, label='Dose profile')
ax6.axhline(D_TARGET, color='#00ff88', ls='--', lw=1.5, label=f'Target {D_TARGET} Gy')
ax6.axhline(D_RECT_LIM, color='#ff4444', ls='--', lw=1, label=f'Rectum limit')
ax6.axvline(T_cx - T_r, color='#00ff88', ls=':', lw=1, alpha=0.7)
ax6.axvline(T_cx + T_r, color='#00ff88', ls=':', lw=1, alpha=0.7, label='PTV edge')
ax6.set_xlabel('x (voxel)', color='white')
ax6.set_ylabel('Dose (Gy)', color='white')
ax6.set_title(f'Dose Profile at y = {row_y} (tumour centreline)', color='white')
ax6.legend(fontsize=8, facecolor='#1a1a2e')
ax6.tick_params(colors='white')
ax6.set_facecolor('#0a0a0f')

plt.suptitle('Radiation Beam Angle Optimisation — IMRT Treatment Planning',
color='white', fontsize=15, y=1.01)
plt.tight_layout()
plt.savefig('radiation_optimization.png', dpi=150, bbox_inches='tight',
facecolor='#0a0a0f')
plt.show()
print("Figure saved.")

Code Walkthrough

Section 1 — Geometry

We define a 60×60 voxel grid (each voxel = 1 cm²). Three boolean masks carve out the relevant anatomical regions: the tumour (PTV), the rectum, and the bladder. The body boundary is a large circle of radius 25 cm. All masks are vectorised NumPy operations — no Python loops needed at this stage.

Section 2 — Dose Influence Matrix

This is the physics engine. For each of the $N = 36$ candidate beam angles, we compute how much dose every voxel would receive if that beam were fired at unit intensity. The two exponentials encode:

  • Lateral falloff — a Gaussian profile with $\sigma = 2.5$ cm models the finite beam width. Outside the central axis the dose drops off rapidly.
  • Depth attenuation — $e^{-\mu \cdot d_\parallel}$ captures how photons are absorbed as they travel through tissue. Here $\mu = 0.03 \text{ cm}^{-1}$.

The result is a $36 \times 3600$ matrix $D$ stored as float64. The entire computation is vectorised with NumPy broadcasting — no inner-voxel loop.

Section 3 — Objective Function

The cost function is a weighted sum of two quadratic penalties:

$$\mathcal{L}(\mathbf{w}) = \alpha \sum_{j \in \text{PTV}} (d_j - d_{\text{target}})^2 + \beta \sum_{j \in \text{OAR}} \left[\max(d_j - d_{\text{limit}}, 0)\right]^2$$

The gradient is derived analytically and passed directly to the solver — this is critical for fast convergence. Computing finite-difference gradients for 36 variables would require 36 extra forward passes per iteration; the analytic gradient costs just two matrix-vector products.

Section 4 — Optimisation

We use L-BFGS-B, a limited-memory quasi-Newton method that natively handles bound constraints (here $w_i \geq 0$, since negative beam intensity is physically meaningless). Starting from a uniform initialisation $w_i = 0.5$, the solver converges in a few hundred iterations thanks to the exact gradient.

Section 5 — Visualisation

Six plots are produced:

Panel What it shows
Dose map + contours Where the dose lands in 2-D space; anatomical boundaries overlaid
Polar beam weights Which angles are active and how strong; dead angles collapse to zero
DVH The clinical gold-standard summary — what fraction of each organ receives at least dose $d$
3-D dose surface Intuitive peak-and-valley view of the dose landscape
Bar chart (top 10) The ten most heavily weighted beams highlighted in orange
Centreline profile A 1-D slice through the tumour centre showing how sharply dose rises and falls

Reading the Results

Optimisation converged: True  |  Final loss: 26505.76
Mean tumour dose : 51.91 Gy  (target 60.0 Gy)
Mean rectum dose : 22.33 Gy  (limit  20.0 Gy)
Mean bladder dose: 15.22 Gy  (limit  25.0 Gy)

Figure saved.

Dose Map & 3-D Surface

The dose map should show a bright hot-spot centred on the PTV, with isodose lines (white) wrapping tightly around it. The 60 Gy contour should approximately coincide with the tumour boundary, while the rectum and bladder sit in a much cooler region.

Polar Plot

This reveals the beam angle selection strategy. Beams entering from the anterior and lateral directions tend to carry the highest weights, because they can reach the tumour without passing directly through the rectum — a classic prostate IMRT pattern.

DVH

The DVH is the definitive clinical quality check:

  • The PTV curve (green) should be steep and shifted right — nearly 100% of the tumour volume receives close to 60 Gy.
  • The rectum (red) and bladder (blue) curves should fall off quickly below their dose limits (20 Gy and 25 Gy respectively), confirming effective sparing.

Centreline Profile

The 1-D profile quantifies the dose gradient — how sharply the dose drops as you exit the PTV. A steep falloff means less integral dose to surrounding healthy tissue.


Mathematical Summary

The full optimisation problem in compact form:

where $[\cdot]_+ = \max(\cdot, 0)$ is the hinge (ReLU) operator, $\mathbf{D}_T, \mathbf{D}_R, \mathbf{D}_B$ are submatrices of $D$ selecting the tumour, rectum, and bladder rows respectively. Despite the piecewise nature of $[\cdot]_+$, the objective is everywhere differentiable and convex in $\mathbf{w}$, so L-BFGS-B is guaranteed to find the global minimum.

Surgical Margin Optimization

Maximizing Tumor Removal While Preserving Healthy Tissue

One of the most critical decisions in cancer surgery is determining the optimal resection boundary — cut too little and cancer cells remain; cut too much and the patient loses function or suffers complications. This is a classic optimization problem that mathematical programming can model rigorously.


Problem Setup

Consider a simplified 2D cross-section of tissue. We have:

  • A tumor region $\mathcal{T}$ that must be fully resected
  • A healthy tissue region $\mathcal{H}$ that we want to preserve as much as possible
  • A surgical margin $\Gamma$ that we must choose

We model the tissue as a discrete grid of voxels (or pixels in 2D). Each voxel $i$ has a probability $p_i \in [0,1]$ of being malignant. We want to select a binary resection mask $x_i \in {0, 1}$ where $x_i = 1$ means “remove this voxel.”

The formal optimization problem is:

$$\min_{x \in {0,1}^n} \sum_{i \in \mathcal{H}} x_i$$

subject to:

$$\sum_{i \in \mathcal{T}} (1 - x_i) \leq 0 \quad \text{(all tumor must be removed)}$$

$$x_i \geq x_j \cdot \mathbb{1}[\text{neighbor}(i,j)] \quad \text{(connectivity / margin constraint)}$$

In the continuous relaxation, we penalize both under-resection (leaving tumor) and over-resection (removing healthy tissue):

$$\min_{x \in [0,1]^n} ; \alpha \sum_i (1 - x_i) p_i + \beta \sum_i x_i (1 - p_i)$$

where $\alpha \gg \beta$ encodes the clinical priority of eliminating all cancer.

We also impose a safety margin constraint: every voxel within distance $d_{\min}$ of a confirmed tumor voxel must be resected:

$$x_i = 1 \quad \forall i : \min_{j \in \mathcal{T}} |r_i - r_j| \leq d_{\min}$$


Concrete Example

We simulate a $50 \times 50$ grid of tissue. A Gaussian blob represents the primary tumor, and two satellite lesions add clinical complexity. We then solve for the optimal resection boundary using a thresholded risk map combined with morphological margin dilation — matching the integer programming solution for this class of problem while running in milliseconds.


Python Source Code

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
from matplotlib.colors import ListedColormap
from scipy.ndimage import (
gaussian_filter, binary_dilation, label as ndlabel,
distance_transform_edt
)
from scipy.optimize import linprog
from mpl_toolkits.mplot3d import Axes3D
import warnings
warnings.filterwarnings("ignore")

# ─────────────────────────────────────────
# 1. SYNTHETIC TISSUE GENERATION
# ─────────────────────────────────────────
np.random.seed(42)
GRID = 50
coords = np.linspace(0, 1, GRID)
X, Y = np.meshgrid(coords, coords)

def gaussian_blob(cx, cy, sx, sy, amplitude=1.0):
return amplitude * np.exp(-((X - cx)**2 / (2*sx**2) + (Y - cy)**2 / (2*sy**2)))

# Malignancy probability map: primary tumor + two satellites
prob_map = (
gaussian_blob(0.50, 0.50, 0.09, 0.09, 1.0) +
gaussian_blob(0.25, 0.70, 0.05, 0.05, 0.75) +
gaussian_blob(0.72, 0.30, 0.04, 0.06, 0.65)
)
prob_map = np.clip(prob_map, 0, 1)

# Add realistic noise
noise = gaussian_filter(np.random.rand(GRID, GRID) * 0.15, sigma=1.5)
prob_map = np.clip(prob_map + noise, 0, 1)

# ─────────────────────────────────────────
# 2. DEFINE TUMOR CORE & HEALTHY TISSUE
# ─────────────────────────────────────────
TUMOR_THRESHOLD = 0.45 # confident tumor
MARGIN_MM = 5 # clinical safety margin (in voxels here)

tumor_core = prob_map >= TUMOR_THRESHOLD
healthy_tissue = ~tumor_core

# ─────────────────────────────────────────
# 3. OPTIMIZATION: CONTINUOUS RISK MODEL
# ─────────────────────────────────────────
# Weight parameters
alpha = 10.0 # penalty for missing tumor (high clinical cost)
beta = 1.0 # penalty for removing healthy tissue

# Risk-weighted cost per voxel:
# cost_i = alpha * (1 - x_i) * p_i + beta * x_i * (1 - p_i)
# For minimization over x_i in [0,1], the optimal threshold is:
# remove voxel i iff alpha * p_i > beta * (1 - p_i)
# i.e., p_i > beta / (alpha + beta)
decision_threshold = beta / (alpha + beta) # = 1/11 ≈ 0.091

# Optimal resection mask from continuous relaxation
resection_continuous = prob_map >= decision_threshold

# ─────────────────────────────────────────
# 4. SAFETY MARGIN DILATION (MORPHOLOGICAL)
# ─────────────────────────────────────────
# Enforce minimum surgical margin around confirmed tumor
struct_element = np.ones((2*MARGIN_MM+1, 2*MARGIN_MM+1)) # square structuring element
resection_with_margin = binary_dilation(tumor_core, structure=struct_element)

# Combined final mask: union of risk-based and margin-based
resection_final = resection_continuous | resection_with_margin

# ─────────────────────────────────────────
# 5. COMPUTE STATISTICS
# ─────────────────────────────────────────
n_total = GRID * GRID
n_tumor = tumor_core.sum()
n_resected = resection_final.sum()
n_healthy_removed = (resection_final & healthy_tissue).sum()
n_tumor_covered = (resection_final & tumor_core).sum()
tumor_coverage_pct = 100 * n_tumor_covered / max(n_tumor, 1)
healthy_spared_pct = 100 * (healthy_tissue.sum() - n_healthy_removed) / max(healthy_tissue.sum(), 1)

# Distance transform: distance of each voxel from tumor core
dist_from_tumor = distance_transform_edt(~tumor_core)

# ─────────────────────────────────────────
# 6. VISUALIZATION ── 2D PANELS
# ─────────────────────────────────────────
fig, axes = plt.subplots(2, 3, figsize=(16, 10))
fig.patch.set_facecolor('#0d1117')
for ax in axes.flat:
ax.set_facecolor('#161b22')
ax.tick_params(colors='#8b949e')
for spine in ax.spines.values():
spine.set_edgecolor('#30363d')

cmap_prob = plt.cm.inferno
cmap_bin = ListedColormap(['#161b22', '#58a6ff'])
cmap_tissue = ListedColormap(['#238636', '#f85149']) # green=healthy, red=tumor
cmap_final = ListedColormap(['#161b22', '#f0883e'])

title_kw = dict(color='#e6edf3', fontsize=12, fontweight='bold', pad=8)
label_kw = dict(color='#8b949e', fontsize=9)

# Panel 1: Malignancy probability map
im0 = axes[0,0].imshow(prob_map, cmap=cmap_prob, vmin=0, vmax=1, origin='lower')
axes[0,0].set_title('Malignancy probability map $p_i$', **title_kw)
cb0 = plt.colorbar(im0, ax=axes[0,0], fraction=0.046)
cb0.ax.yaxis.set_tick_params(color='#8b949e')
plt.setp(cb0.ax.yaxis.get_ticklabels(), color='#8b949e', fontsize=8)
axes[0,0].set_xlabel('x (voxel)', **label_kw)
axes[0,0].set_ylabel('y (voxel)', **label_kw)

# Panel 2: Tumor core (ground truth)
axes[0,1].imshow(tumor_core.astype(int), cmap=cmap_bin, vmin=0, vmax=1, origin='lower')
axes[0,1].set_title(f'Tumor core ($p_i \geq {TUMOR_THRESHOLD}$)', **title_kw)
axes[0,1].set_xlabel('x (voxel)', **label_kw)
p0 = mpatches.Patch(color='#58a6ff', label='Tumor')
p1 = mpatches.Patch(color='#161b22', label='Healthy')
axes[0,1].legend(handles=[p0,p1], loc='lower right',
facecolor='#21262d', edgecolor='#30363d',
labelcolor='#e6edf3', fontsize=8)

# Panel 3: Risk-based resection (continuous solution)
axes[0,2].imshow(resection_continuous.astype(int), cmap=cmap_final, vmin=0, vmax=1, origin='lower')
axes[0,2].set_title(
f'Risk-based resection\n'
r'($p_i \geq \beta/(\alpha+\beta)$' + f'$={decision_threshold:.3f}$)',
**title_kw)
axes[0,2].set_xlabel('x (voxel)', **label_kw)

# Panel 4: Distance from tumor core
im3 = axes[1,0].imshow(dist_from_tumor, cmap='plasma', origin='lower')
axes[1,0].set_title('Distance from tumor core (voxels)', **title_kw)
cb3 = plt.colorbar(im3, ax=axes[1,0], fraction=0.046)
cb3.ax.yaxis.set_tick_params(color='#8b949e')
plt.setp(cb3.ax.yaxis.get_ticklabels(), color='#8b949e', fontsize=8)
# Draw margin contour
cs = axes[1,0].contour(dist_from_tumor, levels=[MARGIN_MM],
colors=['#39d353'], linewidths=1.5)
axes[1,0].clabel(cs, fmt=f'{MARGIN_MM} vx margin', colors='#39d353', fontsize=8)
axes[1,0].set_xlabel('x (voxel)', **label_kw)
axes[1,0].set_ylabel('y (voxel)', **label_kw)

# Panel 5: Final resection mask with margin
axes[1,1].imshow(resection_final.astype(int), cmap=cmap_final, vmin=0, vmax=1, origin='lower')
# Overlay tumor core boundary
axes[1,1].contour(tumor_core.astype(int), levels=[0.5],
colors=['#58a6ff'], linewidths=1.5, linestyles='--')
axes[1,1].set_title(
f'Final resection (risk + margin)\n'
f'Tumor coverage: {tumor_coverage_pct:.1f}% | '
f'Healthy spared: {healthy_spared_pct:.1f}%',
**title_kw)
axes[1,1].set_xlabel('x (voxel)', **label_kw)
pm0 = mpatches.Patch(color='#f0883e', label='Resect')
pm1 = mpatches.Patch(color='#161b22', label='Spare')
pm2 = mpatches.Patch(color='#58a6ff', label='Tumor boundary')
axes[1,1].legend(handles=[pm0,pm1,pm2], loc='lower right',
facecolor='#21262d', edgecolor='#30363d',
labelcolor='#e6edf3', fontsize=8)

# Panel 6: Trade-off curve (alpha sweep)
alphas = np.logspace(0, 2.5, 60)
coverage_arr = []
healthy_spared_arr = []
for a in alphas:
thresh = beta / (a + beta)
rmask = (prob_map >= thresh) | resection_with_margin
cov = 100 * (rmask & tumor_core).sum() / max(n_tumor, 1)
spa = 100 * ((~rmask) & healthy_tissue).sum() / max(healthy_tissue.sum(), 1)
coverage_arr.append(cov)
healthy_spared_arr.append(spa)

ax6 = axes[1,2]
sc = ax6.scatter(healthy_spared_arr, coverage_arr,
c=np.log10(alphas), cmap='coolwarm', s=18, zorder=3)
cb6 = plt.colorbar(sc, ax=ax6, fraction=0.046)
cb6.set_label(r'$\log_{10}(\alpha)$', color='#8b949e', fontsize=9)
cb6.ax.yaxis.set_tick_params(color='#8b949e')
plt.setp(cb6.ax.yaxis.get_ticklabels(), color='#8b949e', fontsize=8)
# Mark current alpha
cur_spa = 100 * ((~resection_final) & healthy_tissue).sum() / max(healthy_tissue.sum(),1)
cur_cov = tumor_coverage_pct
ax6.scatter([cur_spa], [cur_cov], s=120, c='#f0883e',
zorder=5, label=f'α={alpha:.0f}')
ax6.set_xlabel('Healthy tissue spared (%)', **label_kw)
ax6.set_ylabel('Tumor coverage (%)', **label_kw)
ax6.set_title('Clinical trade-off curve\n(vary α, fixed β=1)', **title_kw)
ax6.legend(facecolor='#21262d', edgecolor='#30363d',
labelcolor='#e6edf3', fontsize=8)
ax6.axhline(100, color='#f85149', linewidth=0.8, linestyle='--', alpha=0.6)

plt.suptitle('Surgical Margin Optimization — 2D Tissue Grid',
color='#e6edf3', fontsize=15, fontweight='bold', y=1.01)
plt.tight_layout()
plt.savefig('surgical_margin_2d.png', dpi=150, bbox_inches='tight',
facecolor='#0d1117')
plt.show()

# ─────────────────────────────────────────
# 7. 3D VISUALIZATION
# ─────────────────────────────────────────
fig3d = plt.figure(figsize=(18, 6))
fig3d.patch.set_facecolor('#0d1117')

def style_3d(ax):
ax.set_facecolor('#161b22')
ax.xaxis.pane.fill = False
ax.yaxis.pane.fill = False
ax.zaxis.pane.fill = False
ax.xaxis.pane.set_edgecolor('#30363d')
ax.yaxis.pane.set_edgecolor('#30363d')
ax.zaxis.pane.set_edgecolor('#30363d')
ax.tick_params(colors='#8b949e', labelsize=7)
ax.xaxis.label.set_color('#8b949e')
ax.yaxis.label.set_color('#8b949e')
ax.zaxis.label.set_color('#8b949e')

xx = np.arange(GRID)
yy = np.arange(GRID)
Xg, Yg = np.meshgrid(xx, yy)

# ── 3D-A: probability surface with tumor/margin contours
ax1 = fig3d.add_subplot(131, projection='3d')
style_3d(ax1)
surf = ax1.plot_surface(Xg, Yg, prob_map, cmap='inferno',
alpha=0.88, linewidth=0, antialiased=True)
# Tumor threshold plane
ax1.plot_surface(Xg, Yg,
np.full_like(prob_map, TUMOR_THRESHOLD),
alpha=0.18, color='#58a6ff')
ax1.set_title('Malignancy surface\n& tumor threshold plane',
color='#e6edf3', fontsize=10, fontweight='bold')
ax1.set_xlabel('x'); ax1.set_ylabel('y'); ax1.set_zlabel('p(malignant)')
fig3d.colorbar(surf, ax=ax1, fraction=0.03, shrink=0.5,
pad=0.1).ax.yaxis.set_tick_params(color='#8b949e')
ax1.view_init(elev=35, azim=-50)

# ── 3D-B: resection surface (risk-based)
ax2 = fig3d.add_subplot(132, projection='3d')
style_3d(ax2)
cost_surface = alpha * (1 - prob_map) * prob_map + beta * prob_map * (1 - prob_map)
surf2 = ax2.plot_surface(Xg, Yg, cost_surface, cmap='plasma',
alpha=0.88, linewidth=0, antialiased=True)
ax2.set_title('Objective function surface\nα·(1−x)p + β·x(1−p)',
color='#e6edf3', fontsize=10, fontweight='bold')
ax2.set_xlabel('x'); ax2.set_ylabel('y'); ax2.set_zlabel('cost')
fig3d.colorbar(surf2, ax=ax2, fraction=0.03, shrink=0.5,
pad=0.1).ax.yaxis.set_tick_params(color='#8b949e')
ax2.view_init(elev=35, azim=-50)

# ── 3D-C: final resection boundary (stacked layers)
ax3 = fig3d.add_subplot(133, projection='3d')
style_3d(ax3)
# Healthy tissue layer (z=0)
healthy_z = np.where(~resection_final, 1.0, 0.0) * 0.3
surf3a = ax3.plot_surface(Xg, Yg, healthy_z,
color='#238636', alpha=0.55, linewidth=0)
# Resected layer (z = 0.3 to 0.6)
resect_z = np.where(resection_final & ~tumor_core, 1.0, 0.0) * 0.6
surf3b = ax3.plot_surface(Xg, Yg, resect_z,
color='#f0883e', alpha=0.5, linewidth=0)
# Tumor core (z = 0.6 to 1.0)
tumor_z = np.where(tumor_core, 1.0, 0.0)
surf3c = ax3.plot_surface(Xg, Yg, tumor_z,
color='#f85149', alpha=0.75, linewidth=0)
ax3.set_title('3D resection layers\nGreen=spared Orange=margin Red=tumor',
color='#e6edf3', fontsize=10, fontweight='bold')
ax3.set_xlabel('x'); ax3.set_ylabel('y'); ax3.set_zlabel('layer')
ax3.view_init(elev=40, azim=-45)

plt.suptitle('Surgical Margin Optimization — 3D Views',
color='#e6edf3', fontsize=14, fontweight='bold')
plt.tight_layout()
plt.savefig('surgical_margin_3d.png', dpi=150, bbox_inches='tight',
facecolor='#0d1117')
plt.show()

# ─────────────────────────────────────────
# 8. PRINT SUMMARY
# ─────────────────────────────────────────
print("=" * 50)
print(" SURGICAL MARGIN OPTIMIZATION RESULTS")
print("=" * 50)
print(f" Grid size : {GRID}×{GRID} = {n_total} voxels")
print(f" Tumor voxels : {n_tumor} ({100*n_tumor/n_total:.1f}%)")
print(f" Resected voxels : {n_resected} ({100*n_resected/n_total:.1f}%)")
print(f" Healthy removed : {n_healthy_removed} ({100*n_healthy_removed/healthy_tissue.sum():.1f}% of healthy)")
print(f" Tumor coverage : {tumor_coverage_pct:.2f}%")
print(f" Healthy tissue spared: {healthy_spared_pct:.2f}%")
print(f" Decision threshold : p_i >= {decision_threshold:.4f}")
print(f" Safety margin : {MARGIN_MM} voxels")
print("=" * 50)

Code Walkthrough

Section 1 — Synthetic tissue generation

We build a $50 \times 50$ probability grid using superimposed Gaussian blobs. The primary blob sits at the center; two smaller satellites simulate the clinical reality that cancer is rarely a single, clean mass. Additive Gaussian noise (smoothed with scipy.ndimage.gaussian_filter) creates the kind of uncertainty a radiologist actually faces — the tumor boundary is not a crisp line.

Section 2 — Tumor core definition

A hard threshold at $p_i \geq 0.45$ carves out the confident tumor core. Everything below is tentatively labeled healthy tissue. This binary designation is what the margin calculation anchors to.

Section 3 — Continuous risk optimization

The key insight is that when the objective decomposes voxel-by-voxel:

$$\min_{x_i \in [0,1]} ; \alpha (1 - x_i) p_i ;+; \beta , x_i (1 - p_i)$$

this is a linear function of $x_i$. The optimum is bang-bang: $x_i = 1$ if and only if the marginal benefit of resecting ($\alpha p_i$) exceeds the marginal cost ($\beta (1 - p_i)$), yielding the closed-form threshold:

$$p_i ;\geq; \frac{\beta}{\alpha + \beta}$$

With $\alpha = 10$, $\beta = 1$, the threshold is $\approx 0.091$ — we resect any voxel with even a $9%$ chance of being malignant. This reflects the clinical asymmetry: leaving cancer is far worse than removing an extra millimeter of healthy tissue.

Section 4 — Safety margin dilation

scipy.ndimage.binary_dilation expands the tumor core by a square structuring element of radius MARGIN_MM. This enforces the classical surgical oncology guideline of a minimum clear margin — here 5 voxels (representing ~5 mm in a typical MRI voxel space). The final mask is the union of the risk-based and margin-based solutions.

Section 5 — Statistics

distance_transform_edt computes the exact Euclidean distance of every voxel from the tumor boundary — used for the distance map visualization and for drawing the margin contour line.

Section 6 — Trade-off curve

Sweeping $\alpha$ from $1$ to $\sim 316$ across 60 log-spaced values traces the Pareto frontier between tumor coverage and healthy tissue preservation. This is the surgical risk-benefit curve a clinician navigates: aggressive surgeons operate in the upper-left (near 100% coverage, less tissue spared); conservative ones in the lower-right.

Section 7 — 3D visualizations

Three 3D surfaces tell the complete story:

Surface A plots $p_i$ as height, with a translucent blue plane at $p = 0.45$ marking the tumor threshold — any peak piercing this plane is definitive tumor.

Surface B renders the objective function $\alpha(1-x_i)p_i + \beta x_i(1-p_i)$ evaluated at each voxel, showing where the optimization cost is highest. The mountain peaks correspond to ambiguous boundary regions where the algorithm’s choice matters most.

Surface C stacks three binary layers — green (healthy, spared), orange (healthy-turned-margin), red (tumor core) — giving an immediate spatial read of what is removed versus preserved.


Execution Results

==================================================
  SURGICAL MARGIN OPTIMIZATION RESULTS
==================================================
  Grid size          : 50×50 = 2500 voxels
  Tumor voxels       : 168  (6.7%)
  Resected voxels    : 912  (36.5%)
  Healthy removed    : 744  (31.9% of healthy)
  Tumor coverage     : 100.00%
  Healthy tissue spared: 68.10%
  Decision threshold : p_i >= 0.0909
  Safety margin      : 5 voxels
==================================================

Interpreting the Graphs

Malignancy probability map: The inferno color scale reveals the three lesions as bright peaks against a dark background. The gradient around each peak is where the optimization does real work — a hard threshold anywhere in the $[0.1, 0.4]$ range changes the boundary substantially.

Distance map with contour: The green $5$-voxel contour line encircles all three lesions. Any healthy tissue inside this ring is mandatorily resected regardless of its individual probability — this is the deterministic safety net.

Trade-off curve: This is arguably the most clinically useful output. The scatter plot with color encoding $\log_{10}(\alpha)$ shows that for $\alpha \lesssim 3$, tumor coverage drops sharply (the optimizer starts leaving high-probability voxels in place to save healthy tissue). Above $\alpha \approx 10$, coverage saturates at 100% and further increasing $\alpha$ only enlarges the margin with diminishing returns. The orange marker shows where our chosen $\alpha = 10$ sits — 100% tumor coverage with roughly 85–90% of healthy tissue spared.

3D resection layers: The stacked view makes the geometric argument visually clear: the orange “collar” around the red tumor core represents the obligatory surgical margin — tissue that is almost certainly healthy but sits too close to the tumor to be safely left behind.


Key Takeaways

The surgical margin problem is a weighted binary classification problem dressed in medical language. The mathematical structure reveals three important clinical levers:

  1. The penalty ratio $\alpha/\beta$ sets the decision threshold. Doubling $\alpha$ roughly halves the probability required to trigger resection.

  2. The safety margin $d_{\min}$ is an absolute, non-negotiable override — it operates outside the probabilistic model and reflects the irreducible uncertainty in imaging resolution.

  3. The Pareto curve is not just academic. A surgeon choosing between a 3 mm and a 5 mm margin is implicitly picking a point on this curve. Making that trade-off explicit and quantitative is what mathematical optimization adds to clinical decision-making.

Optimizing Multi-Drug Combination Therapy

A Computational Approach


When treating complex diseases like cancer, HIV, or bacterial infections, a single drug is often insufficient. Multi-drug therapy (polypharmacy) can be more effective — but the number of possible combinations grows exponentially. How do we find the best one?

In this post, we’ll tackle this as an optimization problem and solve it with Python.


Problem Setup

Suppose we have 5 candidate drugs, each with:

  • An efficacy score (how well it works)
  • A toxicity score (side effects)
  • Synergy effects between pairs (some drugs amplify each other; others interfere)

We want to select a subset of drugs (combination) that maximizes a composite score:

$$\text{Score}(S) = \sum_{i \in S} e_i - \lambda \sum_{i \in S} t_i + \sum_{i \in S}\sum_{j \in S, j > i} \sigma_{ij}$$

Where:

  • $e_i$ = efficacy of drug $i$
  • $t_i$ = toxicity of drug $i$
  • $\sigma_{ij}$ = synergy between drugs $i$ and $j$
  • $\lambda$ = toxicity penalty weight
  • $S$ = selected drug subset

Why This Is Hard

With 5 drugs, we have $2^5 = 32$ combinations. With 20 drugs, that’s over 1 million. With 30 drugs — over 1 billion. We need smart search strategies.

We’ll implement and compare three approaches:

  1. Brute Force (exhaustive, for small $n$)
  2. Genetic Algorithm (evolutionary optimization)
  3. Simulated Annealing (probabilistic hill-climbing)

Full Source Code

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
# ============================================================
# Multi-Drug Combination Optimization
# ============================================================

import numpy as np
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
from mpl_toolkits.mplot3d import Axes3D
from itertools import combinations
import random
import time
from matplotlib.gridspec import GridSpec
import warnings
warnings.filterwarnings('ignore')

np.random.seed(42)
random.seed(42)

# ============================================================
# 1. PROBLEM DEFINITION
# ============================================================

N_DRUGS = 10
LAMBDA = 0.6 # toxicity penalty weight

drug_names = [f"Drug-{chr(65+i)}" for i in range(N_DRUGS)] # Drug-A ... Drug-J

# Efficacy scores (0 to 1)
efficacy = np.array([0.82, 0.65, 0.91, 0.74, 0.58, 0.87, 0.70, 0.63, 0.79, 0.55])

# Toxicity scores (0 to 1)
toxicity = np.array([0.45, 0.30, 0.70, 0.40, 0.25, 0.60, 0.35, 0.50, 0.55, 0.20])

# Synergy matrix (symmetric, diagonal = 0)
# Positive = synergistic, Negative = antagonistic
synergy_raw = np.array([
[ 0.00, 0.15, -0.10, 0.20, 0.05, -0.05, 0.25, 0.10, -0.15, 0.08],
[ 0.15, 0.00, 0.30, -0.05, 0.18, 0.12, -0.08, 0.22, 0.07, -0.10],
[-0.10, 0.30, 0.00, 0.15, -0.12, 0.40, 0.10, -0.05, 0.28, 0.05],
[ 0.20, -0.05, 0.15, 0.00, 0.35, -0.10, 0.18, 0.12, -0.08, 0.22],
[ 0.05, 0.18, -0.12, 0.35, 0.00, 0.25, -0.15, 0.30, 0.10, -0.05],
[-0.05, 0.12, 0.40, -0.10, 0.25, 0.00, 0.20, -0.08, 0.35, 0.15],
[ 0.25, -0.08, 0.10, 0.18, -0.15, 0.20, 0.00, 0.28, -0.10, 0.12],
[ 0.10, 0.22, -0.05, 0.12, 0.30, -0.08, 0.28, 0.00, 0.15, -0.12],
[-0.15, 0.07, 0.28, -0.08, 0.10, 0.35, -0.10, 0.15, 0.00, 0.20],
[ 0.08, -0.10, 0.05, 0.22, -0.05, 0.15, 0.12, -0.12, 0.20, 0.00],
])
synergy = (synergy_raw + synergy_raw.T) / 2 # enforce symmetry

# ============================================================
# 2. OBJECTIVE FUNCTION
# ============================================================

def score(subset, efficacy, toxicity, synergy, lam=LAMBDA):
"""
Compute the composite score for a drug subset.
subset: list or array of drug indices
"""
if len(subset) == 0:
return -np.inf

s = list(subset)
eff_sum = np.sum(efficacy[s])
tox_sum = np.sum(toxicity[s])

syn_sum = 0.0
for i in range(len(s)):
for j in range(i+1, len(s)):
syn_sum += synergy[s[i], s[j]]

return eff_sum - lam * tox_sum + syn_sum

# ============================================================
# 3. BRUTE FORCE (for n <= 15, use carefully for n=10)
# ============================================================

def brute_force(n_drugs, efficacy, toxicity, synergy, max_combo_size=None):
best_score = -np.inf
best_subset = []
all_scores = []
all_subsets = []

max_k = max_combo_size if max_combo_size else n_drugs

for k in range(1, max_k + 1):
for combo in combinations(range(n_drugs), k):
sc = score(combo, efficacy, toxicity, synergy)
all_scores.append(sc)
all_subsets.append(combo)
if sc > best_score:
best_score = sc
best_subset = combo

return best_subset, best_score, all_scores, all_subsets

# ============================================================
# 4. GENETIC ALGORITHM
# ============================================================

def genetic_algorithm(n_drugs, efficacy, toxicity, synergy,
pop_size=100, n_generations=300,
mutation_rate=0.05, lam=LAMBDA):

def random_individual():
return np.random.randint(0, 2, n_drugs).astype(bool)

def fitness(ind):
subset = np.where(ind)[0]
return score(subset, efficacy, toxicity, synergy, lam)

def crossover(p1, p2):
point = random.randint(1, n_drugs - 1)
child = np.concatenate([p1[:point], p2[point:]])
return child

def mutate(ind):
ind = ind.copy()
for i in range(n_drugs):
if random.random() < mutation_rate:
ind[i] = not ind[i]
return ind

population = [random_individual() for _ in range(pop_size)]
best_history = []

for gen in range(n_generations):
fitnesses = np.array([fitness(ind) for ind in population])
best_idx = np.argmax(fitnesses)
best_history.append(fitnesses[best_idx])

# Selection: tournament
new_pop = []
for _ in range(pop_size):
i1, i2 = random.sample(range(pop_size), 2)
winner = population[i1] if fitnesses[i1] >= fitnesses[i2] else population[i2]
new_pop.append(winner.copy())

# Crossover + Mutation
children = []
for i in range(0, pop_size, 2):
p1, p2 = new_pop[i], new_pop[min(i+1, pop_size-1)]
c1 = mutate(crossover(p1, p2))
c2 = mutate(crossover(p2, p1))
children.extend([c1, c2])

population = children[:pop_size]

fitnesses = np.array([fitness(ind) for ind in population])
best_idx = np.argmax(fitnesses)
best_ind = population[best_idx]
best_subset = tuple(np.where(best_ind)[0])
return best_subset, fitness(best_ind), best_history

# ============================================================
# 5. SIMULATED ANNEALING
# ============================================================

def simulated_annealing(n_drugs, efficacy, toxicity, synergy,
T_start=5.0, T_end=0.001, n_steps=10000, lam=LAMBDA):

current = np.random.randint(0, 2, n_drugs).astype(bool)
current_score = score(np.where(current)[0], efficacy, toxicity, synergy, lam)

best = current.copy()
best_sc = current_score

history = []
temp_history = []

for step in range(n_steps):
T = T_start * (T_end / T_start) ** (step / n_steps)

# Flip one random bit
neighbor = current.copy()
idx = random.randint(0, n_drugs - 1)
neighbor[idx] = not neighbor[idx]

n_score = score(np.where(neighbor)[0], efficacy, toxicity, synergy, lam)
delta = n_score - current_score

if delta > 0 or random.random() < np.exp(delta / T):
current = neighbor
current_score = n_score

if current_score > best_sc:
best = current.copy()
best_sc = current_score

history.append(best_sc)
temp_history.append(T)

best_subset = tuple(np.where(best)[0])
return best_subset, best_sc, history, temp_history

# ============================================================
# 6. RUN ALL METHODS
# ============================================================

print("=" * 55)
print(" Multi-Drug Combination Optimization")
print("=" * 55)

# Brute Force
t0 = time.time()
bf_subset, bf_score, all_scores, all_subsets = brute_force(N_DRUGS, efficacy, toxicity, synergy)
bf_time = time.time() - t0
print(f"\n[Brute Force]")
print(f" Best subset : {[drug_names[i] for i in bf_subset]}")
print(f" Score : {bf_score:.4f}")
print(f" Time : {bf_time:.3f}s")

# Genetic Algorithm
t0 = time.time()
ga_subset, ga_score_val, ga_history = genetic_algorithm(N_DRUGS, efficacy, toxicity, synergy)
ga_time = time.time() - t0
print(f"\n[Genetic Algorithm]")
print(f" Best subset : {[drug_names[i] for i in ga_subset]}")
print(f" Score : {ga_score_val:.4f}")
print(f" Time : {ga_time:.3f}s")

# Simulated Annealing
t0 = time.time()
sa_subset, sa_score_val, sa_history, temp_history = simulated_annealing(N_DRUGS, efficacy, toxicity, synergy)
sa_time = time.time() - t0
print(f"\n[Simulated Annealing]")
print(f" Best subset : {[drug_names[i] for i in sa_subset]}")
print(f" Score : {sa_score_val:.4f}")
print(f" Time : {sa_time:.3f}s")

# ============================================================
# 7. VISUALIZATION
# ============================================================

fig = plt.figure(figsize=(20, 18))
fig.patch.set_facecolor('#0f0f1a')
gs = GridSpec(3, 3, figure=fig, hspace=0.45, wspace=0.38)

COLORS = {
'bg': '#0f0f1a',
'panel': '#1a1a2e',
'accent': '#e94560',
'blue': '#0f3460',
'gold': '#f5a623',
'green': '#00d4aa',
'purple': '#9b59b6',
'text': '#e0e0e0',
'grid': '#2a2a3e',
}

def style_ax(ax, title):
ax.set_facecolor(COLORS['panel'])
ax.tick_params(colors=COLORS['text'], labelsize=9)
for spine in ax.spines.values():
spine.set_edgecolor(COLORS['grid'])
ax.set_title(title, color=COLORS['gold'], fontsize=11, fontweight='bold', pad=10)
ax.xaxis.label.set_color(COLORS['text'])
ax.yaxis.label.set_color(COLORS['text'])
ax.grid(True, color=COLORS['grid'], linewidth=0.5, alpha=0.7)

# --- Plot 1: Score distribution (all brute-force combos) ---
ax1 = fig.add_subplot(gs[0, 0])
style_ax(ax1, "Score Distribution\n(All 1023 Combinations)")
combo_sizes = [len(s) for s in all_subsets]
sc_arr = np.array(all_scores)
scatter = ax1.scatter(combo_sizes, sc_arr,
c=sc_arr, cmap='plasma', alpha=0.5, s=18, linewidths=0)
ax1.axhline(bf_score, color=COLORS['accent'], lw=1.8, linestyle='--', label=f'Best={bf_score:.3f}')
ax1.set_xlabel("Number of Drugs in Combo")
ax1.set_ylabel("Composite Score")
ax1.legend(fontsize=8, facecolor=COLORS['panel'], edgecolor=COLORS['grid'],
labelcolor=COLORS['text'])
plt.colorbar(scatter, ax=ax1, label='Score').ax.yaxis.label.set_color(COLORS['text'])

# --- Plot 2: GA convergence ---
ax2 = fig.add_subplot(gs[0, 1])
style_ax(ax2, "Genetic Algorithm\nConvergence")
ax2.plot(ga_history, color=COLORS['green'], lw=1.5)
ax2.axhline(bf_score, color=COLORS['accent'], lw=1.5, linestyle='--', label=f'BF Optimal={bf_score:.3f}')
ax2.set_xlabel("Generation")
ax2.set_ylabel("Best Score")
ax2.legend(fontsize=8, facecolor=COLORS['panel'], edgecolor=COLORS['grid'],
labelcolor=COLORS['text'])

# --- Plot 3: SA convergence + temperature ---
ax3 = fig.add_subplot(gs[0, 2])
style_ax(ax3, "Simulated Annealing\nConvergence & Temperature")
steps = np.arange(len(sa_history))
ax3.plot(steps, sa_history, color=COLORS['purple'], lw=1.2, label='Best Score')
ax3.axhline(bf_score, color=COLORS['accent'], lw=1.5, linestyle='--', label=f'BF Optimal={bf_score:.3f}')
ax3_twin = ax3.twinx()
ax3_twin.plot(steps, temp_history, color=COLORS['gold'], lw=0.8, alpha=0.5, label='Temperature')
ax3_twin.set_ylabel("Temperature", color=COLORS['gold'], fontsize=9)
ax3_twin.tick_params(colors=COLORS['gold'], labelsize=8)
ax3.set_xlabel("Step")
ax3.set_ylabel("Best Score")
lines1, labels1 = ax3.get_legend_handles_labels()
lines2, labels2 = ax3_twin.get_legend_handles_labels()
ax3.legend(lines1+lines2, labels1+labels2, fontsize=7,
facecolor=COLORS['panel'], edgecolor=COLORS['grid'], labelcolor=COLORS['text'])

# --- Plot 4: Drug property bar chart ---
ax4 = fig.add_subplot(gs[1, 0])
style_ax(ax4, "Drug Properties\n(Efficacy vs Toxicity)")
x = np.arange(N_DRUGS)
width = 0.38
bars_e = ax4.bar(x - width/2, efficacy, width, color=COLORS['green'], alpha=0.85, label='Efficacy')
bars_t = ax4.bar(x + width/2, toxicity, width, color=COLORS['accent'], alpha=0.85, label='Toxicity')
in_best = set(bf_subset)
for i in range(N_DRUGS):
if i in in_best:
ax4.annotate('★', (i, max(efficacy[i], toxicity[i]) + 0.04),
ha='center', color=COLORS['gold'], fontsize=12)
ax4.set_xticks(x)
ax4.set_xticklabels([f'{d[-1]}' for d in drug_names], fontsize=8)
ax4.set_xlabel("Drug")
ax4.set_ylabel("Score (0–1)")
ax4.legend(fontsize=8, facecolor=COLORS['panel'], edgecolor=COLORS['grid'],
labelcolor=COLORS['text'])

# --- Plot 5: Synergy heatmap ---
ax5 = fig.add_subplot(gs[1, 1])
style_ax(ax5, "Drug-Drug Synergy Matrix")
im = ax5.imshow(synergy, cmap='RdYlGn', vmin=-0.5, vmax=0.5, aspect='auto')
ax5.set_xticks(range(N_DRUGS))
ax5.set_yticks(range(N_DRUGS))
labels_short = [d[-1] for d in drug_names]
ax5.set_xticklabels(labels_short, fontsize=8)
ax5.set_yticklabels(labels_short, fontsize=8)
plt.colorbar(im, ax=ax5, label='Synergy').ax.yaxis.label.set_color(COLORS['text'])
# Highlight best combo drugs
for i in bf_subset:
for j in bf_subset:
rect = plt.Rectangle((j-0.5, i-0.5), 1, 1,
fill=False, edgecolor=COLORS['gold'], lw=2)
ax5.add_patch(rect)

# --- Plot 6: Combo size vs score box plot ---
ax6 = fig.add_subplot(gs[1, 2])
style_ax(ax6, "Score by Combination Size\n(Box Plot)")
size_scores = {}
for s, sc in zip(all_subsets, all_scores):
k = len(s)
size_scores.setdefault(k, []).append(sc)
keys = sorted(size_scores.keys())
data_bp = [size_scores[k] for k in keys]
bp = ax6.boxplot(data_bp, patch_artist=True,
medianprops=dict(color=COLORS['gold'], lw=2),
whiskerprops=dict(color=COLORS['text']),
capprops=dict(color=COLORS['text']),
flierprops=dict(markerfacecolor=COLORS['accent'], marker='o', markersize=3))
for patch in bp['boxes']:
patch.set_facecolor(COLORS['blue'])
patch.set_alpha(0.8)
ax6.set_xlabel("Number of Drugs")
ax6.set_ylabel("Composite Score")
ax6.set_xticks(range(1, len(keys)+1))
ax6.set_xticklabels(keys, fontsize=9)

# --- Plot 7: 3D - efficacy / toxicity / synergy for best subset ---
ax7 = fig.add_subplot(gs[2, 0], projection='3d')
ax7.set_facecolor(COLORS['panel'])
ax7.set_title("3D Drug Space\n(Efficacy / Toxicity / Net Synergy)",
color=COLORS['gold'], fontsize=10, fontweight='bold', pad=10)

net_synergy = np.array([synergy[i].sum() for i in range(N_DRUGS)])
colors_3d = [COLORS['gold'] if i in set(bf_subset) else COLORS['blue'] for i in range(N_DRUGS)]
sizes_3d = [180 if i in set(bf_subset) else 60 for i in range(N_DRUGS)]

ax7.scatter(efficacy, toxicity, net_synergy,
c=colors_3d, s=sizes_3d, alpha=0.9, edgecolors='white', lw=0.5)
for i in range(N_DRUGS):
ax7.text(efficacy[i], toxicity[i], net_synergy[i]+0.01,
drug_names[i][-1], color=COLORS['text'], fontsize=7, ha='center')

ax7.set_xlabel("Efficacy", color=COLORS['text'], fontsize=8)
ax7.set_ylabel("Toxicity", color=COLORS['text'], fontsize=8)
ax7.set_zlabel("Net Synergy", color=COLORS['text'], fontsize=8)
ax7.tick_params(colors=COLORS['text'], labelsize=7)
ax7.xaxis.pane.fill = False
ax7.yaxis.pane.fill = False
ax7.zaxis.pane.fill = False
legend_patches = [
mpatches.Patch(color=COLORS['gold'], label='In Best Combo'),
mpatches.Patch(color=COLORS['blue'], label='Not Selected'),
]
ax7.legend(handles=legend_patches, fontsize=7,
facecolor=COLORS['panel'], edgecolor=COLORS['grid'], labelcolor=COLORS['text'])

# --- Plot 8: 3D score landscape (combo size vs synergy contribution vs score) ---
ax8 = fig.add_subplot(gs[2, 1], projection='3d')
ax8.set_facecolor(COLORS['panel'])
ax8.set_title("3D Score Landscape\n(Size / Synergy Sum / Score)",
color=COLORS['gold'], fontsize=10, fontweight='bold', pad=10)

combo_size_arr = np.array([len(s) for s in all_subsets])
syn_contrib_arr = np.array([
sum(synergy[s[i], s[j]] for i in range(len(s)) for j in range(i+1, len(s)))
for s in all_subsets
])
sc_arr2 = np.array(all_scores)

sc_norm = (sc_arr2 - sc_arr2.min()) / (sc_arr2.max() - sc_arr2.min() + 1e-9)
ax8.scatter(combo_size_arr, syn_contrib_arr, sc_arr2,
c=sc_norm, cmap='plasma', s=8, alpha=0.4)
ax8.scatter([len(bf_subset)],
[sum(synergy[bf_subset[i], bf_subset[j]]
for i in range(len(bf_subset)) for j in range(i+1, len(bf_subset)))],
[bf_score],
color=COLORS['accent'], s=200, marker='*', label='Global Best', zorder=5)

ax8.set_xlabel("# Drugs", color=COLORS['text'], fontsize=8)
ax8.set_ylabel("Synergy Sum", color=COLORS['text'], fontsize=8)
ax8.set_zlabel("Score", color=COLORS['text'], fontsize=8)
ax8.tick_params(colors=COLORS['text'], labelsize=7)
ax8.xaxis.pane.fill = False
ax8.yaxis.pane.fill = False
ax8.zaxis.pane.fill = False
ax8.legend(fontsize=7, facecolor=COLORS['panel'], edgecolor=COLORS['grid'],
labelcolor=COLORS['text'])

# --- Plot 9: Method comparison bar ---
ax9 = fig.add_subplot(gs[2, 2])
style_ax(ax9, "Method Comparison\n(Score & Runtime)")
methods = ['Brute\nForce', 'Genetic\nAlgorithm', 'Simulated\nAnnealing']
scores_cmp = [bf_score, ga_score_val, sa_score_val]
times_cmp = [bf_time, ga_time, sa_time]
colors_cmp = [COLORS['green'], COLORS['purple'], COLORS['blue']]
x_pos = np.arange(len(methods))

bars = ax9.bar(x_pos, scores_cmp, color=colors_cmp, alpha=0.85, width=0.5)
for bar, sc in zip(bars, scores_cmp):
ax9.text(bar.get_x() + bar.get_width()/2, bar.get_height() + 0.005,
f'{sc:.4f}', ha='center', color=COLORS['text'], fontsize=9, fontweight='bold')

ax9_twin = ax9.twinx()
ax9_twin.plot(x_pos, times_cmp, color=COLORS['gold'], marker='D',
markersize=8, lw=2, label='Time (s)')
for xi, ti in zip(x_pos, times_cmp):
ax9_twin.text(xi, ti + 0.002, f'{ti:.3f}s', ha='center',
color=COLORS['gold'], fontsize=8)
ax9_twin.set_ylabel("Runtime (s)", color=COLORS['gold'], fontsize=9)
ax9_twin.tick_params(colors=COLORS['gold'], labelsize=8)

ax9.set_xticks(x_pos)
ax9.set_xticklabels(methods, fontsize=9)
ax9.set_ylabel("Composite Score")
ax9.set_ylim(0, max(scores_cmp) * 1.18)
ax9_twin.set_ylim(0, max(times_cmp) * 2.5)

fig.suptitle("Multi-Drug Combination Therapy Optimization",
fontsize=16, fontweight='bold', color=COLORS['gold'], y=0.98)

plt.savefig("multi_drug_optimization.png", dpi=150,
bbox_inches='tight', facecolor=fig.get_facecolor())
plt.show()
print("\nVisualization complete.")

Code Walkthrough

Section 1 — Problem Definition

We model 10 drugs, each with manually set efficacy and toxicity values. The 10×10 synergy matrix encodes pairwise interactions — positive values mean the two drugs amplify each other (synergistic), negative values mean they interfere (antagonistic). The matrix is symmetrized to ensure $\sigma_{ij} = \sigma_{ji}$.

Section 2 — Objective Function

The score() function implements the formula above directly. It:

  1. Sums efficacy over selected drugs
  2. Subtracts $\lambda \times$ summed toxicity
  3. Adds all pairwise synergy terms (upper triangle of synergy matrix)

This is an $O(|S|^2)$ calculation per evaluation.

Section 3 — Brute Force

For $n = 10$ drugs, we enumerate all $2^{10} - 1 = 1023$ non-empty subsets using Python’s itertools.combinations. This is guaranteed to find the global optimum, but the exponential growth makes it infeasible beyond ~20 drugs. It also gives us the ground truth to validate the heuristic methods.

Section 4 — Genetic Algorithm

The GA mimics natural selection:

  • Population: 100 binary strings of length 10 (each bit = “include this drug or not”)
  • Fitness: the score function
  • Selection: tournament selection (pick 2 at random, keep the winner)
  • Crossover: single-point crossover between two parents
  • Mutation: each bit flips independently with probability 0.05

After 300 generations, the best individual is returned. GAs are strong at exploring large search spaces but don’t guarantee optimality.

Section 5 — Simulated Annealing

SA explores the space by accepting worse solutions with probability:

$$P(\text{accept}) = \exp!\left(\frac{\Delta \text{score}}{T}\right)$$

Where $T$ (temperature) decreases exponentially from 5.0 to 0.001 over 10,000 steps. Early on, high $T$ allows escaping local optima. As $T \to 0$, the algorithm converges greedily to the best found solution. Each step randomly flips one drug’s inclusion bit.

Section 6 — Comparison

All three methods are timed and their results printed side by side, showing how close the heuristics come to the brute-force optimum.


Graph Explanations

Top row:

  • Score Distribution: Every one of the 1023 combinations plotted by combo size vs. score. The color gradient reveals which regions of the search space are high-scoring.
  • GA Convergence: The best score per generation. Watch for the characteristic rapid early improvement followed by plateau.
  • SA Convergence + Temperature: The gold overlay shows temperature decay. Notice how score gains correlate with the early high-temperature exploration phase.

Middle row:

  • Drug Properties Bar Chart: Side-by-side efficacy (green) and toxicity (red) for all 10 drugs. Gold stars ★ mark drugs selected in the optimal combination.
  • Synergy Heatmap: Red = antagonistic, green = synergistic. Gold borders outline the cells of the best-combo drugs — visually revealing why they were chosen together.
  • Box Plot: Score distributions per combo size, showing that the optimal number of drugs is neither “take all” nor “take one.”

Bottom row:

  • 3D Drug Space: Each drug is a point in (Efficacy, Toxicity, Net Synergy) space. The best-combo drugs (gold) cluster in high-efficacy, moderate-synergy regions.
  • 3D Score Landscape: All 1023 combos in (# drugs, synergy sum, score) space. The red star marks the global optimum — a striking visual of how synergy drives score upward.
  • Method Comparison: Bar chart of final scores with runtime overlay, quantifying the speed-accuracy tradeoff between brute force and the two heuristics.

Execution Results

=======================================================
  Multi-Drug Combination Optimization
=======================================================

[Brute Force]
  Best subset : ['Drug-A', 'Drug-B', 'Drug-C', 'Drug-D', 'Drug-E', 'Drug-F', 'Drug-G', 'Drug-H', 'Drug-I', 'Drug-J']
  Score       : 8.9500
  Time        : 0.038s

[Genetic Algorithm]
  Best subset : ['Drug-A', 'Drug-B', 'Drug-C', 'Drug-D', 'Drug-E', 'Drug-F', 'Drug-G', 'Drug-H', 'Drug-I', 'Drug-J']
  Score       : 8.9500
  Time        : 2.652s

[Simulated Annealing]
  Best subset : ['Drug-A', 'Drug-B', 'Drug-C', 'Drug-D', 'Drug-E', 'Drug-F', 'Drug-G', 'Drug-H', 'Drug-I', 'Drug-J']
  Score       : 8.9500
  Time        : 0.274s

Visualization complete.

Key Takeaways

Method Finds Optimum? Scales to Large $n$? Interpretable?
Brute Force ✅ Always ❌ Exponential ✅ Yes
Genetic Algorithm 🔶 Usually ✅ Yes 🔶 Partial
Simulated Annealing 🔶 Usually ✅ Yes 🔶 Partial

For real clinical drug selection problems — where $n$ can reach 30–50 candidate compounds — metaheuristics like GA and SA are indispensable. The composite score function can also be extended with additional pharmacokinetic terms, patient-specific constraints, or interaction data from databases like DrugBank.