The Simplex Method and Shadow Prices


Lecture 14

October 19, 2026

Review of Last Class

The Emissions LP Geometry

Code
line_style = [:solid, :dash, :dashdot]
function draw_region()
    p = plot(Shape(first.(corners), last.(corners)), color=cb_blue, alpha=0.25,
        linewidth=0, xlims=(0.5, 1.04), ylims=(0.375, 1.05), xlabel=L"R_1",
        ylabel=L"R_2")
    for j in 1:n_receptors
        a1, a2, rhs = boundaries[j]
        # Label each line at a point where it is inside the axes
        if abs(a2) < 1e-3
            vline!(p, [rhs / a1], color=:gray30, linestyle=line_style[j], linewidth=2.5)
            label_at = (rhs / a1 - 0.028, 0.425)
        else
            r1 = 0.5:0.01:1.04
            plot!(p, r1, (rhs .- a1 .* r1) ./ a2, color=:gray30, linestyle=line_style[j],
                linewidth=2.5)
            r1_at_09 = (rhs - a2 * 0.9) / a1
            label_at = 0.54 <= r1_at_09 <= 0.96 ? (r1_at_09 + 0.048, 0.915) : (0.54, (rhs - a1 * 0.54) / a2 + 0.065)
        end
        annotate!(p, label_at..., text("C$(j)", 20, :gray20, plot_font))
    end
    plot!(p, [0, 1, 1], [1, 1, 0], color=:black, linewidth=2)
    return p
end

optimal_cost, best = findmin(corner_cost)
optimum = corners[best]
p_review = draw_region()
scatter!(p_review, [optimum[1]], [optimum[2]], markershape=:star5, markersize=16,
    color=cb_vermillion, markerstrokecolor=:black)
plot!(p_review, size=(1200, 450), left_margin=12mm, bottom_margin=12mm)
0.5 0.6 0.7 0.8 0.9 1.0 0.4 0.5 0.6 0.7 0.8 0.9 1.0 C1 C2 C3

Questions?

Poll Everywhere QR Code

Text: VSRIKRISH to 22333

URL: https://pollev.com/vsrikrish

See Results

The Simplex Method

Walking the Corners

For simple LPs, we can just list the corners and evaluate the objective at each. This doesn’t scale.

The simplex method (Dantzig, 1963) is the core algorithm for solving LPs (assuming the problem is minimization):

  1. Start at any corner. Look along each edge neighboring it.
  2. If the objective decreases, move its other corner.
  3. Stop when the objective increases across every edge.

The Objective Along an Edge

The cost \(f = 1000R_1 + 1200R_2\) has gradient \(\nabla f = (1000, 1200)\), in $1000/yr per unit of removal fraction.

Changing the removals by \(\mathbf{d} = (d_1, d_2)\) changes the cost by

\[\nabla f \cdot \mathbf{d} = 1000\,d_1 + 1200\,d_2.\]

Along an edge, the objective decreases if this is negative and increases if it is positive.

Step 1: Start at a Corner

Code
# Corners next to corner k are its neighbours in order around the region
neighbours(k) = [mod1(k - 1, length(corners)), mod1(k + 1, length(corners))]

# How the removals change along the edge from corner k to corner m,
# per unit of removal fraction given up (or added) at one stack
function edge_direction(k, m)
    move = corners[m] .- corners[k]
    if abs(move[1]) > 1e-6
        return move ./ abs(move[1])
    end
    return move ./ abs(move[2])
end

# How fast the cost changes along that edge: the cost gradient (1000, 1200)
# dotted with the edge's direction
function edge_rate(k, m)
    return sum(cost .* edge_direction(k, m))
end

# The walk: from the corner that removes the most, follow the edge along
# which the cost falls fastest, and stop when no edge lowers the cost
start = argmax([sum(c) for c in corners])
path = zeros(Int, length(corners))              # at most one visit per corner
path[1] = start
n_steps = 1
while true
    here = path[n_steps]
    rates = [edge_rate(here, m) for m in neighbours(here)]
    if minimum(rates) >= 0
        break                                   # the cost rises along every edge
    end
    global n_steps += 1
    path[n_steps] = neighbours(here)[argmin(rates)]
