|
| 1 | +""" |
| 2 | +## GAMSSOURCE: https://www.gams.com/latest/gamslib_ml/libhtml/gamslib_trnspwl.html |
| 3 | +## LICENSETYPE: Demo |
| 4 | +## MODELTYPE: MIP, NLP |
| 5 | +## KEYWORDS: non linear programming, mixed integer linear programming, transportation problem, scheduling, economies of scale, non-convex objective, special ordered sets |
| 6 | +
|
| 7 | +
|
| 8 | +A Transportation Problem with discretized Economies of Scale (TRNSPWL) |
| 9 | +
|
| 10 | +This problem finds a least cost shipping schedule that meets |
| 11 | +requirements at markets and supplies at factories. This instance |
| 12 | +applies economies of scale which results in a non-convex |
| 13 | +objective. This is an extension of the trnsport model in the GAMS |
| 14 | +Model Library. |
| 15 | +
|
| 16 | +The original nonlinear term is "sum((i,j), c(i,j)*sqrt(x(i,j)))". |
| 17 | +We use the following discretization f(x) of sqrt(x) |
| 18 | +
|
| 19 | + For x<=50: f(x) = 1/sqrt(50)*x, |
| 20 | + for x>=400: f(x) = (sqrt(600)-sqrt(400))/200*(x-400) + sqrt(400) |
| 21 | + in between we discretize with linear interpolation between points |
| 22 | +
|
| 23 | +This discretization has some good properties: |
| 24 | + 0) f(x) is a continuous function |
| 25 | + 1) f(0)=0, otherwise we would pick up a fixed cost even for unused |
| 26 | + connections |
| 27 | + 2) a fine representation in the reasonable range of shipments |
| 28 | + (between 50 and 400) |
| 29 | + 3) f(x) underestimates sqrt in the area of x=0 to 600. Past that is |
| 30 | + overestimates sqrt. |
| 31 | +
|
| 32 | +The model is organized as follows: |
| 33 | + 1) We set a starting point for the NLP solver so it will get stuck |
| 34 | + in local optimum that is not the global optimum. |
| 35 | +
|
| 36 | + 2) We use three formulations for representing piecewise linear |
| 37 | + functions all based on the same discretization. |
| 38 | +
|
| 39 | + a) a formulation with SOS2 variables. This formulation mainly is |
| 40 | + based on the convex combination of neighboring |
| 41 | + points. Moreover, the domain of the discretization can be |
| 42 | + unbounded: we can assign a slope in the (potentially |
| 43 | + unbounded) first and last segment. |
| 44 | +
|
| 45 | + b) a formulation with SOS2 variables based on convex combinations |
| 46 | + of neighboring points. This formulation requires a bounded |
| 47 | + region for the discretization. Here we discretize between 0 and |
| 48 | + 600. |
| 49 | +
|
| 50 | + c) a formuation with binary variables. This also requires the |
| 51 | + domain to be bounded, but it does not rely on the convex |
| 52 | + combination of neighboring points. There are examples, where |
| 53 | + this formulation solves much faster than the formulation b). |
| 54 | +
|
| 55 | + In this example x is clearly bounded by 0 from below and |
| 56 | + min(smax(i,a(i),smax(j,b(j)) from above, so formulation b and c |
| 57 | + are sufficient and perform better on this particular model and |
| 58 | + instance. We added the formulation a to demonstrate how to model |
| 59 | + an unbounded discretization, in case there are no derived |
| 60 | + bounds. The formulation a can be easily adjusted to accommodate |
| 61 | + problems where only one end of the discretization is unbounded. |
| 62 | +
|
| 63 | + 3) We restart the non-convex NLP from the solution of the discretized |
| 64 | + model and hope that the NLP solver finds the global solution. |
| 65 | +
|
| 66 | +
|
| 67 | +Dantzig, G B, Chapter 3.3. In Linear Programming and Extensions. |
| 68 | +Princeton University Press, Princeton, New Jersey, 1963. |
| 69 | +""" |
| 70 | + |
| 71 | +from __future__ import annotations |
| 72 | + |
| 73 | +import numpy as np |
| 74 | + |
| 75 | +import gamspy.formulations as formulations |
| 76 | +from gamspy import ( |
| 77 | + Card, |
| 78 | + Container, |
| 79 | + Equation, |
| 80 | + Model, |
| 81 | + Options, |
| 82 | + Parameter, |
| 83 | + Problem, |
| 84 | + Sense, |
| 85 | + Set, |
| 86 | + Smax, |
| 87 | + Sum, |
| 88 | + Variable, |
| 89 | +) |
| 90 | +from gamspy.math import sqrt |
| 91 | + |
| 92 | + |
| 93 | +def main(): |
| 94 | + m = Container() |
| 95 | + |
| 96 | + # Sets |
| 97 | + i = Set( |
| 98 | + m, |
| 99 | + name="i", |
| 100 | + records=["seattle", "san-diego"], |
| 101 | + description="canning plants", |
| 102 | + ) |
| 103 | + j = Set( |
| 104 | + m, |
| 105 | + name="j", |
| 106 | + records=["new-york", "chicago", "topeka"], |
| 107 | + description="markets", |
| 108 | + ) |
| 109 | + |
| 110 | + # Parameters |
| 111 | + a = Parameter( |
| 112 | + m, |
| 113 | + name="a", |
| 114 | + domain=i, |
| 115 | + records=np.array([350, 600]), |
| 116 | + description="capacity of plant i in cases", |
| 117 | + ) |
| 118 | + |
| 119 | + b = Parameter( |
| 120 | + m, |
| 121 | + name="b", |
| 122 | + domain=j, |
| 123 | + records=np.array([325, 300, 275]), |
| 124 | + description="demand at market j in cases", |
| 125 | + ) |
| 126 | + |
| 127 | + d = Parameter( |
| 128 | + m, |
| 129 | + name="d", |
| 130 | + domain=[i, j], |
| 131 | + records=np.array([[2.5, 1.7, 1.8], [2.5, 1.8, 1.4]]), |
| 132 | + description="distance in thousands of miles", |
| 133 | + ) |
| 134 | + |
| 135 | + f = Parameter( |
| 136 | + m, |
| 137 | + name="f", |
| 138 | + records=90, |
| 139 | + description="freight in dollars per case per thousand miles", |
| 140 | + ) |
| 141 | + |
| 142 | + c = Parameter( |
| 143 | + m, |
| 144 | + name="c", |
| 145 | + domain=[i, j], |
| 146 | + description="transport cost in thousands of dollars per case", |
| 147 | + ) |
| 148 | + c[i, j] = f * d[i, j] / 1000 |
| 149 | + |
| 150 | + # Variables |
| 151 | + x = Variable( |
| 152 | + m, |
| 153 | + name="x", |
| 154 | + type="positive", |
| 155 | + domain=[i, j], |
| 156 | + description="shipment quantities in cases", |
| 157 | + ) |
| 158 | + |
| 159 | + # Equation |
| 160 | + |
| 161 | + # Objective Function; total transportation costs in thousands of dollars |
| 162 | + cost = Sum([i, j], c[i, j] * sqrt(x[i, j])) |
| 163 | + |
| 164 | + supply = Equation( |
| 165 | + m, |
| 166 | + name="supply", |
| 167 | + domain=i, |
| 168 | + description="observe supply limit at plant i", |
| 169 | + ) |
| 170 | + demand = Equation( |
| 171 | + m, |
| 172 | + name="demand", |
| 173 | + domain=j, |
| 174 | + description="satisfy demand at market j", |
| 175 | + ) |
| 176 | + |
| 177 | + supply[i] = Sum(j, x[i, j]) <= a[i] |
| 178 | + |
| 179 | + demand[j] = Sum(i, x[i, j]) >= b[j] |
| 180 | + |
| 181 | + transport = Model( |
| 182 | + m, |
| 183 | + name="transport", |
| 184 | + equations=m.getEquations(), |
| 185 | + problem=Problem.NLP, |
| 186 | + sense=Sense.MIN, |
| 187 | + objective=cost, |
| 188 | + ) |
| 189 | + |
| 190 | + # Start the local NLP solver in a local solution that is not globally |
| 191 | + # optimal |
| 192 | + x.l["seattle", "chicago"] = 25 |
| 193 | + x.l["seattle", "topeka"] = 275 |
| 194 | + x.l["san-diego", "new-york"] = 325 |
| 195 | + x.l["san-diego", "chicago"] = 275 |
| 196 | + |
| 197 | + localopt = Parameter( |
| 198 | + m, |
| 199 | + name="localopt", |
| 200 | + description="objective of local optimum that is not globally optimal", |
| 201 | + ) |
| 202 | + |
| 203 | + transport.solve(options=Options(nlp="conopt")) |
| 204 | + print( |
| 205 | + "Initial Objective Function Value: ", |
| 206 | + round(transport.objective_value, 3), |
| 207 | + ) |
| 208 | + |
| 209 | + localopt[...] = transport.objective_value |
| 210 | + |
| 211 | + # The first model (formulation a) implements a piecewise linear |
| 212 | + # approximation based on the convex combination of neighboring points |
| 213 | + # using SOS2 variables with unbounded segments at the beginning and |
| 214 | + # end of the discretization |
| 215 | + # Sets |
| 216 | + s = Set( |
| 217 | + m, |
| 218 | + name="s", |
| 219 | + records=["slope0"] + [f"s{i}" for i in range(1, 7)] + ["slopeN"], |
| 220 | + description="SOS2 elements", |
| 221 | + ) |
| 222 | + ss = Set( |
| 223 | + m, |
| 224 | + name="ss", |
| 225 | + domain=s, |
| 226 | + records=[f"s{i}" for i in range(1, 7)], |
| 227 | + description="sample points", |
| 228 | + ) |
| 229 | + |
| 230 | + # Parameters |
| 231 | + p = Parameter(m, name="p", domain=s, description="x coordinate of sample point") |
| 232 | + sqrtp = Parameter( |
| 233 | + m, name="sqrtp", domain=s, description="y coordinate of sample point" |
| 234 | + ) |
| 235 | + xlow = Parameter(m, name="xlow", records=50) |
| 236 | + xhigh = Parameter(m, name="xhigh", records=400) |
| 237 | + xmax = Parameter(m, name="xmax") |
| 238 | + |
| 239 | + xmax[...] = Smax(i, a[i]) |
| 240 | + |
| 241 | + if xmax.records.value[0] < xhigh.records.value[0]: |
| 242 | + raise Exception("xhigh too big") |
| 243 | + |
| 244 | + if xlow.records.value[0] < 0: |
| 245 | + raise Exception("xlow less than 0") |
| 246 | + |
| 247 | + # Equidistant sampling of the sqrt function with slopes at the beginning |
| 248 | + # and end |
| 249 | + p["slope0"] = -1 |
| 250 | + p[ss] = xlow + (xhigh - xlow) / (Card(ss) - 1) * ss.off |
| 251 | + p["slopeN"] = 1 |
| 252 | + |
| 253 | + sqrtp["slope0"] = -1 / sqrt(xlow) |
| 254 | + sqrtp[ss] = sqrt(p[ss]) |
| 255 | + sqrtp["slopeN"] = (sqrt(xmax) - sqrt(xhigh)) / (xmax - xhigh) |
| 256 | + |
| 257 | + x_points = [record[-1] for record in p[ss].toList()] |
| 258 | + y_points = [record[-1] for record in sqrtp[ss].toList()] |
| 259 | + left_gradient = -sqrtp["slope0"].toList()[0] |
| 260 | + right_gradient = sqrtp["slopeN"].toList()[0] |
| 261 | + sqrtx, eqs = formulations.pwl_convexity_formulation( |
| 262 | + x, |
| 263 | + [-float("inf"), *x_points, float("inf")], |
| 264 | + [left_gradient, *y_points, right_gradient], |
| 265 | + using="sos2", |
| 266 | + ) |
| 267 | + sqrtx.lo[...] = 0 |
| 268 | + |
| 269 | + # Alternatively, use the Graph API: |
| 270 | + # graph = formulations.PWLGraph( |
| 271 | + # list(zip(x_points, y_points, strict=True)), |
| 272 | + # left_gradient=left_gradient, |
| 273 | + # right_gradient=right_gradient, |
| 274 | + # ) |
| 275 | + # sqrtx, eqs = formulations.pwlinear( |
| 276 | + # x, graph, method="convexity", using="sos2" |
| 277 | + # ) |
| 278 | + |
| 279 | + defobjdisc = Sum([i, j], c[i, j] * sqrtx[i, j]) |
| 280 | + |
| 281 | + trnsdiscA = Model( |
| 282 | + m, |
| 283 | + name="trnsdiscA", |
| 284 | + equations=[supply, demand, *eqs], |
| 285 | + problem="mip", |
| 286 | + sense=Sense.MIN, |
| 287 | + objective=defobjdisc, |
| 288 | + ) |
| 289 | + |
| 290 | + trnsdiscA.solve(options=Options(relative_optimality_gap=0)) |
| 291 | + |
| 292 | + # The next model (formulation b) uses the convex combinations of |
| 293 | + # neighboring points but requires the discretization to be bounded |
| 294 | + # (here we go from 0 to xmax). |
| 295 | + p["slope0"] = 0 |
| 296 | + p[ss] = xlow + (xhigh - xlow) / (Card(ss) - 1) * ss.off |
| 297 | + p["slopeN"] = xmax |
| 298 | + sqrtp[s] = sqrt(p[s]) |
| 299 | + |
| 300 | + x_points = [0, *[record[-1] for record in p[ss].toList()], xmax.toValue()] |
| 301 | + y_points = [ |
| 302 | + 0, |
| 303 | + *[record[-1] for record in sqrtp[ss].toList()], |
| 304 | + sqrtp["slopeN"].toList()[0], |
| 305 | + ] |
| 306 | + sqrtx, eqs = formulations.pwl_convexity_formulation( |
| 307 | + x, x_points, y_points, using="sos2" |
| 308 | + ) |
| 309 | + |
| 310 | + # Alternatively, use the Graph API: |
| 311 | + # graph = formulations.PWLGraph(list(zip(x_points, y_points, strict=True))) |
| 312 | + # sqrtx, eqs = formulations.pwlinear( |
| 313 | + # x, graph, method="convexity", using="sos2" |
| 314 | + # ) |
| 315 | + |
| 316 | + defobjdisc = Sum([i, j], c[i, j] * sqrtx[i, j]) |
| 317 | + |
| 318 | + trnsdiscB = Model( |
| 319 | + m, |
| 320 | + name="trnsdiscB", |
| 321 | + equations=[supply, demand, *eqs], |
| 322 | + problem="mip", |
| 323 | + sense=Sense.MIN, |
| 324 | + objective=defobjdisc, |
| 325 | + ) |
| 326 | + |
| 327 | + trnsdiscB.solve() |
| 328 | + |
| 329 | + # The next model (formulation c) implements another formulation for a |
| 330 | + # piecewise linear function. We need to assume that the domain region |
| 331 | + # is bounded. We use the same discretization as in the previous |
| 332 | + # formulation. |
| 333 | + sqrtx, eqs = formulations.pwl_interval_formulation(x, x_points, y_points) |
| 334 | + |
| 335 | + # Alternatively, use the Graph API with the graph defined above: |
| 336 | + # sqrtx, eqs = formulations.pwlinear(x, graph, method="interval") |
| 337 | + |
| 338 | + defobjdisc = Sum([i, j], c[i, j] * sqrtx[i, j]) |
| 339 | + |
| 340 | + trnsdiscC = Model( |
| 341 | + m, |
| 342 | + name="trnsdiscC", |
| 343 | + equations=[supply, demand, *eqs], |
| 344 | + problem="mip", |
| 345 | + sense=Sense.MIN, |
| 346 | + objective=defobjdisc, |
| 347 | + ) |
| 348 | + |
| 349 | + trnsdiscC.solve() |
| 350 | + |
| 351 | + # Now restart the local solver from this approximate point |
| 352 | + transport.solve(options=Options(nlp="conopt")) |
| 353 | + |
| 354 | + print( |
| 355 | + "Improved Objective Function Value: ", |
| 356 | + round(transport.objective_value, 3), |
| 357 | + ) |
| 358 | + |
| 359 | + # Ensure that we are better off transport.objective_value |
| 360 | + if transport.objective_value - localopt.toValue() > 1e-6: |
| 361 | + raise Exception("we should get an improved transport.objective_value") |
| 362 | + |
| 363 | + |
| 364 | +if __name__ == "__main__": |
| 365 | + main() |
0 commit comments