Objectives, Decision Variables, Constraints, and Feasibility › Implementation in SciPy and NumPy

Michael BrenndoerferOctober 29, 20269 min read
Machine Learning from ScratchAll chapters
Part 14 of 15Constrained Optimization
Chapter 84Objectives, Decision Variables, Constraints, and Feasibility › Implementation in SciPy and NumPy
Reading section 7 of 10 · Implementation in SciPy and NumPy · 9 minSection 7 of 10 · 9 min

Section 7 of 10 · 9 min read

Objectives, Decision Variables, Constraints, and Feasibility › Implementation in SciPy and NumPySection view · Sections in this chapter7 / 10 ↓
Reading tools Definitions, links & notes

Choose your expertise level to adjust how many terms are explained. Beginners see more tooltips, experts see fewer to maintain reading flow. Hover over underlined terms for instant definitions.

Article links

Make inline references clickable

Notes & comments

We use SciPy because it exposes all three layers we need in one place: linprog for the relaxation, milp for the integer problem, and minimize for the penalty experiment. The organizing principle is to build the audit trail first, because a solver's own report should never be the only evidence we keep. Each code block below corresponds to a step in the hand solution, so the numeric outputs can be checked against the algebra rather than accepted on faith.

PythonIn [9]
CollapseExpand
# Decision variables: pallets of product 1 and product 2.
A = np.array(
    [
        [2.0, 1.0],  # machine-hours per pallet
        [1.0, 2.0],
    ]
)  # labour-hours per pallet
b = np.array([8.0, 8.0])  # available machine-hours, labour-hours
c = np.array([40.0, 30.0])  # profit per pallet (currency units)

We start with brute force, which is both the ground truth for this tiny instance and the pattern we would use for any verification by enumeration.

PythonIn [10]
CollapseExpand
enumerated = []
for point in itertools.product(range(5), repeat=2):
    x = np.array(point, dtype=float)
    slack = b - A @ x
    if np.all(slack >= -1e-12):
        enumerated.append(
            (int(x[0]), int(x[1]), float(c @ x), float(slack.min()))
        )

best_enum = max(enumerated, key=lambda row: row[2])
n_feasible_enum = len(enumerated)
ConsoleOut [11]
CollapseExpand
feasible integer plans with 0 <= x_j <= 4 : 17
best by enumeration: x1=3, x2=2, profit=180.00, tightest slack=0.0

Next we solve the LP relaxation. linprog minimizes, so we pass −c-c and negate the returned objective to recover profit.

PythonIn [12]
CollapseExpand
lp = linprog(c=-c, A_ub=A, b_ub=b, bounds=[(0.0, None)] * 2, method="highs")
if not lp.success or lp.x is None or lp.fun is None:
    raise RuntimeError(f"LP failed: {lp.message}")
lp_profit = float(-lp.fun)

Then the integer problem. milp accepts a LinearConstraint matrix with lower and upper vectors, which is the general form bℓ≤Ax≤bub_{\ell} \leq Ax \leq b_u; passing −∞-\infty as the lower bound makes it a pure Ax≤buAx \leq b_u constraint. integrality marks which variables must be whole numbers, and setting the relative gap option to zero asks the solver to prove optimality rather than stop at its default tolerance.

PythonIn [13]
CollapseExpand
milp_fit = milp(
    c=-c,
    constraints=LinearConstraint(A, -np.inf, b),
    integrality=np.ones(2),
    bounds=Bounds(np.zeros(2), np.full(2, np.inf)),
    options={"mip_rel_gap": 0.0},
)
ConsoleOut [14]
CollapseExpand
LP relaxation : profit=186.6667 at x=[2.6667 2.6667]
MILP solution : profit=180.0000 at x=[3. 2.]
LP-relaxation gap : 3.70%
MILP solver gap   : 0.00%

The LP relaxation returns approximately (2.6667,2.6667)(2.6667,2.6667) with profit 186.67. The integer solver returns (3,2)(3,2) with profit 180. The computed LP-relaxation gap is 3.70 percent, while the solver's final MILP search gap is zero, matching the independently enumerated integer optimum.

The bound and the incumbent are easier to trust when they appear on the same scale. The relaxation is optimistic, the integer plan is attainable, and the gap is the distance between them.

PythonIn [16]
CollapseExpand
from book_plot_style import PALETTE, label_bars, polish_axes, use_book_style

use_book_style()
C = [PALETTE[key] for key in ("primary", "teal", "amber", "purple")]

