pykp 0.0.22__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pykp/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .knapsack import Knapsack
2
+ from .item import Item
3
+ from .arrangement import Arrangement
4
+ from .sampler import Sampler
pykp/arrangement.py ADDED
@@ -0,0 +1,63 @@
1
+ import numpy as np
2
+ from .item import Item
3
+
4
+ class Arrangement():
5
+ """
6
+ Represents an arrangement of items for the knapsack problem.
7
+
8
+ Attributes:
9
+ * items (np.ndarray[Item]): An array of items for the knapsack problem.
10
+ * state (np.ndarray[int]): Binary array indicating the inclusion/exclusion of items in the arrangement.
11
+ * value (int): The total value of items in the arrangement.
12
+ * weight (int): The total weight of items in the arrangement.
13
+ """
14
+ def __init__(
15
+ self,
16
+ items: np.ndarray[Item],
17
+ state: np.ndarray[int],
18
+ ):
19
+ """Initialises an Arrangement instance.
20
+
21
+ Args:
22
+ items (np.ndarray[Item]): An array of items for the knapsack problem.
23
+ state (np.ndarray[int]): Binary array indicating the inclusion/exclusion of items in the arrangement.
24
+ capacity (int): The maximum weight capacity constraint for the arrangement.
25
+ """
26
+ if not np.all(np.isin(state, [0, 1])):
27
+ raise ValueError("Elements of `state` must be 0 or 1.")
28
+
29
+ self.items = items
30
+ self.state = state
31
+ self.value = self.__calculate_value()
32
+ self.weight = self.__calculate_weight()
33
+
34
+
35
+ def __calculate_value(self):
36
+ """
37
+ Calculates the total value of items currently in the knapsack.
38
+
39
+ Returns:
40
+ float: The total value of items in the knapsack.
41
+ """
42
+ return sum([self.items[i].value for i, inside in enumerate(self.state) if bool(inside)])
43
+
44
+
45
+ def __calculate_weight(self):
46
+ """
47
+ Calculates the total weight of items currently in the knapsack.
48
+
49
+ Returns:
50
+ float: The total weight of items in the knapsack.
51
+ """
52
+ return sum([self.items[i].weight for i, inside in enumerate(self.state) if bool(inside)])
53
+
54
+
55
+ def __str__(self):
56
+ state = int("".join(self.state.astype(int).astype(str)), 2)
57
+ return f"(v: {self.value}, w: {self.weight}, s: {state})"
58
+
59
+ def __repr__(self):
60
+ state = int("".join(self.state.astype(int).astype(str)), 2)
61
+ return f"(v: {self.value}, w: {self.weight}, s: {state})"
62
+
63
+
pykp/item.py ADDED
@@ -0,0 +1,40 @@
1
+ class Item:
2
+ """
3
+ Represents an item for the knapsack problem.
4
+
5
+ Attributes:
6
+ value (int): The value of the item.
7
+ weight (int): The weight of the item.
8
+ """
9
+ def __init__(self, value: int, weight: int):
10
+ """
11
+ Initialises an Item instance.
12
+
13
+ Args:
14
+ value (int): The value of the item.
15
+ weight (int): The weight of the item.
16
+ """
17
+ self.weight = weight
18
+ self.value = value
19
+
20
+ def update_value(self, new_value: int):
21
+ """Updates the value of the item.
22
+
23
+ Args:
24
+ new_value (int): New value of the item.
25
+ """
26
+ self.value = new_value
27
+
28
+ def update_weight(self, new_weight: int):
29
+ """Updates the weight of the item.
30
+
31
+ Args:
32
+ new_weight (int): New weight of the item.
33
+ """
34
+ self.weight = new_weight
35
+
36
+ def __str__(self):
37
+ return f"weight: {self.weight}; value: {self.value}"
38
+
39
+ def __repr__(self):
40
+ return str(self)
pykp/knapsack.py ADDED
@@ -0,0 +1,742 @@
1
+ import json
2
+ import numpy as np
3
+ from .item import Item
4
+ from .arrangement import Arrangement
5
+ import operator
6
+ import itertools
7
+ import pandas as pd
8
+ import matplotlib.pyplot as plt
9
+ from anytree import Node, PreOrderIter
10
+
11
+
12
+ class Knapsack:
13
+ """
14
+ Represents a knapsack problem solver.
15
+
16
+ Attributes:
17
+ * items (np.ndarray[Item]): An array of items available for the knapsack problem.
18
+ * capacity (int): The maximum weight capacity of the knapsack.
19
+ * state (np.ndarray): Binary array indicating the inclusion/exclusion of items in the knapsack.
20
+ * value (float): The total value of items currently in the knapsack.
21
+ * weight (float): The total weight of items currently in the knapsack.
22
+ * is_feasible (bool): Indicates if the knapsack is within its weight capacity.
23
+ * is_at_capacity (bool): Indicates if the knapsack is at full capacity.
24
+ * terminal_nodes (np.ndarray[Arrangement]): An array of all possible arrangements of items that are under the weight constraint, and at full capacity
25
+ * optimal_nodes (np.ndarray[Arrangement]): An array of optimal solutions to the knapsack problem.
26
+
27
+ Methods:
28
+ * add(item: Item) -> np.ndarray:
29
+ Adds the specified item to the knapsack and returns current state.
30
+
31
+ * remove(item: Item) -> np.ndarray:
32
+ Removes the specified item from the knapsack.
33
+
34
+ * set_state(state: np.ndarray) -> np.ndarray:
35
+ Sets the knapsack state using the provided binary array.
36
+
37
+ * empty() -> np.ndarray:
38
+ Empties the knapsack by setting all items to be excluded.
39
+
40
+ * solve_terminal_nodes():
41
+ Finds the terminal nodes of the knapsack.
42
+
43
+ * solve_feasible_nodes():
44
+ Finds the feasible nodes of the knapsack.
45
+
46
+ * solve_branch_and_bound():
47
+ Solves the optimal and second-best terminal nodes using best-first branch-and-bound.
48
+
49
+ * calculate_sahni_k(arrangement: Arrangement) -> int:
50
+ Calculates the Sahni-k value for a given arrangement.
51
+
52
+ * plot_terminal_nodes_histogram():
53
+ Plots a histogram of terminal node values.
54
+
55
+ * write_to_json():
56
+ Writes the knapsack configuration to a .json file.
57
+
58
+ * load_from_json():
59
+ Loads a knapsack configuration from a provided .json file.
60
+
61
+ * summary() -> pd.DataFrame:
62
+ Generates a summary DataFrame containing information about the knapsack state and solutions.
63
+ """
64
+ def __init__(
65
+ self,
66
+ items: np.ndarray[Item],
67
+ capacity: int,
68
+ load_from_json: bool = False,
69
+ path_to_spec: str = None
70
+ ):
71
+ """
72
+ Initialises a Knapsack instance.
73
+
74
+ Parameters:
75
+ items (np.ndarray[Item]): An array of items for the knapsack problem.
76
+ capacity (int): The maximum weight capacity of the knapsack.
77
+ load_from_json (bool, optional): Whether to load the instance from a .json spec. Default is False.
78
+ path_to_spec (str, optional): Path to json spec file. Default is None.
79
+ """
80
+
81
+ if load_from_json:
82
+ self.load_from_json(path_to_spec)
83
+ return
84
+
85
+ if len(items) == 0:
86
+ raise ValueError("`items` must have length greater than 0.")
87
+ if not np.all([isinstance(item, Item) for item in items]):
88
+ raise ValueError("All elements in `items` must be of type `Item`.")
89
+ if capacity < 0:
90
+ raise ValueError("`capacity` must be non-negative.")
91
+ if not isinstance(items, np.ndarray):
92
+ items = np.array(items)
93
+
94
+ self.items = items
95
+ self.capacity = capacity
96
+ self.state = np.zeros_like(items)
97
+ self.value = 0
98
+ self.weight = 0
99
+ self.is_feasible = True
100
+ self.is_at_capacity = False
101
+
102
+ self.nodes = np.array([])
103
+ self.feasible_nodes = np.array([])
104
+ self.terminal_nodes = np.array([])
105
+ self.optimal_nodes = np.array([])
106
+
107
+
108
+ def solve(
109
+ self,
110
+ solve_terminal_nodes: bool = False,
111
+ solve_feasible_nodes: bool = False,
112
+ solve_second_best: bool = True
113
+ ):
114
+ """
115
+ Solves the knapsack problem and returns optimal arrangements.
116
+
117
+ Parameters:
118
+ solve_terminal_nodes (bool, optional): Whether to find all terminal nodes. Default is False.
119
+ solve_feasible_nodes (bool, optional): Whether to find all feasible nodes. Default is False.
120
+ solve_second_bnest (bool, optional): Whether to find the second best node. Default is False.
121
+
122
+ Returns:
123
+ np.ndarray: Optimal arrangements for the knapsack problem.
124
+ """
125
+ if solve_terminal_nodes:
126
+ self.solve_terminal_nodes()
127
+
128
+ if solve_feasible_nodes:
129
+ self.solve_feasible_nodes()
130
+
131
+ self.solve_branch_and_bound(solve_second_best = solve_second_best)
132
+
133
+
134
+ def add(self, item: Item):
135
+ """
136
+ Adds the specified item to the knapsack.
137
+
138
+ Parameters:
139
+ item (Item): The item to be added to the knapsack.
140
+
141
+ Returns:
142
+ np.ndarray: The updated knapsack state.
143
+ """
144
+ if not isinstance(item, Item):
145
+ raise ValueError("`item` must be of type `Item`.")
146
+ if not item in self.items:
147
+ raise ValueError("`item` must be an existing `item` inside the `Knapsack` instance.")
148
+ self.state[np.where(self.items == item)[0][0]] = 1
149
+ self.__update_state()
150
+ return self.state
151
+
152
+
153
+ def remove(self, item: Item):
154
+ """
155
+ Removes the specified item from the knapsack.
156
+
157
+ Parameters:
158
+ item (Item): The item to be removed from the knapsack.
159
+
160
+ Returns:
161
+ np.ndarray: The updated knapsack state.
162
+ """
163
+ if not isinstance(item, Item):
164
+ raise ValueError("`item` must be of type `Item`.")
165
+ if not item in self.items:
166
+ raise ValueError("`item` must be an existing `item` inside the `Knapsack` instance.")
167
+
168
+ self.state[np.where(self.items == item)] = 0
169
+ self.__update_state()
170
+ return self.state
171
+
172
+
173
+ def set_state(self, state: np.ndarray):
174
+ """
175
+ Sets the knapsack state using the provided binary array.
176
+
177
+ Parameters:
178
+ state (np.ndarray): Binary array indicating the inclusion/exclusion of items in the knapsack.
179
+
180
+ Returns:
181
+ np.ndarray: The updated knapsack state.
182
+ """
183
+ self.state = state
184
+ self.__update_state()
185
+ return self.state
186
+
187
+
188
+ def empty(self):
189
+ """
190
+ Empties the knapsack by setting all items to be excluded.
191
+
192
+ Returns:
193
+ np.ndarray: The updated knapsack state.
194
+ """
195
+ self.state = np.zeros_like(self.items)
196
+ self.__update_state()
197
+ return self.state
198
+
199
+
200
+ def __update_state(self):
201
+ """
202
+ Private method to update the knapsacks internal state.
203
+ """
204
+ self.value = self.__calculate_value()
205
+ self.weight = self.__calculate_weight()
206
+ self.is_feasible = self.capacity >= self.weight
207
+ out_items = [self.items[i] for i, element in enumerate(self.state) if element == 0]
208
+ if sum(self.state) == len(self.state):
209
+ self.is_at_capacity = True
210
+ else:
211
+ self.is_at_capacity = min([
212
+ self.weight + item.weight
213
+ for item in out_items
214
+ ]) > self.capacity
215
+
216
+
217
+ def __calculate_value(self):
218
+ """
219
+ Calculates the total value of items currently in the knapsack.
220
+
221
+ Returns:
222
+ float: The total value of items in the knapsack.
223
+ """
224
+ mask = np.ma.make_mask(self.state, shrink=False)
225
+ return sum([item.value for item in self.items[mask]])
226
+
227
+
228
+ def __calculate_weight(self):
229
+ """
230
+ Calculates the total weight of items currently in the knapsack.
231
+
232
+ Returns:
233
+ float: The total weight of items in the knapsack.
234
+ """
235
+ mask = np.ma.make_mask(self.state, shrink=False)
236
+ return sum([item.weight for item in self.items[mask]])
237
+
238
+
239
+ def __calculate_upper_bound(
240
+ self,
241
+ included_items: np.ndarray[Item],
242
+ excluded_items: np.ndarray[Item]
243
+ ) -> float:
244
+ """
245
+ Calculates the upper bound of the supplied branch.
246
+
247
+ Args:
248
+ included_items (np.ndarray[Item]): Items included by all nodes within the branch.
249
+ excluded_items (np.ndarray[Item]): Items excluded by all nodes within the branch.
250
+
251
+ Returns:
252
+ float: Upper bound of the branch.
253
+ """
254
+ arrangement = Arrangement(
255
+ items = self.items,
256
+ state = np.array([int(item in included_items) for item in self.items])
257
+ )
258
+ candidate_items = np.array(sorted(
259
+ set(self.items) - set(included_items) - set(excluded_items),
260
+ key = lambda item: item.value/item.weight,
261
+ reverse = True
262
+ ))
263
+ balance = self.capacity - arrangement.weight
264
+
265
+ if len(candidate_items) == 0:
266
+ upper_bound = arrangement.value
267
+ else:
268
+ i = 0
269
+ upper_bound = arrangement.value
270
+ while balance > 0 and i < len(candidate_items):
271
+ item = candidate_items[i]
272
+ added_weight = min(balance, item.weight)
273
+ upper_bound = upper_bound + added_weight * item.value / item.weight
274
+ balance = balance - added_weight
275
+ i += 1
276
+ return upper_bound
277
+
278
+
279
+ def __explore_node(
280
+ self,
281
+ included_items: np.ndarray[Item],
282
+ excluded_items: np.ndarray[Item],
283
+ parent: Node,
284
+ upper_bound: float,
285
+ solve_second_best: bool,
286
+ ):
287
+ """
288
+ Determines weight/value of node and the upper bound of the branch containing the node. Prunes the branch if the upper bound is below the second-highest-valued terminal node discovered so far.
289
+
290
+ Args:
291
+ included_items (np.ndarray[Item]): Items included in the node.
292
+ excluded_items (np.ndarray[Item]): Items excluded in the branch.
293
+ parent (Node): Parent of the node.
294
+ upper_bound (float): Upper bound of the node.
295
+ """
296
+ arrangement = Arrangement(
297
+ items = self.items,
298
+ state = np.array([int(item in included_items) for item in self.items])
299
+ )
300
+ balance = self.capacity - arrangement.weight
301
+ if balance < 0:
302
+ return
303
+
304
+ if self.__is_subset_terminal(included_items):
305
+ self.__bb_minimum_values = sorted(
306
+ set([*self.__bb_minimum_values, arrangement.value])
307
+ )[-1 * (1 + int(solve_second_best)):]
308
+
309
+ node = Node(
310
+ name = {"state": arrangement.state, "value": arrangement.value},
311
+ items = arrangement.items,
312
+ state = arrangement.state,
313
+ value = arrangement.value,
314
+ weight = arrangement.weight,
315
+ upper_bound = upper_bound,
316
+ parent = parent,
317
+ )
318
+
319
+ if len(excluded_items) + len(included_items) < len(self.items):
320
+ next_item = self.items[len(excluded_items) + len(included_items)]
321
+
322
+ upper_bound = self.__calculate_upper_bound(
323
+ included_items = np.append(included_items, next_item),
324
+ excluded_items = excluded_items
325
+ )
326
+ if upper_bound > np.min(self.__bb_minimum_values):
327
+ self.__bb_queue = np.append(
328
+ self.__bb_queue,
329
+ {
330
+ "included_items": np.append(included_items, next_item),
331
+ "excluded_items": excluded_items,
332
+ "parent": node,
333
+ "upper_bound": upper_bound,
334
+ "solve_second_best": solve_second_best,
335
+ }
336
+ )
337
+
338
+ upper_bound = self.__calculate_upper_bound(
339
+ included_items = included_items,
340
+ excluded_items = np.append(excluded_items, next_item)
341
+ )
342
+ if upper_bound > np.min(self.__bb_minimum_values):
343
+ self.__bb_queue = np.append(
344
+ self.__bb_queue,
345
+ {
346
+ "included_items": included_items,
347
+ "excluded_items": np.append(excluded_items, next_item),
348
+ "parent": node,
349
+ "upper_bound": upper_bound,
350
+ "solve_second_best": solve_second_best,
351
+ }
352
+ )
353
+
354
+
355
+ def solve_branch_and_bound(self, solve_second_best: bool):
356
+ """
357
+ Solves the optimal and second-best terminal nodes using best-first branch-and-bound.
358
+ """
359
+ self.items = np.array(sorted(
360
+ self.items,
361
+ key = lambda item: item.value/item.weight,
362
+ reverse = True
363
+ ))
364
+ self.__bb_queue = np.array([])
365
+ self.__bb_minimum_values = np.array([-1])
366
+ initial_arrangement = Arrangement(
367
+ items = self.items,
368
+ state = np.zeros_like(self.items)
369
+ )
370
+ upper_bound = self.__calculate_upper_bound(
371
+ included_items = np.array([]),
372
+ excluded_items = np.array([])
373
+ )
374
+ root = Node(
375
+ name = {"state": initial_arrangement.state, "value": initial_arrangement.value},
376
+ items = self.items,
377
+ state = np.zeros_like(self.items),
378
+ value = 0,
379
+ weight = 0,
380
+ upper_bound = upper_bound,
381
+ parent = None,
382
+ )
383
+
384
+ self.__bb_queue = np.append(
385
+ {
386
+ "included_items": np.array([self.items[0]]),
387
+ "excluded_items": np.array([]),
388
+ "parent": root,
389
+ "upper_bound": self.__calculate_upper_bound(
390
+ included_items = np.array([self.items[0]]),
391
+ excluded_items = np.array([]),
392
+ ),
393
+ "solve_second_best": solve_second_best,
394
+ },
395
+ self.__bb_queue,
396
+ )
397
+ self.__bb_queue = np.append(
398
+ {
399
+ "included_items": np.array([]),
400
+ "excluded_items": np.array([self.items[0]]),
401
+ "parent": root,
402
+ "upper_bound": self.__calculate_upper_bound(
403
+ included_items = np.array([]),
404
+ excluded_items = np.array([self.items[0]]),
405
+ ),
406
+ "solve_second_best": solve_second_best,
407
+ },
408
+ self.__bb_queue,
409
+ )
410
+
411
+ while len(self.__bb_queue) > 0:
412
+ kwargs, self.__bb_queue = self.__bb_queue[0], self.__bb_queue[1:]
413
+ self.__explore_node(**kwargs)
414
+
415
+ self.__bb_queue = np.array(sorted(
416
+ [item for item in self.__bb_queue if item["upper_bound"] > np.min(self.__bb_minimum_values)],
417
+ key = lambda x: x["upper_bound"],
418
+ reverse = True
419
+ ))
420
+
421
+ self.tree = root
422
+ nodes = sorted(
423
+ set([
424
+ (tuple(node.state), node.value)
425
+ for node in PreOrderIter(root)
426
+ if self.__is_subset_terminal(
427
+ [self.items[i] for i, inside in enumerate(node.state) if bool(inside)]
428
+ )
429
+ ]),
430
+ key = lambda x: x[1],
431
+ reverse = True
432
+ )
433
+ self.optimal_nodes = np.array([
434
+ Arrangement(
435
+ items = self.items,
436
+ state = np.array(node[0])
437
+ ) for node
438
+ in nodes
439
+ if node[1] == nodes[0][1]
440
+ ])
441
+ self.sahni_k = self.calculate_sahni_k(self.optimal_nodes[0])
442
+
443
+ if solve_second_best:
444
+ self.terminal_nodes = np.append(
445
+ self.optimal_nodes,
446
+ np.array(Arrangement(
447
+ items = self.items,
448
+ state = np.array(nodes[len(self.optimal_nodes)][0])
449
+ ) )
450
+ )
451
+
452
+
453
+ def solve_terminal_nodes(self):
454
+ """
455
+ Solves the knapsack problem and returns optimal arrangements.
456
+
457
+ Returns:
458
+ np.ndarray: Optimal arrangements for the knapsack problem.
459
+ """
460
+ self.optimal_nodes = np.array([])
461
+ self.terminal_nodes = np.array([])
462
+ for i in range(1, len(self.items) + 1):
463
+ subsets = list(itertools.combinations(self.items, i))
464
+ for subset in subsets:
465
+ if self.__is_subset_terminal(subset):
466
+ self.terminal_nodes = np.append(
467
+ self.terminal_nodes,
468
+ Arrangement(
469
+ items = self.items,
470
+ state = np.array([int(item in subset) for item in self.items])
471
+ )
472
+ )
473
+
474
+ self.terminal_nodes = sorted(
475
+ self.terminal_nodes,
476
+ key = operator.attrgetter("value"),
477
+ reverse = True
478
+ )
479
+ self.optimal_nodes = np.array([
480
+ arrangement for arrangement
481
+ in self.terminal_nodes
482
+ if arrangement.value == self.terminal_nodes[0].value
483
+ ])
484
+ self.sahni_k = self.calculate_sahni_k(self.optimal_nodes[0])
485
+
486
+ return self.optimal_nodes
487
+
488
+
489
+ def solve_feasible_nodes(self) -> np.ndarray:
490
+ """
491
+ Solves the knapsack problem and returns optimal arrangements.
492
+
493
+ Parameters:
494
+ verbose (bool, optional): If True, prints the string representation of the knapsack summary. Default is True.
495
+
496
+ Returns:
497
+ np.ndarray: Optimal arrangements for the knapsack problem.
498
+ """
499
+ self.feasible_nodes = np.array([
500
+ Arrangement(
501
+ items = self.items,
502
+ state = np.zeros_like(self.items)
503
+ )
504
+ ])
505
+ for i in range(1, len(self.items) + 1):
506
+ subsets = list(itertools.combinations(self.items, i))
507
+ for subset in subsets:
508
+ if self.__is_subset_feasible(subset):
509
+ self.feasible_nodes = np.append(
510
+ self.feasible_nodes,
511
+ Arrangement(
512
+ items = self.items,
513
+ state = np.array([int(item in subset) for item in self.items])
514
+ )
515
+ )
516
+
517
+ self.feasible_nodes = sorted(
518
+ self.feasible_nodes,
519
+ key = operator.attrgetter("value"),
520
+ )
521
+ return self.feasible_nodes
522
+
523
+ def solve_all_nodes(self) -> np.ndarray:
524
+ """
525
+ Populates an array with all nodes in the knapsack problem.
526
+ """
527
+ self.feasible_nodes = np.array([
528
+ Arrangement(
529
+ items = self.items,
530
+ state = np.zeros_like(self.items)
531
+ )
532
+ ])
533
+ for i in range(1, len(self.items) + 1):
534
+ subsets = list(itertools.combinations(self.items, i))
535
+ for subset in subsets:
536
+ self.nodes = np.append(
537
+ self.nodes,
538
+ Arrangement(
539
+ items = self.items,
540
+ state = np.array([int(item in subset) for item in self.items])
541
+ )
542
+ )
543
+
544
+ self.feasible_nodes = sorted(
545
+ self.feasible_nodes,
546
+ key = operator.attrgetter("value"),
547
+ )
548
+ return self.feasible_nodes
549
+
550
+
551
+ def __is_subset_feasible(self, subset: list[Item]) -> bool:
552
+ """Private method to determine whether subset of items is feasible (below capacity limit).
553
+
554
+ Args:
555
+ subset (list[Item]): Subset of items.
556
+
557
+ Returns:
558
+ bool: True if the node is terminal, otherwise False.
559
+ """
560
+ weight = sum([i.weight for i in subset])
561
+ balance = self.capacity - weight
562
+ if balance < 0:
563
+ return False
564
+ return True
565
+
566
+
567
+ def __is_subset_terminal(self, subset: list[Item]) -> bool:
568
+ """Private method to determine whether subset of items is a terminal node.
569
+
570
+ Args:
571
+ subset (list[Item]): Subset of items.
572
+
573
+ Returns:
574
+ bool: True if the node is terminal, otherwise False.
575
+ """
576
+ weight = sum([i.weight for i in subset])
577
+ balance = self.capacity - weight
578
+ if balance < 0:
579
+ return False
580
+ remaining_items = set(self.items) - set(subset)
581
+ for i in remaining_items:
582
+ if i.weight <= balance:
583
+ return False
584
+ return True
585
+
586
+
587
+ def calculate_sahni_k(self, arrangement: Arrangement):
588
+ """
589
+ Calculates the Sahni-k value for a given arrangement.
590
+
591
+ Parameters:
592
+ arrangement (Arrangement): The arrangement for which to calculate Sahni-k.
593
+
594
+ Returns:
595
+ int: Sahni-k value.
596
+ """
597
+ if not isinstance(arrangement, Arrangement):
598
+ raise ValueError("`arrangement` must be of type `Arrangement`.")
599
+
600
+ for subset_size in range(0, len(arrangement.state)+1):
601
+ in_indexes = [i for i, element in enumerate(arrangement.state) if element == 1]
602
+ for subset in itertools.combinations(in_indexes, subset_size):
603
+ initial_state = np.array([int(i in subset) for i in range(0, len(arrangement.state))])
604
+ self.set_state(initial_state.copy())
605
+
606
+ # Solve greedily
607
+ while not self.is_at_capacity:
608
+ out_items = [
609
+ self.items[i]
610
+ for i, element
611
+ in enumerate(self.state)
612
+ if element == 0 and self.items[i].weight + self.weight <= self.capacity
613
+ ]
614
+ densities = [item.value/item.weight for item in out_items]
615
+ self.add(
616
+ out_items[densities.index(max(densities))]
617
+ )
618
+
619
+ if np.array_equal(arrangement.state, self.state):
620
+ return subset_size
621
+
622
+
623
+ def plot_terminal_nodes_histogram(self):
624
+ """
625
+ Plots a histogram of values for possible at-capacity arrangements.
626
+ """
627
+ with plt.style.context(["nature"]):
628
+ fig, axes = plt.subplots(
629
+ figsize=(8, 3),
630
+ dpi=300,
631
+ nrows=1,
632
+ ncols=1,
633
+ constrained_layout=True
634
+ )
635
+
636
+ axes.hist(
637
+ [arrangement.value for arrangement in self.terminal_nodes],
638
+ bins=100,
639
+ color="#FF2C00",
640
+ alpha=0.7,
641
+ )
642
+ axes.set_ylabel("Number of solutions")
643
+ axes.set_xlabel("Solution value")
644
+ plt.show()
645
+
646
+
647
+ def write_to_json(self, path: str):
648
+ """
649
+ Writes the knapsack instance to .json.
650
+
651
+ Args:
652
+ path (str): Filepath to output file.
653
+ """
654
+ if self.optimal_nodes.size == 0:
655
+ self.solve(solve_second_best = False)
656
+
657
+ instance_spec = {
658
+ "capacity": self.capacity,
659
+ "sahni_k": self.calculate_sahni_k(self.optimal_nodes[0]),
660
+ "optimal_value": self.optimal_nodes[0].value,
661
+ "items": [
662
+ {
663
+ "id": i,
664
+ "value": item.value,
665
+ "weight": item.weight,
666
+ } for i, item in enumerate(self.items)
667
+ ]
668
+ }
669
+
670
+ with open(path, "w") as f:
671
+ json.dump(instance_spec, f, indent = 4, default = int)
672
+
673
+
674
+ def load_from_json(self, path: str):
675
+ """
676
+ Loads knapsack instance from a .json file.
677
+
678
+ Args:
679
+ path (str): Filepath to instance .json file.
680
+ """
681
+ with open(path) as f:
682
+ spec = json.load(f)
683
+ self.__init__(
684
+ items = np.array([Item(item["value"], item["weight"]) for item in spec["items"]]),
685
+ capacity = int(spec["capacity"])
686
+ )
687
+
688
+
689
+ def summary(self):
690
+ """
691
+ Generates a summary DataFrame containing information about the knapsack state and solutions.
692
+
693
+ Returns:
694
+ pd.DataFrame: Summary DataFrame.
695
+ """
696
+ best_inferior_solution = self.terminal_nodes[len(self.optimal_nodes)]
697
+
698
+ header = ", ".join([
699
+ f"C = {self.capacity}",
700
+ f"nC = {round(self.capacity / np.sum([item.weight for item in self.items]), 2)}",
701
+ f"nTerminal = {len(self.terminal_nodes)}",
702
+ f"nOptimal = {len(self.optimal_nodes)}",
703
+ f"Δ = {self.optimal_nodes[0].value - best_inferior_solution.value}",
704
+ f"Δ% = {(self.optimal_nodes[0].value - best_inferior_solution.value) / self.optimal_nodes[0].value:.3}"
705
+ ])
706
+ columns = pd.MultiIndex.from_arrays([
707
+ [header] * len(self.items),
708
+ [i+1 for i, item in enumerate(self.items)]
709
+ ])
710
+
711
+ rows = [
712
+ [item.value for item in self.items],
713
+ [item.weight for item in self.items],
714
+ [round(item.value / item.weight, 3) for item in self.items],
715
+ ]
716
+ rows.extend([
717
+ np.where(arrangement.state == 1, "IN", "OUT")
718
+ for arrangement in self.optimal_nodes
719
+ ])
720
+ rows.append(np.where(best_inferior_solution.state == 1, "IN", "OUT") )
721
+
722
+ index = ["v", "w", "density"]
723
+ index.extend([
724
+ ", ".join([
725
+ f"solution (v = {arrangement.value}",
726
+ f"w = {arrangement.weight}",
727
+ f"k = {self.calculate_sahni_k(arrangement)})",
728
+ ])
729
+ for arrangement in self.optimal_nodes
730
+ ])
731
+ index.append(", ".join([
732
+ f"best inferior (v = {best_inferior_solution.value}",
733
+ f"w = {best_inferior_solution.weight}",
734
+ f"k = {self.calculate_sahni_k(best_inferior_solution)})",
735
+ ]))
736
+
737
+ return pd.DataFrame(rows, columns=columns, index=index, dtype="object")
738
+
739
+
740
+ def __str__(self):
741
+ return repr(self.summary())
742
+
pykp/sampler.py ADDED
@@ -0,0 +1,81 @@
1
+ import numpy as np
2
+ from .knapsack import Knapsack
3
+ from .item import Item
4
+ from typing import Tuple
5
+
6
+ class Arrangement():
7
+ def __init__(self, value):
8
+ self.value = value
9
+
10
+
11
+ class Sampler():
12
+ def __init__(
13
+ self,
14
+ num_items: int,
15
+ normalised_capacity: float,
16
+ density_range: Tuple[float, float],
17
+ solution_value_range: Tuple[int, int],
18
+ ):
19
+ """
20
+ A class for sampling knapsack instances.
21
+
22
+ Args:
23
+ num_items (int): The number of items to sample.
24
+ normalised_capacity (float): The normalised capacity of the knapsack
25
+ (sum of weights / capacity constraint).
26
+ density_range (Tuple[float, float]): The range of item value densities
27
+ to sample from.
28
+ solution_value_range (Tuple[int, int]): The range of solution values
29
+ for the knapsack.
30
+ """
31
+ self.num_items = num_items
32
+ self.normalised_capacity = normalised_capacity
33
+ self.density_range = density_range
34
+ self.solution_value_range = solution_value_range
35
+
36
+
37
+ def sample(self) -> Knapsack:
38
+ """
39
+ Samples a knapsack instance using the sampling criteria provided to
40
+ the sampler.
41
+
42
+ Returns:
43
+ Knapsack: The sampled knapsack instance.
44
+ """
45
+ weights = np.random.uniform(
46
+ low = 100,
47
+ high = 1000,
48
+ size = self.num_items,
49
+ )
50
+ densities = np.random.uniform(
51
+ low = self.density_range[0],
52
+ high = self.density_range[1],
53
+ size = self.num_items,
54
+ )
55
+ values = weights * densities
56
+ items = np.array([Item(int(values[i]), int(weights[i])) for i in range(self.num_items)])
57
+
58
+ sum_weights = np.sum([item.weight for item in items])
59
+ kp = Knapsack(
60
+ items = items,
61
+ capacity = int(self.normalised_capacity * sum_weights),
62
+ )
63
+ kp.solve()
64
+ solution_value = np.random.uniform(self.solution_value_range[0], self.solution_value_range[1])
65
+ scale_factor = solution_value / kp.optimal_nodes[0].value
66
+ scaled_items = np.array([
67
+ Item(
68
+ np.max([int(item.value * scale_factor), 1]),
69
+ np.max([int(item.weight * scale_factor), 1])
70
+ )
71
+ for item in items
72
+ ])
73
+ scaled_items = np.array(
74
+ sorted(scaled_items, key=lambda item: item.value/item.weight, reverse=True)
75
+ )
76
+ scaled_kp = Knapsack(
77
+ items = scaled_items,
78
+ capacity = int(kp.capacity * scale_factor),
79
+ )
80
+ scaled_kp.solve()
81
+ return scaled_kp
@@ -0,0 +1,108 @@
1
+ Metadata-Version: 2.1
2
+ Name: pykp
3
+ Version: 0.0.22
4
+ Summary: Tooling for sampling and solving instances of the 0-1 Knapsack Problem
5
+ Home-page: https://github.com/HRSAndrabi/pykp
6
+ Author: Hassan Andrabi
7
+ Author-email: hrs.andrabi@gmail.com
8
+ License: MIT
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Programming Language :: Python :: 3.12
11
+ Classifier: Operating System :: OS Independent
12
+ Requires-Python: >=3.12
13
+ Description-Content-Type: text/markdown
14
+ Requires-Dist: anytree >=2.12.1
15
+ Requires-Dist: pandas >=2.2.3
16
+ Requires-Dist: matplotlib ==3.9.2
17
+ Requires-Dist: numpy ==2.1.3
18
+ Provides-Extra: dev
19
+ Requires-Dist: pytest >=7.0 ; extra == 'dev'
20
+ Requires-Dist: twine >=4.0.2 ; extra == 'dev'
21
+
22
+ # PyKP: A Python Package for Knapsack Problem Solving
23
+
24
+ PyKP is a free and open-source library for sampling and solving instances of the knapsack problem. It provides tools to define knapsack instances, solve them efficiently, and analyse computational complexity metrics. You can also use `pykp` to sample knapsack problem instances based on specified distributions.
25
+
26
+ ## Features
27
+
28
+ - Define knapsack problem instances with custom items, weights, and values.
29
+ - Solve knapsack instances using branch-and-bound and other methods.
30
+ - Compute optimal and feasible solutions for different knapsack configurations.
31
+ - Analyse computational complexity metrics.
32
+ - Generate synthetic knapsack instances with custom weight, density, and solution value ranges.
33
+
34
+ ## Installation
35
+
36
+ PyKP support Python version 3.12 and higher. To install PyKP, run
37
+
38
+ ```
39
+ pip install pykp
40
+ ```
41
+
42
+ ## Usage
43
+
44
+ ### Defining and Solving a Knapsack Problem
45
+
46
+ To start, define a knapsack problem with a set of items and solve it using the Knapsack class.
47
+
48
+ ```python
49
+ import numpy as np
50
+ from pykp import Knapsack
51
+ from pykp import Item
52
+
53
+ # Define items for the knapsack
54
+ items = np.array([
55
+ Item(value=10, weight=5),
56
+ Item(value=15, weight=10),
57
+ Item(value=7, weight=3)
58
+ ])
59
+
60
+ # Initialise a Knapsack instance
61
+ capacity = 15
62
+ knapsack = Knapsack(items=items, capacity=capacity)
63
+ knapsack.solve()
64
+
65
+ # Display the optimal solution
66
+ print("Optimal Solution Value:", knapsack.optimal_nodes[0].value)
67
+ ```
68
+
69
+ ### Generating Knapsack Instances with Sampler
70
+
71
+ The `Sampler` class allows you to generate knapsack instances based on specific ranges for item densities (value/weight ratio) and optimal solution values.
72
+
73
+ ```python
74
+ from pykp import Sampler
75
+
76
+ # Initialise a Sampler instance with desired ranges
77
+ sampler = Sampler(
78
+ num_items=5,
79
+ normalised_capacity=0.6,
80
+ density_range=(0.5, 1.5),
81
+ solution_value_range=(100, 200)
82
+ )
83
+
84
+ # Generate a sampled knapsack instance
85
+ sampled_knapsack = sampler.sample()
86
+ print("Sampled Knapsack Capacity:", sampled_knapsack.capacity)
87
+ ```
88
+
89
+ ### Analysing Knapsack Solutions
90
+
91
+ The package provides methods to analyse the optimal solutions and other feasible arrangements.
92
+
93
+ ```python
94
+ # Display a summary of the knapsack solutions
95
+ print(sampled_knapsack.summary())
96
+ ```
97
+
98
+ ## License
99
+
100
+ This project is licensed under the MIT License.
101
+
102
+ ## Contributing
103
+
104
+ Contributions are welcome. Please fork the repository and submit a pull request.
105
+
106
+ ## Contact
107
+
108
+ For questions or feedback, please reach out at hrs.andrabi@gmail.com.
@@ -0,0 +1,9 @@
1
+ pykp/__init__.py,sha256=QdffeMuDYTYwxcnVxj2YV3kVLNqotRFP21YTBRPftcM,119
2
+ pykp/arrangement.py,sha256=q_ZYKQ1u9FVTsJP9L76jtmNsoZDCMWZJadrZqaKL-P0,1999
3
+ pykp/item.py,sha256=5ZAHWFvQJjAwqwKevzstAhDWV89b3v07_lS2FFTtOgU,843
4
+ pykp/knapsack.py,sha256=FpuTEsIGZ_ncEVblCsxU9ydL8ZIeAdyxum2QLN62-8M,21334
5
+ pykp/sampler.py,sha256=-hp-lrV6Dl0sPEC2B5GOYlIQnUMnO9sPkLrqTkpbHdo,2202
6
+ pykp-0.0.22.dist-info/METADATA,sha256=X9Ax38eC5LKLgwYWtD5Poxn1H-L8kF_cPI-nlx5II_E,3183
7
+ pykp-0.0.22.dist-info/WHEEL,sha256=P9jw-gEje8ByB7_hXoICnHtVCrEwMQh-630tKvQWehc,91
8
+ pykp-0.0.22.dist-info/top_level.txt,sha256=kiQepwiX9Bp7ZxL_N3rQiI5mUV4d0kMfsSlyuimctnE,5
9
+ pykp-0.0.22.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (75.3.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ pykp