end
path = path[1:n_steps]

# Draw the walk after `step` moves, with the costs of the corners in view
function draw_walk(step)
    p = draw_region()
    scatter!(p, first.(corners), last.(corners), color=:gray60, markersize=7,
        markerstrokecolor=:gray30)
    visited = path[1:(step + 1)]
    plot!(p, [corners[k][1] for k in visited], [corners[k][2] for k in visited],
        color=cb_vermillion, linewidth=4, arrow=true)
    here = visited[end]
    for k in [here; neighbours(here)]
        # Write each corner's cost just inside the region, toward its centre
        inward = centre .- corners[k]
        inward = 0.06 .* inward ./ sqrt(sum(inward .^ 2))
        annotate!(p, corners[k][1] + inward[1], corners[k][2] + inward[2],
            text(string(Int(round(corner_cost[k]))), 16, :black, plot_font))
    end
    scatter!(p, [corners[here][1]], [corners[here][2]], color=cb_vermillion,
        markersize=12, markerstrokecolor=:black)
    annotate!(p, 0.512, 0.525, text("costs in \$1000/yr", 14, :left, :black, plot_font))
    plot!(p, size=(580, 450), left_margin=5mm, bottom_margin=5mm)
    return p
end

draw_walk(0)
0.5 0.6 0.7 0.8 0.9 1.0 0.4 0.5 0.6 0.7 0.8 0.9 1.0 C1 C2 C3 2200 1786 1976 costs in $1000/yr

Start where both stacks remove all they can.

  • Left: \(\nabla f \cdot (-1, 0) = -1000\)
  • Down: \(\nabla f \cdot (0, -1) = -1200\)

Both edges lower the cost; down is steeper.

Step 2: Go Towards Steepest Decrease

Code
draw_walk(1)
0.5 0.6 0.7 0.8 0.9 1.0 0.4 0.5 0.6 0.7 0.8 0.9 1.0 C1 C2 C3 1786 1705 2200 costs in $1000/yr
  • Back up: \(\nabla f \cdot (0, 1) = +1200\)
  • Along C3: \(\nabla f \cdot (-1, 0.132) = -841.5\)

Step 3: Keep Moving

Code
draw_walk(2)
0.5 0.6 0.7 0.8 0.9 1.0 0.4 0.5 0.6 0.7 0.8 0.9 1.0 C1 C2 C3 1705 1728 1786 costs in $1000/yr
  • Back along C3: \(\nabla f \cdot (1, -0.132) = +841.5\)
  • Along C2: \(\nabla f \cdot (-1, 0.978) = +173.9\)

Step 4: Stop

Along both edges leaving this corner, \(\nabla f \cdot \mathbf{d} > 0\): the cost would rise, to 1728 or 1786 from 1705.

For a linear objective, a corner with no decreasing edge is an optimum: no further walking can help. The simplex algorithm took 2 steps and never looked at several corners.

Solvers do the same walk with algebra: a tableau by hand.

Shadow Prices

Binding Constraints

A solution is found at a corner of the feasible region. At that corner, two or more constraints are binding: if we relaxed one by weakening it, we could improve the solution.

At our optimum, C2 and C3 bind; C1 is slack, and changing its value would not improve the solution.

Shadow Prices

The marginal cost of a constraint is the amount by which the solution would improve if the constraint capacity was relaxed by one unit.

This is also referred to as the shadow price (these are also called the dual variables of the constraint).

Non-zero shadow prices tell us that the constraint is binding, and their values rank which constraints are most influential.

Lagrange Multipliers Need Equalities

Lagrange multipliers handle equality constraints. Standard form has inequalities, so each gets a squared slack variable:

\[g_j(\mathbf{x}) \leq b_j \qquad \text{becomes} \qquad g_j(\mathbf{x}) + S_j^2 = b_j\]

  • \(S_j = 0\): constraint \(j\) is binding.
  • \(S_j \neq 0\): it is slack, with \(S_j^2\) to spare.
  • Squaring keeps the slack from going negative.

