Skip to content

Commit 2f19c85

Browse files
committed
added docstrings
1 parent 2b0e0a9 commit 2f19c85

File tree

2 files changed

+327
-205
lines changed

2 files changed

+327
-205
lines changed

linear_programming/simplex.py

+327
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,327 @@
1+
"""
2+
Python implementation of the simplex algorithm for solving linear programs in
3+
tabular form with
4+
- `>=`, `<=`, and `=` constraints and
5+
- each variable `x1, x2, ...>= 0`.
6+
7+
See https://gist.github.com/imengus/f9619a568f7da5bc74eaf20169a24d98 for how
8+
to convert linear programs to simplex tableaus, and the steps taken in the
9+
simplex algorithm.
10+
11+
Resources:
12+
https://en.wikipedia.org/wiki/Linear_programming
13+
https://en.wikipedia.org/wiki/Simplex_algorithm
14+
15+
https://towardsdatascience.com/ \
16+
a-beginners-guide-to-linear-programming-and-the-simplex-algorithm \
17+
-87db017e92b4
18+
"""
19+
from typing import Any
20+
21+
import numpy as np
22+
23+
24+
class Tableau:
25+
"""Operate on simplex tableaus"""
26+
27+
def __init__(self, tableau: np.ndarray, n_vars: int) -> None:
28+
"""
29+
>>> tableau = np.array([[-1,-1,0,0,-1],[1,3,1,0,4],[3,1,0,1,4.]])
30+
>>> t = Tableau(tableau, 2)
31+
Traceback (most recent call last):
32+
...
33+
ValueError: RHS must be > 0
34+
"""
35+
rhs = tableau[:, -1]
36+
if np.any(rhs, where=rhs < 0):
37+
raise ValueError("RHS must be > 0")
38+
39+
if tableau is None:
40+
return
41+
42+
self.tableau = tableau
43+
self.n_rows = len(tableau[:, 0])
44+
self.n_cols = len(tableau[0])
45+
46+
# Number of decision variables x1, x2, x3...
47+
self.n_vars = n_vars
48+
49+
# Number of artificial variables to be minimised
50+
self.n_art_vars = len(np.where(tableau[self.n_vars : -1] == -1)[0])
51+
52+
# 2 if there are >= or == constraints (nonstandard), 1 otherwise (std)
53+
self.n_stages = (self.n_art_vars > 0) + 1
54+
55+
# Number of slack variables added to make inequalities into equalities
56+
self.n_slack = self.n_rows - self.n_stages
57+
58+
# Objectives for each stage
59+
self.objectives = ["max"]
60+
61+
# In two stage simplex, first minimise then maximise
62+
if self.n_art_vars:
63+
self.objectives.append("min")
64+
65+
self.col_titles = [""]
66+
67+
# Index of current pivot row and column
68+
self.row_idx = None
69+
self.col_idx = None
70+
71+
# Does objective row only contain (non)-negative values?
72+
self.stop_iter = False
73+
74+
@staticmethod
75+
def generate_col_titles(*args: int) -> list[str]:
76+
"""Generate column titles for tableau of specific dimensions
77+
78+
>>> Tableau.generate_col_titles(2, 3, 1)
79+
['x1', 'x2', 's1', 's2', 's3', 'a1', 'RHS']
80+
"""
81+
# decision | slack | artificial
82+
string_starts = ["x", "s", "a"]
83+
titles = []
84+
for i in range(3):
85+
for j in range(args[i]):
86+
titles.append(string_starts[i] + str(j + 1))
87+
titles.append("RHS")
88+
return titles
89+
90+
def find_pivot(self, tableau: np.ndarray) -> tuple[Any, Any]:
91+
"""Finds the pivot row and column.
92+
>>> tableau = np.array([
93+
... [-2,-1,0,0,0],
94+
... [3,1,1,0,6],
95+
... [1,2,0,1,7.]
96+
... ])
97+
>>> t = Tableau(tableau, 2)
98+
>>> t.find_pivot(t.tableau)
99+
(1, 0)
100+
"""
101+
objective = self.objectives[-1]
102+
103+
# Find entries of highest magnitude in objective rows
104+
sign = (objective == "min") - (objective == "max")
105+
col_idx = np.argmax(sign * tableau[0, : self.n_vars])
106+
107+
# Choice is only valid if below 0 for maximise, and above for minimise
108+
if sign * self.tableau[0, col_idx] <= 0:
109+
self.stop_iter = True
110+
return 0, 0
111+
112+
# Pivot row is chosen as having the lowest quotient when elements of
113+
# the pivot column divide the right-hand side
114+
115+
# Slice excluding the objective rows
116+
s = slice(self.n_stages, self.n_rows)
117+
118+
# RHS
119+
dividend = tableau[s, -1]
120+
121+
# Elements of pivot column within slice
122+
divisor = tableau[s, col_idx]
123+
124+
# Array filled with nans
125+
nans = np.full(self.n_rows - self.n_stages, np.nan)
126+
127+
# If element in pivot column is greater than zeron_stages, return
128+
# quotient or nan otherwise
129+
quotients = np.divide(dividend, divisor, out=nans, where=divisor > 0)
130+
131+
# Arg of minimum quotient excluding the nan values. n_stages is added
132+
# to compensate for earlier exclusion of objective columns
133+
row_idx = np.nanargmin(quotients) + self.n_stages
134+
return row_idx, col_idx
135+
136+
def pivot(self, tableau: np.ndarray, row_idx: int, col_idx: int) -> np.ndarray:
137+
"""Pivots on value on the intersection of pivot row and column.
138+
139+
>>> tableau = np.array([[-2,-3,0,0,0],[1,3,1,0,4],[3,1,0,1,4.]])
140+
>>> t = Tableau(tableau, 2)
141+
>>> t.pivot(t.tableau, 1, 0).tolist()
142+
... # doctest: +NORMALIZE_WHITESPACE
143+
[[0.0, 3.0, 2.0, 0.0, 8.0],
144+
[1.0, 3.0, 1.0, 0.0, 4.0],
145+
[0.0, -8.0, -3.0, 1.0, -8.0]]
146+
"""
147+
# Avoid changes to original tableau
148+
piv_row = tableau[row_idx].copy()
149+
150+
piv_val = piv_row[col_idx]
151+
152+
# Entry becomes 1
153+
piv_row *= 1 / piv_val
154+
155+
# Variable in pivot column becomes basic, ie the only non-zero entry
156+
for idx, coeff in enumerate(tableau[:, col_idx]):
157+
tableau[idx] += -coeff * piv_row
158+
tableau[row_idx] = piv_row
159+
return tableau
160+
161+
def change_stage(self, tableau: np.ndarray, objectives: list[Any]) -> np.ndarray:
162+
"""Exits first phase of the two-stage method by deleting artificial
163+
rows and columns, or completes the algorithm if exiting the standard
164+
case.
165+
166+
>>> tableau = np.array([
167+
... [3,3,-1,-1,0,0,4],
168+
... [2,1,0,0,0,0,0.],
169+
... [1,2,-1,0,1,0,2],
170+
... [2,1,0,-1,0,1,2]
171+
... ])
172+
>>> t = Tableau(tableau, 2)
173+
>>> t.change_stage(t.tableau, t.objectives).tolist()
174+
... # doctest: +NORMALIZE_WHITESPACE
175+
[[2.0, 1.0, 0.0, 0.0, 0.0, 0.0],
176+
[1.0, 2.0, -1.0, 0.0, 1.0, 2.0],
177+
[2.0, 1.0, 0.0, -1.0, 0.0, 2.0]]
178+
"""
179+
# Objective of original objective row remains
180+
objectives.pop()
181+
182+
if not objectives:
183+
return tableau
184+
185+
# Slice containing ids for artificial columns
186+
s = slice(-self.n_art_vars - 1, -1)
187+
188+
# Delete the artificial variable columns
189+
tableau = np.delete(tableau, s, axis=1)
190+
191+
# Delete the objective row of the first stage
192+
tableau = np.delete(tableau, 0, axis=0)
193+
194+
self.n_stages = 1
195+
self.n_rows -= 1
196+
self.n_art_vars = 0
197+
self.stop_iter = False
198+
self.objectives = objectives
199+
return tableau
200+
201+
def run_simplex(self) -> dict[Any, Any]:
202+
"""Operate on tableau until objective function cannot be
203+
improved further.
204+
205+
# Standard linear program:
206+
Max: x1 + x2
207+
ST: x1 + 3x2 <= 4
208+
3x1 + x2 <= 4
209+
>>> tableau = np.array([[-1,-1,0,0,0],[1,3,1,0,4],[3,1,0,1,4.]])
210+
>>> t = Tableau(tableau, 2)
211+
>>> t.run_simplex()
212+
{'P': 2.0, 'x1': 1.0, 'x2': 1.0}
213+
214+
# Optimal tableau input:
215+
>>> tableau = np.array([
216+
... [0,0,0.25,0.25,2],
217+
... [0,1,0.375,-0.125,1],
218+
... [1,0,-0.125,0.375,1]
219+
... ])
220+
>>> t = Tableau(tableau, 2)
221+
>>> t.run_simplex()
222+
{'P': 2.0, 'x1': 1.0, 'x2': 1.0}
223+
224+
# Non-standard: >= constraints
225+
Max: 2x1 + 3x2 + x3
226+
ST: x1 + x2 + x3 <= 40
227+
2x1 + x2 - x3 >= 10
228+
- x2 + x3 >= 10
229+
>>> tableau = np.array([
230+
... [2,0,0,0,-1,-1,0,0,20],
231+
... [-2,-3,-1,0,0,0,0,0,0],
232+
... [1,1,1,1,0,0,0,0,40],
233+
... [2,1,-1,0,-1,0,1,0,10],
234+
... [0,-1,1,0,0,-1,0,1,10.]
235+
... ])
236+
>>> t = Tableau(tableau, 3)
237+
>>> t.run_simplex()
238+
{'P': 70.0, 'x1': 10.0, 'x2': 10.0, 'x3': 20.0}
239+
240+
# Non standard: minimisation and equalities
241+
Min: x1 + x2
242+
ST: 2x1 + x2 = 12
243+
6x1 + 5x2 = 40
244+
>>> tableau = np.array([
245+
... [8,6,0,-1,0,-1,0,0,52],
246+
... [1,1,0,0,0,0,0,0,0],
247+
... [2,1,1,0,0,0,0,0,12],
248+
... [2,1,0,-1,0,0,1,0,12],
249+
... [6,5,0,0,1,0,0,0,40],
250+
... [6,5,0,0,0,-1,0,1,40.]
251+
... ])
252+
>>> t = Tableau(tableau, 2)
253+
>>> t.run_simplex()
254+
{'P': 7.0, 'x1': 5.0, 'x2': 2.0}
255+
"""
256+
# Stop simplex algorithm from cycling.
257+
iter_num = 0
258+
259+
while iter_num < 100:
260+
# Completion of each stage removes an objective. If both stages
261+
# are complete, then no objectives are left
262+
if not self.objectives:
263+
self.col_titles = self.generate_col_titles(
264+
self.n_vars, self.n_slack, self.n_art_vars
265+
)
266+
267+
# Find the values of each variable at optimal solution
268+
return self.interpret_tableau(self.tableau, self.col_titles)
269+
270+
row_idx, col_idx = self.find_pivot(self.tableau)
271+
272+
# If there are no more negative values in objective row
273+
if self.stop_iter:
274+
# Delete artificial variable columns and rows. Update attributes
275+
self.tableau = self.change_stage(self.tableau, self.objectives)
276+
277+
# Pivot again
278+
continue
279+
280+
self.tableau = self.pivot(self.tableau, row_idx, col_idx)
281+
iter_num += 1
282+
return {}
283+
284+
def interpret_tableau(
285+
self, tableau: np.ndarray, col_titles: list[str]
286+
) -> dict[str, float]:
287+
"""Given the final tableau, add the corresponding values of the basic
288+
decision variables to the `output_dict`
289+
>>> tableau = np.array([
290+
... [0,0,0.875,0.375,5],
291+
... [0,1,0.375,-0.125,1],
292+
... [1,0,-0.125,0.375,1]
293+
... ])
294+
>>> t = Tableau(tableau, 2)
295+
>>> t.interpret_tableau(tableau, ["x1", "x2", "s1", "s2", "RHS"])
296+
{'P': 5.0, 'x1': 1.0, 'x2': 1.0}
297+
"""
298+
output_dict = {}
299+
300+
# P = RHS of final tableau
301+
output_dict["P"] = abs(tableau[0, -1])
302+
303+
for i in range(self.n_vars):
304+
# Gives ids of nonzero entries in the ith column
305+
nonzero = np.nonzero(tableau[:, i])
306+
n_nonzero = len(nonzero[0])
307+
308+
# First entry in the nonzero ids
309+
nonzero_rowidx = nonzero[0][0]
310+
nonzero_val = tableau[nonzero_rowidx, i]
311+
312+
# If there is only one nonzero value in column, which is one
313+
if n_nonzero == nonzero_val == 1:
314+
rhs_val = tableau[nonzero_rowidx, -1]
315+
output_dict[col_titles[i]] = rhs_val
316+
# Check for basic variables
317+
for title in col_titles:
318+
# Don't add RHS or slack variables to output dict
319+
if title[0] not in "R-s":
320+
output_dict.setdefault(title, 0)
321+
return output_dict
322+
323+
324+
if __name__ == "__main__":
325+
import doctest
326+
327+
doctest.testmod()

0 commit comments

Comments
 (0)