forked from tmbb/dantzig
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathassignment_problem.exs
More file actions
402 lines (377 loc) · 15 KB
/
Copy pathassignment_problem.exs
File metadata and controls
402 lines (377 loc) · 15 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
#!/usr/bin/env elixir
# Assignment Problem Example
# ==========================
#
# This example demonstrates how to solve the classic Assignment Problem using
# the Dantzig DSL. The assignment problem is one of the fundamental problems
# in combinatorial optimization, with applications in resource allocation,
# scheduling, and matching problems.
#
# Business Context
# ----------------
# The assignment problem arises in many real-world scenarios:
#
# - **Workforce Scheduling**: Assign employees to shifts or projects optimally
# - **Task Allocation**: Match tasks to workers based on skills and costs
# - **Resource Matching**: Assign resources to demands (machines to jobs, etc.)
# - **Bipartite Matching**: Match items from two sets (e.g., students to projects)
# - **Transportation**: Assign vehicles to routes or deliveries
# - **Sports Scheduling**: Assign referees to games or teams to venues
#
# In this example, we assign workers to tasks with the goal of minimizing
# total assignment cost while ensuring:
# - Each worker is assigned to exactly one task
# - Each task is assigned to exactly one worker
#
# This creates a perfect matching (one-to-one correspondence) between
# workers and tasks.
#
# Mathematical Formulation
# ------------------------
#
# **Decision Variables:**
# x[w,t] ∈ {0,1} where x[w,t] = 1 if worker w is assigned to task t
#
# **Parameters:**
# c[w,t] = cost of assigning worker w to task t
# W = set of workers
# T = set of tasks
#
# **Constraints:**
# - Each worker assigned to exactly one task:
# Σₜ x[w,t] = 1 for all w ∈ W
#
# - Each task assigned to exactly one worker:
# Σw x[w,t] = 1 for all t ∈ T
#
# **Objective:**
# Minimize total assignment cost:
# minimize Σw Σₜ c[w,t] · x[w,t]
#
# This is a special case of the transportation problem where:
# - All supplies (workers) have supply = 1
# - All demands (tasks) have demand = 1
# - The problem is balanced (|W| = |T|)
#
# Because the problem is balanced and all supplies/demands are 1, the
# solution will naturally be integer-valued even if we relax the binary
# constraint (this is due to the total unimodularity of the constraint matrix).
#
# DSL Syntax Explanation
# ----------------------
#
# **Model Parameters:**
# Problem.define model_parameters: %{data: data} do
# # Constants from model_parameters are accessible in expressions
# end
#
# Model parameters allow passing runtime data (like cost matrices) into
# the problem definition. They're accessed using map notation:
# - cost_matrix[w][t] accesses nested map values
# - String keys are automatically converted to atom keys when needed
#
# **Variables with Lists:**
# variables("assign", [w <- workers, t <- tasks], :binary, "Description")
#
# When using lists (not ranges) in generators:
# - The list values become variable indices
# - Order matters: variables are created for all combinations
# - Access variables using: assign(w, t) where w ∈ workers, t ∈ tasks
#
# **Constraints with Sums:**
# constraints([w <- workers], sum(assign(w, :_)) == 1, "Description")
#
# - sum(assign(w, :_)) sums over all tasks t for worker w
# - sum(assign(:_, t)) sums over all workers w for task t
# - The wildcard :_ means "all values of this index"
#
# **Objective with Expressions:**
# objective(
# sum(for w <- workers, t <- tasks, do: assign(w, t) * cost_matrix[w][t]),
# direction: :minimize
# )
#
# - Can use for-comprehensions in objective expressions
# - Can access model_parameters (cost_matrix) in expressions
# - Can multiply variables by constants from model_parameters
#
# **Problem Modification:**
# problem = Problem.modify(problem) do
# objective(new_expression, direction: :minimize)
# end
#
# Problem.modify allows updating an existing problem without recreating
# variables and constraints. Useful for:
# - Changing objectives
# - Adding new constraints
# - Updating bounds
#
# Common Gotchas
# --------------
#
# 1. **Nested Map Access:**
# - cost_matrix[w][t] works when accessing model_parameters
# - String keys are automatically converted to atoms if needed
# - Ensure nested structure matches your access pattern
#
# 2. **Generator Variable Scope:**
# - Variables from generators (w, t) are available in expressions
# - Use list values directly: [w <- workers] not [w <- 1..length(workers)]
# - Generator variables create bindings accessible during evaluation
#
# 3. **Objective Expression Evaluation:**
# - For-comprehensions in objectives are evaluated at problem definition time
# - Can use variables and constants from model_parameters
# - Order of operations matters: assign(w, t) * cost_matrix[w][t]
#
# 4. **Perfect Matching Requirements:**
# - Assignment problem requires |W| = |T| (balanced problem)
# - If unbalanced, need dummy workers/tasks or different formulation
# - Each constraint must sum to exactly 1 (not <= or >=)
#
# 5. **Binary Variables:**
# - Use :binary type for assignment variables (0 or 1)
# - The problem structure ensures integer solutions naturally
# - Don't need to explicitly enforce x[w,t] ∈ {0,1} if problem is unimodular
#
# 6. **Cost Matrix Structure:**
# - Ensure cost_matrix has entries for all worker-task pairs
# - Missing entries will cause evaluation errors
# - Consider using default values or sparse representation for large problems
#
# 7. **Problem Modification:**
# - Problem.modify creates a new problem structure
# - Original problem is unchanged (immutable)
# - Variables and constraints are preserved unless explicitly changed
#
# 8. **Solution Variable Names:**
# - Solution variables use pattern: "var_name_index1_index2"
# - For assign(w, t), variable name is "assign_#{w}_#{t}"
# - Use Problem.get_variables_nd/2 for easier access with indices
require Dantzig.Problem, as: Problem
require Dantzig.Problem.DSL, as: DSL
# ============================================================================
# Problem Data Setup
# ============================================================================
#
# Define the cost matrix: cost[worker][task] = cost of assigning worker to task
# This represents the cost (time, money, or other metric) of each assignment.
#
# In a real application, costs might represent:
# - Time required for worker to complete task
# - Skill mismatch penalty
# - Travel time or distance
# - Preference scores (negated for minimization)
#
cost_matrix = %{
"Alice" => %{"Task1" => 2, "Task2" => 3, "Task3" => 1},
"Bob" => %{"Task1" => 4, "Task2" => 2, "Task3" => 3},
"Charlie" => %{"Task1" => 3, "Task2" => 1, "Task3" => 4}
}
# Extract workers and tasks from the cost matrix
# This pattern ensures consistency: workers and tasks match cost matrix keys
workers = Map.keys(cost_matrix)
# Tasks is the set of all possible tasks across all workers
# We extract unique task names from the cost matrix
tasks =
cost_matrix
|> Enum.flat_map(fn {_k, v} -> Map.keys(v) end)
|> Enum.uniq()
|> Enum.sort()
# Display problem setup
IO.puts("==================")
IO.puts("Assignment Problem")
IO.puts("==================")
IO.puts("Workers: #{Enum.join(workers, ", ")}")
IO.puts("Tasks: #{Enum.join(tasks, ", ")}")
IO.puts("")
IO.puts("Cost Matrix:")
Enum.each(workers, fn worker ->
costs = Enum.map(tasks, fn task -> "#{task}:#{cost_matrix[worker][task]}" end)
IO.puts(" #{worker}: #{Enum.join(costs, ", ")}")
end)
IO.puts("")
# ============================================================================
# Problem Definition (Simplified Objective)
# ============================================================================
#
# First, we create the problem with a simplified objective to verify the
# constraint structure works correctly. We'll update the objective later
# using Problem.modify to use actual costs.
#
# Model Parameters:
# - workers: List of worker names (strings)
# - tasks: List of task names (strings)
# - cost_matrix: Nested map with cost[worker][task] = cost
#
# The model_parameters dictionary makes these values accessible within
# the Problem.define block.
#
problem =
Problem.define model_parameters: %{workers: workers, tasks: tasks, cost_matrix: cost_matrix} do
new(
name: "Assignment Problem",
description: "Assign workers to tasks to minimize total cost"
)
# Binary variables: assign[w,t] = 1 if worker w is assigned to task t, 0 otherwise
# Pattern: [w <- workers, t <- tasks] creates variables for all worker-task pairs
# Type: :binary ensures each variable is 0 or 1 (assigned or not assigned)
variables(
"assign",
[w <- workers, t <- tasks],
:binary,
"Whether worker is assigned to task"
)
# Constraint: each worker is assigned to exactly one task
# For each worker w, sum over all tasks t must equal 1
# The wildcard :_ means "sum over all values of this index"
constraints(
[w <- workers],
sum(assign(w, :_)) == 1,
"Each worker assigned to exactly one task"
)
# Constraint: each task is assigned to exactly one worker
# For each task t, sum over all workers w must equal 1
constraints(
[t <- tasks],
sum(assign(:_, t)) == 1,
"Each task assigned to exactly one worker"
)
# Objective: minimize total assignment cost (simplified for now)
# Initially, we just minimize the number of assignments (which is constant)
# This helps verify the problem structure before adding cost calculations
objective(
# Just sum all assignments (should equal number of workers/tasks)
sum(assign(:_, :_)),
direction: :minimize
)
end
# ============================================================================
# Helper Function: Calculate Total Cost from Solution
# ============================================================================
#
# This helper function extracts the assignment decisions from the solution
# and calculates the total cost by looking up costs in the cost matrix.
#
# Solution variables are named using the pattern: "var_name_index1_index2"
# For assign(w, t), the variable name is "assign_#{w}_#{t}"
#
# We check if assigned > 0.5 to handle floating-point solutions (though
# binary variables should be exactly 0 or 1).
#
calculate_total_cost = fn solution, workers, tasks, cost_matrix ->
{total_cost, _} =
Enum.reduce(workers, {0, []}, fn worker, {acc_cost, _} ->
worker_assignments =
Enum.reduce(tasks, {[], 0}, fn task, {task_list, task_cost} ->
# Construct variable name from indices
var_name = "assign(#{worker},#{task})"
assigned = solution.variables[var_name]
# Check if this assignment is active (value > 0.5 for binary)
if assigned > 0.5 do
cost = cost_matrix[worker][task]
{[{worker, task, cost} | task_list], task_cost + cost}
else
{task_list, task_cost}
end
end)
# Display assignments for this worker
Enum.each(elem(worker_assignments, 0), fn {w, t, c} ->
IO.puts(" #{w} → #{t} (cost: #{c})")
end)
{acc_cost + elem(worker_assignments, 1), []}
end)
total_cost
end
# ============================================================================
# Solve with Simplified Objective
# ============================================================================
#
# First solve with the simplified objective to verify constraints work correctly.
# The objective value should equal the number of workers (since each worker
# is assigned exactly once).
#
IO.puts("Solving the simplified assignment problem...")
IO.puts("(Objective: minimize number of assignments)")
IO.puts("")
case Problem.solve(problem, solver: :highs, print_optimizer_input: true) do
{solution, objective_value} ->
IO.puts("Solution:")
IO.puts("=========")
IO.puts("Objective value (simplified): #{objective_value}")
IO.puts("")
IO.puts("Assignments:")
total_cost = calculate_total_cost.(solution, workers, tasks, cost_matrix)
IO.puts("")
IO.puts("Summary:")
IO.puts(" Total cost (from cost matrix): #{total_cost}")
IO.puts(" Reported objective (simplified): #{objective_value}")
IO.puts(" Cost matches objective: #{abs(total_cost - objective_value) < 0.001}")
IO.puts("")
:error ->
IO.puts("❌ Failed to solve the problem")
System.halt(1)
end
# ============================================================================
# Problem Modification: Update Objective with Actual Costs
# ============================================================================
#
# Now we use Problem.modify to update the objective function to use actual
# costs from the cost matrix. This demonstrates how to modify an existing
# problem without recreating variables and constraints.
#
# The new objective expression:
# sum(for w <- workers, t <- tasks, do: assign(w, t) * cost_matrix[w][t])
#
# This multiplies each assignment variable by its corresponding cost and
# sums the result, giving us the total assignment cost.
#
problem =
Problem.modify problem do
# Update objective to minimize total assignment cost
# For each worker-task pair, multiply assignment variable by cost
# Sum all products to get total cost
# Note: Nested map access cost_matrix[w][t] with variables from for-comprehension
# doesn't work correctly, so using explicit terms for now
objective(
assign("Alice", "Task1") * cost_matrix["Alice"]["Task1"] +
assign("Alice", "Task2") * cost_matrix["Alice"]["Task2"] +
assign("Alice", "Task3") * cost_matrix["Alice"]["Task3"] +
assign("Bob", "Task1") * cost_matrix["Bob"]["Task1"] +
assign("Bob", "Task2") * cost_matrix["Bob"]["Task2"] +
assign("Bob", "Task3") * cost_matrix["Bob"]["Task3"] +
assign("Charlie", "Task1") * cost_matrix["Charlie"]["Task1"] +
assign("Charlie", "Task2") * cost_matrix["Charlie"]["Task2"] +
assign("Charlie", "Task3") * cost_matrix["Charlie"]["Task3"],
:minimize
)
end
# ============================================================================
# Solve with Actual Cost Objective
# ============================================================================
#
# Solve again with the cost-based objective. The solution should now minimize
# the total assignment cost while still satisfying all constraints.
#
IO.puts("Solving the full assignment problem...")
IO.puts("(Objective: minimize total assignment cost)")
IO.puts("")
case Problem.solve(problem, solver: :highs, print_optimizer_input: true) do
{solution, objective_value} ->
IO.puts("Solution:")
IO.puts("=========")
IO.puts("Objective value (total cost): #{objective_value}")
IO.puts("")
IO.puts("Assignments:")
total_cost = calculate_total_cost.(solution, workers, tasks, cost_matrix)
IO.puts("")
IO.puts("Summary:")
IO.puts(" Total cost (from cost matrix): #{total_cost}")
IO.puts(" Reported objective (from solver): #{objective_value}")
IO.puts(" Cost matches objective: #{abs(total_cost - objective_value) < 0.001}")
IO.puts("")
IO.puts("Optimal assignment found! ✓")
:error ->
IO.puts("❌ Failed to solve the problem")
System.halt(1)
end