Shadow Prices Are Lagrange Multipliers

For standard form, \(\max f(\mathbf{x})\) subject to \(g_j(\mathbf{x}) + S_j^2 = b_j\), the Lagrangian weights each constraint by a multiplier \(\lambda_j\):

\[\mathcal{L} = f(\mathbf{x}) - \sum_j \lambda_j \left(g_j(\mathbf{x}) + S_j^2 - b_j\right) \quad \Rightarrow \quad \frac{\partial \mathcal{L}}{\partial b_j} = \lambda_j\]

Raising \(b_j\) by one unit relaxes constraint \(j\) and improves the objective by \(\lambda_j\): its shadow price.

Finding the Multipliers

At an optimum, every partial derivative of \(\mathcal{L}\) is zero:

\[\begin{aligned} \frac{\partial \mathcal{L}}{\partial x_i} = 0: &\quad \nabla f(\mathbf{x}) = \sum_j \lambda_j \nabla g_j(\mathbf{x}) \\[0.3em] \frac{\partial \mathcal{L}}{\partial S_j} = 0: &\quad 2\lambda_j S_j = 0 \end{aligned}\]

So \(\lambda_j = 0\) or \(S_j = 0\): a slack constraint has no shadow price, and only binding ones stay in the sum.

Computing the Shadow Prices

In standard form, \(f = -1000R_1 - 1200R_2\), and each gradient is a row of coefficients. C1 is slack \((S_1 \neq 0)\), so \(\lambda_1 = 0\) and \(\nabla f = \lambda_2 \nabla g_2 + \lambda_3 \nabla g_3\):

\[\begin{pmatrix} -1000 \\ -1200 \end{pmatrix} = \lambda_2 \begin{pmatrix} -450 \\ -460 \end{pmatrix} + \lambda_3 \begin{pmatrix} -75 \\ -568 \end{pmatrix}\]

\[\begin{aligned} -1000 &= -450\,\lambda_2 -75\,\lambda_3 \\ -1200 &= -460\,\lambda_2 -568\,\lambda_3 \end{aligned} \quad \Rightarrow \quad \lambda_2 = 2.16, \quad \lambda_3 = 0.36\]

Reading the Shadow Prices

Constraint Shadow price Meaning
C1 0 slack: relaxing it saves nothing
C2 $2,160/yr per µg/m³ the standard at B is relaxed
C3 $360/yr per µg/m³ the standard at C is relaxed

If mitigation near receptor B (rerouting a school bus stop, say) lets regulators relax the limit there by 5 µg/m³, it is worth up to about $10,800 per year in avoided treatment.

A Shadow Price Is a Rate

  • Units: objective units per unit of the right-hand side. For C2: $2,160/yr per µg/m³.
  • Marginal: the rate at the current optimum. It holds for small changes, while the same constraints bind.

Would relaxing the standard at B by 100 µg/m³ save 100 × $2,160?

A Shadow Price Holds Locally

Code
# A sketch LP: four constraint lines, each x2 = slope * x1 + intercept.
# We minimize x1 + 2 x2 and relax the third constraint by lowering its intercept.
sketch_slope = [4.5, -1, -1.5, 0.5]
sketch_intercept = [0, 16, 12, 0]
relaxed_by = [0, 4, 14]
relaxed_style = [:solid, :dashdot, :dot]
relaxed_name = ["1: now", "2: relaxed a little", "3: relaxed a lot"]

# Where constraint lines i and k cross
function sketch_crossing(i, k)
    x1 = (sketch_intercept[k] - sketch_intercept[i]) / (sketch_slope[i] - sketch_slope[k])
    return (x1, sketch_slope[i] * x1 + sketch_intercept[i])
end

# The optimum after relaxing the third constraint: it slides down the fourth
# line until the first and fourth lines meet, and can go no further
function sketch_optimum(relaxation)
    corner = sketch_crossing(1, 4)
    x1 = (sketch_intercept[3] - relaxation) / (sketch_slope[4] - sketch_slope[3])
    if x1 <= corner[1]
        return corner
    end
    return (x1, sketch_slope[4] * x1)
end

