Objectives, Decision Variables, Constraints, and Feasibility › Implementation in SciPy and NumPy
Machine Learning from ScratchAll chapters 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
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]Expand
# 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]Expand
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]Expand
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 and negate the returned objective to recover profit.
PythonIn [12]Expand
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 ; passing as the lower bound makes it a pure 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]Expand
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]Expand
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 with profit 186.67. The integer solver returns 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]Expand
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]Expand

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]Expand
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]Expand
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]Expand
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]Expand

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]Expand
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]Expand
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.6667For 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 ; 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!