fig = plt.figure(figsize=(6.0, 4.8))
ax = fig.add_subplot(111)
positions = [0, 1]
bars = ax.barh(
    positions,
    gap_values,
    color=[PALETTE["primary"], PALETTE["amber"]],
    height=0.5,
)
ax.set_yticks(positions, gap_labels)
ax.set_xlim(0.0, max(gap_values) * 1.18)
ax.set_ylim(-0.6, 1.8)
ax.set_xlabel("Profit (currency units)")
ax.set_title("Relaxation bound and integer incumbent")
label_bars(ax, bars, labels=[f"{value:.2f}" for value in gap_values])
ax.text(
    0.02,
    0.96,
    f"LP-relaxation gap: {gap_relative:.2%}\nFinal MILP gap: {milp_fit.mip_gap:.2%}",
    transform=ax.transAxes,
    va="top",
    color=PALETTE["ink"],
    bbox=dict(facecolor=PALETTE["paper"], alpha=0.92, edgecolor="none"),
)

polish_axes(ax)
plt.show()
VisualizationOut [16]
CollapseExpand
A horizontal bar chart compares the LP relaxation bound of about 186.67 with the integer incumbent plan of 180, with a 3.70 percent LP-relaxation gap and a zero final MILP search gap stated above the bars.
The LP relaxation and the best integer incumbent on a common profit scale. The relaxation is an optimistic bound, the integer plan is achievable, and their separation is the initial LP-relaxation integrality gap. The final MILP search gap is zero because the integer optimum has been proved.

The hand calculation, enumeration, and MILP solver agree on this instance. If they disagreed, we would investigate the formulation, implementation, and numerical behavior. Agreement is a useful cross-check, but shared input errors could still affect every method.

The audit function below is the part we would keep even in production. It ignores the solver's claim and recomputes everything from the decision vector: non-negativity, the signed slack of each constraint, the total overspend, the largest resource violation, the objective, and integrality.

PythonIn [17]
CollapseExpand
def audit(x, tol=1e-9):
    """Independent check of a claimed solution: feasibility first, then value."""
    x = np.asarray(x, dtype=float)
    if x.shape != c.shape or not np.all(np.isfinite(x)):
        raise ValueError(
            "Expected a finite decision vector with one value per product"
        )
    slack = b - A @ x
    return {
        "x": x,
        "slack": slack,
        "overspend": float(np.sum(np.maximum(0.0, -slack))),
        "max_resource_violation": float(np.max(np.maximum(0.0, -slack))),
        "profit": float(c @ x),
        "integer": bool(np.all(np.abs(x - np.round(x)) <= tol)),
        "feasible": bool(np.all(x >= -tol) and np.all(slack >= -tol)),
    }
ConsoleOut [18]
CollapseExpand
LP relaxation  feasible=True  integer=False overspend=0.0e+00 max_resource_violation=0.0e+00 profit=186.6667
MILP solver    feasible=True  integer=True  overspend=0.0e+00 max_resource_violation=0.0e+00 profit=180.0000
enumeration    feasible=True  integer=True  overspend=0.0e+00 max_resource_violation=0.0e+00 profit=180.0000

The LP answer satisfies the resource limits and nonnegativity but is fractional. The MILP and enumerated answers also satisfy the integer domain and have the same profit. This separates a feasible relaxation point from a feasible integer plan without relying solely on solver status.

The audit is most informative when the residuals are plotted. The three methods are shown in a fixed order, and no random sampling or tie-breaking is used in this comparison.

PythonIn [20]
CollapseExpand
from book_plot_style import PALETTE, label_bars, polish_axes, use_book_style

use_book_style()
C = [PALETTE[key] for key in ("primary", "teal", "amber", "purple")]

fig = plt.figure(figsize=(6.0, 4.8))
ax = fig.add_subplot(111)
machine_bars = ax.bar(
    slack_positions - slack_width / 2.0,
    slack_matrix[:, 0],
    width=slack_width,
    color=PALETTE["primary"],
    label="Machine slack",
)
labour_bars = ax.bar(
    slack_positions + slack_width / 2.0,
    slack_matrix[:, 1],
    width=slack_width,
    color=PALETTE["amber"],
    label="Labour slack",
)
label_bars(
    ax, machine_bars, labels=[f"{value:.0f}" for value in slack_matrix[:, 0]]
)
label_bars(
    ax, labour_bars, labels=[f"{value:.0f}" for value in slack_matrix[:, 1]]
)
ax.set_ylim(-0.05, 1.18)
ax.set_xticks(slack_positions, audit_names)
ax.set_ylabel("Slack (hours)")
ax.set_title("Independent slack audit across solution methods")
ax.axhline(0.0, color=PALETTE["muted"], lw=1.0)
ax.legend(loc="upper center", bbox_to_anchor=(0.5, -0.20), ncol=2)
polish_axes(ax)
plt.show()
VisualizationOut [20]
CollapseExpand
Grouped vertical bar chart shows machine and labour slack for the LP relaxation, the MILP solver, and enumeration, with non-negative bar heights confirming that each plan satisfies both resource limits.
Independent slack audit for the LP relaxation, the MILP solver, and brute-force enumeration. Non-negative machine and labour slacks confirm the two resource inequalities; the separate audit also checks nonnegativity and integrality. The LP relaxation is fractional despite satisfying both resource limits.