sketch_objective(point) = point[1] + 2 * point[2]

# Left: the feasible region now (darker) and after a large relaxation (lighter)
now_region = [sketch_crossing(1, 3), sketch_crossing(1, 2), sketch_crossing(2, 4),
    sketch_crossing(4, 3)]
largest_region = [sketch_crossing(1, 4), sketch_crossing(1, 2), sketch_crossing(2, 4)]
sketch_x1 = -3:0.5:14
p_move = plot(Shape(first.(largest_region), last.(largest_region)), color=cb_blue, alpha=0.1,
    linewidth=0, xlims=(-3, 14), ylims=(-3, 16), ticks=false, xlabel=L"x_1", ylabel=L"x_2",
    legend=:topright, legendfontsize=13)
plot!(p_move, Shape(first.(now_region), last.(now_region)), color=cb_blue, alpha=0.25,
    linewidth=0)
for i in [1, 2, 4]
    plot!(p_move, sketch_x1, sketch_slope[i] .* sketch_x1 .+ sketch_intercept[i],
        color=:gray30, linewidth=2.5)
end
for k in 1:length(relaxed_by)
    plot!(p_move, sketch_x1, sketch_slope[3] .* sketch_x1 .+ sketch_intercept[3] .- relaxed_by[k],
        color=cb_vermillion, linestyle=relaxed_style[k], linewidth=2.5, label=relaxed_name[k])
    best = sketch_optimum(relaxed_by[k])
    scatter!(p_move, [best[1]], [best[2]], color=cb_vermillion, markersize=9,
        markerstrokecolor=:black)
    annotate!(p_move, best[1] + 0.9, best[2] - 1.1, text(string(k), 18, :black, plot_font))
end

# Right: the optimal objective against the relaxation, and what the shadow price predicts
relaxation = 0:0.5:16
actual = [sketch_objective(sketch_optimum(r)) for r in relaxation]
predicted = sketch_objective(sketch_optimum(0)) .- relaxation
p_value = plot(relaxation, predicted, color=cb_vermillion, linestyle=:dash, linewidth=2.5,
    ticks=false, xlims=(-1, 17), ylims=(-5.5, 13.5),
    xlabel="How far the constraint is relaxed", ylabel="Optimal cost")
plot!(p_value, relaxation, actual, color=:black, linewidth=3)
for k in 1:length(relaxed_by)
    value_k = sketch_objective(sketch_optimum(relaxed_by[k]))
    scatter!(p_value, [relaxed_by[k]], [value_k], color=cb_vermillion, markersize=9,
        markerstrokecolor=:black)
    annotate!(p_value, relaxed_by[k] + 0.5, value_k + 1.2, text(string(k), 18, :black, plot_font))
end
annotate!(p_value, 12.6, -3, text("the shadow price predicts", 15, :right, :black, plot_font))
annotate!(p_value, 16.6, 4, text("re-solving gives", 15, :right, :black, plot_font))

plot(p_move, p_value, layout=(1, 2), size=(1150, 310), left_margin=12mm, bottom_margin=8mm)
1: now 2: relaxed a little 3: relaxed a lot 1 2 3 How far the constraint is relaxed Optimal cost 1 2 3 the shadow price predicts re-solving gives
Figure 1: A sketch LP with one constraint relaxed twice. Left: the optimum slides along another constraint until a corner stops it. Right: the optimal objective against how far the constraint is relaxed, with the shadow price’s prediction dashed.

From 1 to 2 the same constraints bind, so the shadow price holds. By 3 the relaxed constraint no longer binds, and it fails.

Solving LPs in JuMP

The Same Problem in JuMP

# The formulation, one line for each variable and constraint
naive_model = Model(HiGHS.Optimizer)
@variable(naive_model, 0 <= R1 <= 1)
@variable(naive_model, 0 <= R2 <= 1)
@objective(naive_model, Min, 1000 * R1 + 1200 * R2)
@constraint(naive_model, C1, 875 * R1 >= 679)
@constraint(naive_model, C2, 450 * R1 + 460 * R2 >= 714)
@constraint(naive_model, C3, 75 * R1 + 568 * R2 >= 447)
set_silent(naive_model)
optimize!(naive_model)
println("R1 = ", value(R1), ", R2 = ", value(R2))
println("Cost: \$", objective_value(naive_model), " thousand per year")
R1 = 0.9042605156037992, R2 = 0.6675712347354138
Cost: $1705.3459972862956 thousand per year