The last experiment replaces the two resource limits by a quadratic penalty while retaining continuous nonnegative variables. On this particular convex model, increasing the weight moves the penalized solution toward the LP optimum and reduces resource overuse. This is a property to verify for the model, rather than a consequence of continuity alone.

PythonIn [21]
CollapseExpand
def penalised(lam):
    """Negative profit plus a quadratic penalty on resource overuse."""

    def objective(x):
        over = np.maximum(0.0, A @ x - b)
        return float(-(c @ x) + lam * np.sum(over**2))

    return objective


penalty_rows = []
for lam in (0.5, 5.0, 50.0, 500.0):
    fit = minimize(
        penalised(lam),
        x0=np.array([1.0, 1.0]),
        method="L-BFGS-B",
        bounds=[(0.0, None)] * 2,
        options={"gtol": 1e-10, "maxiter": 10000},
    )
    if not fit.success or fit.x is None:
        raise RuntimeError(f"Penalty solve failed: {fit.message}")
    report = audit(fit.x)
    penalty_rows.append(
        (lam, fit.x[0], fit.x[1], report["overspend"], report["profit"])
    )
ConsoleOut [22]
CollapseExpand
  lambda       x1       x2    overspend     profit
     0.5   11.556    1.556     2.33e+01     508.89
       5    3.556    2.556     2.33e+00     218.89
      50    2.756    2.656     2.33e-01     189.89
     500    2.676    2.666     2.33e-02     186.99

LP relaxation target for comparison: 186.6667

For these weights the penalized solution exceeds the hard limits and reports profit unavailable to any feasible plan. As the weight increases, overuse declines and the profit approaches the continuous relaxation value. No integrality requirement appears in this penalty objective, so it does not enforce whole pallets. A penalized solution might happen to be integral in another problem; that coincidence is not a domain guarantee.

Key Parameters

For linprog, c is minimized, so profit maximization requires negating it. A_ub and b_ub specify row-wise inequalities Ax≤bAx\leq b; A_eq and b_eq specify equalities. bounds supplies lower and upper values per variable, with None meaning no bound. The default method="highs" chooses between HiGHS dual simplex and interior point; it is not a combined-method guarantee.

For milp, constraints accepts a LinearConstraint, a tuple convertible to one, or a sequence of those inputs. Variable bounds use the separate bounds=Bounds(...) argument. integrality uses 1 for ordinary integer variables and 0 for continuous variables. mip_rel_gap controls the requested relative search gap and time_limit caps allotted time; a time-limited result may have no feasible incumbent.

For minimize, x0 supplies the starting decision. method="L-BFGS-B" is a limited-memory quasi-Newton method for box-constrained continuous optimization. It does not handle general constraints through its constraints argument; choose a method that supports them if required. Its gtol is a threshold on the maximum absolute projected-gradient component. Termination can also depend on objective reduction or evaluation limits, so inspect the status and message before relying on a result.

Key Methods

The linprog and milp result interfaces used here provide status, success, and message; inspect these fields before using the candidate. linprog returns status == 0 for an optimal solve, 2 for infeasibility, and 3 for unboundedness; the message string names the cause. milp returns status == 0 when an optimal solution was found to the requested gap, 1 when a time or iteration limit was hit (which may leave x as None if no feasible incumbent was found), and 2 when the problem was proven infeasible. These are floating-point termination reports interpreted with the requested gap and numerical tolerances; the status alone is not an exact-arithmetic proof.

The .x field supplies the candidate decision and .fun supplies the solver's objective value in minimization form. The audit accepts that candidate but recomputes its value and constraints from A, b, and c; it does not trust the reported .fun. Check result status and the presence of these fields before using them.

Comments

Add to the conversation

A question, a correction, or an example from your own work.

No comments yet. Be the first to share your thoughts!

Newsletter

Stay up to date

Get new articles, book updates, and notes on AI and technology.

Unsubscribe at any time. Privacy policy