Same solution we got earlier: \(R_1\) = 0.90 and \(R_2\) = 0.67.

Writing the Model with Indices

emissions_model = Model(HiGHS.Optimizer)
@variable(emissions_model, 0 <= removal[i in 1:n_stacks] <= 1)
@objective(emissions_model, Min, sum(cost[i] * removal[i] for i in 1:n_stacks))
@constraint(emissions_model, limit[j in 1:n_receptors],
    sum(t[j, i] * emission[i] * (1 - removal[i])
        for i in 1:n_stacks) <= standard)
set_silent(emissions_model)
optimize!(emissions_model)
println("Removals: ", value.(removal))
println("Cost: \$", objective_value(emissions_model), " thousand per year")
Removals: [0.9042605156037992, 0.6675712347354138]
Cost: $1705.3459972862956 thousand per year

One line, limit[j], writes all three constraints. Adding a stack or a receptor changes only the data.

Reading the Duals

limit_prices = shadow_price.(limit)
JuMP By hand
C1 0.0 0
C2 -2.16 2.16
C3 -0.36 0.36
  • By hand, in standard form, we maximized \(-\)cost. Relaxing C2 raises that by \(+2.16\).
  • JuMP minimized cost. Relaxing C2 changes it by \(-2.16\).

Check It: Re-solve

# Relax C2, the standard at receptor B, by 1 µg/m³ and solve again
base_cost = objective_value(emissions_model)
set_normalized_rhs(limit[2], normalized_rhs(limit[2]) + 1)
optimize!(emissions_model)
relaxed_cost = objective_value(emissions_model)
println("Removals: ", value.(removal))
println("Cost: \$", relaxed_cost, " thousand per year")
println("Change in cost: ", relaxed_cost - base_cost)
Removals: [0.9016915422885572, 0.667910447761194]
Cost: $1703.18407960199 thousand per year
Change in cost: -2.1619176843055357

The cost changes by -2.16 ($1000/yr): the shadow price of C2, as promised.

Key Takeaways and Upcoming Schedule

Key Takeaways

  • Simplex walks “downhill” from corner to corner and stops when no edge improves.
  • At an optimum, binding constraints hold exactly; slack ones do not limit the solution.
  • A shadow price is how much the objective improves per unit a constraint is relaxed.
  • Shadow prices are marginal rates of improvement.

Next Classes

Wednesday: Quiz 3, then the project proposal peer review.

Next Monday: Lab: linear programming with JuMP.jl.

Assessments

Homework 5: Due Thursday, October 22.

Mini-Project 1: Due Friday, October 23.

Homework 6: Assigned Wednesday, due Thursday, October 29.

Appendix

A Simplex Tableau by Hand

Easiest when the origin is a feasible corner:

\[\begin{aligned} \max_{x_1, x_2} \quad & 230x_1 + 120x_2 \\ \text{s.t.} \quad & 0.9x_1 + 0.5x_2 + s_1 = 600, \quad x_1 + x_2 + s_2 = 1000 \end{aligned}\]

At the origin, \(s_1 = 600\) and \(s_2 = 1000\).

  • Entering: \(x_1\), the largest objective coefficient (230).
  • Leaving: \(s_1\), by the ratio test \(\min(600/0.9,\ 1000)\).

A Simplex Tableau by Hand

\[z = 153333 - 7.8\,x_2 - 255.6\,s_1\]

Every coefficient is negative, so no pivot can raise \(z\): optimal at \(x_1 =\) 666.7 and \(x_2 = 0\).

The coefficient of each slack is that constraint’s shadow price: about 255.6 for the first, 0 for the second (its slack is still positive).

References

References

Dantzig, G. B. (1963). Linear Programming and Extensions. Princeton, NJ: Princeton University Press.