@File : init.py @Time : 2023/10/30 12:35:54 @Author : Alejandro Marrero @Version : 1.0 @Contact : amarrerd@ull.edu.es @License : (C)Copyright 2023, Alejandro Marrero @Desc : None

BPP

Bases: Problem

Bin Packing Problem

Source code in digneapy/domains/bpp.py
 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
class BPP(Problem):
    """Bin Packing Problem"""

    def __init__(
        self,
        items: Iterable[int],
        maximum_capacity: np.uint32 | int,
        seed: Optional[int | np.random.SeedSequence] = None,
        *args,
        **kwargs,
    ):
        """Creates a new Bin Packing Problem (BPP) object

        Args:
            items (Iterable[int]): Items to store. It must be any iterable with
                integer values where each value is the weight of an item.
            capacity (np.uint32 | int): Maximum capacity of each bin in the problem.
            seed (Optional[int  |  np.random.SeedSequence], optional): Seed for random number engine. Defaults to None.

        Raises:
            ValueError: If the capacity is not an integer or it's negative.
            ValueError: If any item has a zero or negative weight.
        """

        try:
            self._maximum_capacity = int(maximum_capacity)
            if self._maximum_capacity <= 0:
                raise ValueError("maximum_capacity must be a positive integer in BPP.")

        except (TypeError, ValueError) as exc:
            raise ValueError(
                "Invalid maximum_capacity value for a BPP object."
            ) from exc

        try:
            self._items = tuple(map(int, items))
            if len(self._items) == 0:
                raise ValueError(
                    "Invalid items for a BPP object. "
                    f"Expected an iterable with a least one item. Got: {items}"
                )
            if any(item < 0 for item in self._items):
                raise ValueError(
                    "Invalid items for a BPP object. "
                    "Expected all items to be positive integers. "
                    f"Got: {items}"
                )
        except Exception:
            raise

        dimension = len(self._items)
        bounds = [(0, dimension - 1)] * dimension
        super().__init__(dimension=dimension, bounds=bounds, name="BPP", seed=seed)

    @property
    def maximum_capacity(self) -> int:
        return self._maximum_capacity

    @property
    def items(self) -> Tuple[int, ...]:
        return self._items

    def evaluate(self, individual: Sequence | Solution | np.ndarray) -> Tuple[float]:
        """Evaluates the candidate individual with the information of the Bin Packing.

        The fitness of the solution is the amount of unused space, as well as the
        number of bins for a specific solution. Falkenauer (1998) performance metric
        defined as:
            (x) = \\frac{\\sum_{k=1}^{N} \\left(\\frac{fill_k}{C}\\right)^2}{N}

        Args:
            individual (Sequence | Solution): Individual to evaluate

        Returns:
            Tuple[float]: Falkenauer Fitness
        """
        if len(individual) != self._dimension:
            raise ValueError(
                f"Mismatch between individual ({len(individual)}) "
                f"and problem dimension ({self._dimension}) in BPP."
            )

        used_bins = np.max(individual).astype(np.int32) + 1
        filled_bins = np.zeros(used_bins)

        # For each bin in the solution
        # we set is weight as the sum of the items they store
        # The individual is encoded as follows
        # Each index, refers to the ith item in the instance
        # The value of individual[i] refers to the bin where
        # such item is store
        for item_index, bin in enumerate(individual):
            filled_bins[bin] += self._items[item_index]

        ratio = filled_bins / self._maximum_capacity
        fitness = np.sum(ratio * ratio) / used_bins

        try:
            # We asume that individual is a Solution object
            # Therefore, it must have a fitness and objective attributes
            # Otherwise, we got sequence/ndarray and we just return the fitness
            individual.fitness = fitness
            individual.objectives = (fitness,)
        except Exception:
            return (fitness,)

        return (fitness,)

    def __call__(self, individual: Sequence | Solution | np.ndarray) -> Tuple[float]:
        """Evaluates the candidate individual with the information of the Bin Packing.

        The fitness of the solution is the amount of unused space, as well as the
        number of bins for a specific solution. Falkenauer (1998) performance metric
        defined as:
            (x) = \\frac{\\sum_{k=1}^{N} \\left(\\frac{fill_k}{C}\\right)^2}{N}

        Args:
            individual (Sequence | Solution): Individual to evaluate

        Returns:
            Tuple[float]: Falkenauer Fitness
        """
        return self.evaluate(individual)

    def __str__(self):
        return f"BPP(n={self._dimension},C={self._maximum_capacity},I={self._items})"

    def __len__(self):
        return self._dimension

    def __array__(self, dtype=np.int32, copy: Optional[bool] = None) -> np.ndarray:
        """Return a NumPy array representation of the Bin Packing Problem.

        The representation stores the capacity first and then all the items.

        Returns:
            np.ndarray: A one-dimensional array describing the instance.
        """
        return np.asarray(
            [self._maximum_capacity, *self._items], dtype=dtype, copy=copy
        )

    def create_solution(self) -> Solution:
        """Creates a random BPP solution

        The solution is created with variables equal to [0, dimension].
        Which means that each item is stored in a independent bin.
        Also, the number of objectives is set to 1.

        Returns:
            Solution: Initial valid solution.
        """
        items = np.arange(self._dimension)
        return Solution(
            variables=items,
            objectives=np.zeros(1),
        )

    def to_file(self, filename: str | Path = "instance.bpp"):
        """Saves the BPP problem to a file

        Args:
            filename (str | Path, optional): Name of the filename to store the problem.
                It can be either a string or a Path. Defaults to "instance.bpp".

        Raises:
            RuntimeError: If something goes wrong when saving the problem.
        """
        try:
            with open(filename, "w") as file:
                file.write(f"{len(self)}\t{self._maximum_capacity}\n\n")
                content = "\n".join(str(i) for i in self._items)
                file.write(content)

        except Exception as exc:
            raise RuntimeError("Failed to save BPP problem to file.") from exc

    @classmethod
    def from_file(cls, filename: str | Path) -> Self:
        """Loads a BPP problem from a file

        Args:
            filename (str | Path):  Name of the filename to load the problem from.
                It can be either a string or a Path.

        Raises:
            RuntimeError: If something goes wrong when loading the problem.

        Returns:
            BPP: Returns a new BPP object with the content of the file
        """
        try:
            with open(filename) as f:
                lines = f.readlines()
                lines = [line.rstrip() for line in lines]

            (_, capacity) = lines[0].split()
            items = list(int(i) for i in lines[2:])

            return cls(items=items, maximum_capacity=int(capacity))
        except Exception as exc:
            raise RuntimeError(
                f"Failed to load BPP problem from file {filename}"
            ) from exc

    def to_instance(self) -> Instance:
        """Generates an Instance with the information of the Problem

        Returns:
            Instance: New Instance object that defines this BPP
        """
        _variables = [self._maximum_capacity, *self._items]
        return Instance(variables=_variables)

__array__(dtype=np.int32, copy=None)

Return a NumPy array representation of the Bin Packing Problem.

The representation stores the capacity first and then all the items.

Returns:
  • ndarray

    np.ndarray: A one-dimensional array describing the instance.

Source code in digneapy/domains/bpp.py
156
157
158
159
160
161
162
163
164
165
166
def __array__(self, dtype=np.int32, copy: Optional[bool] = None) -> np.ndarray:
    """Return a NumPy array representation of the Bin Packing Problem.

    The representation stores the capacity first and then all the items.

    Returns:
        np.ndarray: A one-dimensional array describing the instance.
    """
    return np.asarray(
        [self._maximum_capacity, *self._items], dtype=dtype, copy=copy
    )

__call__(individual)

Evaluates the candidate individual with the information of the Bin Packing.

The fitness of the solution is the amount of unused space, as well as the number of bins for a specific solution. Falkenauer (1998) performance metric defined as: (x) = \frac{\sum_{k=1}^{N} \left(\frac{fill_k}{C}\right)^2}{N}

Parameters:
  • individual (Sequence | Solution) –

    Individual to evaluate

Returns:
  • Tuple[float]

    Tuple[float]: Falkenauer Fitness

Source code in digneapy/domains/bpp.py
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
def __call__(self, individual: Sequence | Solution | np.ndarray) -> Tuple[float]:
    """Evaluates the candidate individual with the information of the Bin Packing.

    The fitness of the solution is the amount of unused space, as well as the
    number of bins for a specific solution. Falkenauer (1998) performance metric
    defined as:
        (x) = \\frac{\\sum_{k=1}^{N} \\left(\\frac{fill_k}{C}\\right)^2}{N}

    Args:
        individual (Sequence | Solution): Individual to evaluate

    Returns:
        Tuple[float]: Falkenauer Fitness
    """
    return self.evaluate(individual)

__init__(items, maximum_capacity, seed=None, *args, **kwargs)

Creates a new Bin Packing Problem (BPP) object

Parameters:
  • items (Iterable[int]) –

    Items to store. It must be any iterable with integer values where each value is the weight of an item.

  • capacity (uint32 | int) –

    Maximum capacity of each bin in the problem.

  • seed (Optional[int | SeedSequence], default: None ) –

    Seed for random number engine. Defaults to None.

Raises:
  • ValueError

    If the capacity is not an integer or it's negative.

  • ValueError

    If any item has a zero or negative weight.

Source code in digneapy/domains/bpp.py
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
def __init__(
    self,
    items: Iterable[int],
    maximum_capacity: np.uint32 | int,
    seed: Optional[int | np.random.SeedSequence] = None,
    *args,
    **kwargs,
):
    """Creates a new Bin Packing Problem (BPP) object

    Args:
        items (Iterable[int]): Items to store. It must be any iterable with
            integer values where each value is the weight of an item.
        capacity (np.uint32 | int): Maximum capacity of each bin in the problem.
        seed (Optional[int  |  np.random.SeedSequence], optional): Seed for random number engine. Defaults to None.

    Raises:
        ValueError: If the capacity is not an integer or it's negative.
        ValueError: If any item has a zero or negative weight.
    """

    try:
        self._maximum_capacity = int(maximum_capacity)
        if self._maximum_capacity <= 0:
            raise ValueError("maximum_capacity must be a positive integer in BPP.")

    except (TypeError, ValueError) as exc:
        raise ValueError(
            "Invalid maximum_capacity value for a BPP object."
        ) from exc

    try:
        self._items = tuple(map(int, items))
        if len(self._items) == 0:
            raise ValueError(
                "Invalid items for a BPP object. "
                f"Expected an iterable with a least one item. Got: {items}"
            )
        if any(item < 0 for item in self._items):
            raise ValueError(
                "Invalid items for a BPP object. "
                "Expected all items to be positive integers. "
                f"Got: {items}"
            )
    except Exception:
        raise

    dimension = len(self._items)
    bounds = [(0, dimension - 1)] * dimension
    super().__init__(dimension=dimension, bounds=bounds, name="BPP", seed=seed)

create_solution()

Creates a random BPP solution

The solution is created with variables equal to [0, dimension]. Which means that each item is stored in a independent bin. Also, the number of objectives is set to 1.

Returns:
  • Solution( Solution ) –

    Initial valid solution.

Source code in digneapy/domains/bpp.py
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
def create_solution(self) -> Solution:
    """Creates a random BPP solution

    The solution is created with variables equal to [0, dimension].
    Which means that each item is stored in a independent bin.
    Also, the number of objectives is set to 1.

    Returns:
        Solution: Initial valid solution.
    """
    items = np.arange(self._dimension)
    return Solution(
        variables=items,
        objectives=np.zeros(1),
    )

evaluate(individual)

Evaluates the candidate individual with the information of the Bin Packing.

The fitness of the solution is the amount of unused space, as well as the number of bins for a specific solution. Falkenauer (1998) performance metric defined as: (x) = \frac{\sum_{k=1}^{N} \left(\frac{fill_k}{C}\right)^2}{N}

Parameters:
  • individual (Sequence | Solution) –

    Individual to evaluate

Returns:
  • Tuple[float]

    Tuple[float]: Falkenauer Fitness

Source code in digneapy/domains/bpp.py
 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
def evaluate(self, individual: Sequence | Solution | np.ndarray) -> Tuple[float]:
    """Evaluates the candidate individual with the information of the Bin Packing.

    The fitness of the solution is the amount of unused space, as well as the
    number of bins for a specific solution. Falkenauer (1998) performance metric
    defined as:
        (x) = \\frac{\\sum_{k=1}^{N} \\left(\\frac{fill_k}{C}\\right)^2}{N}

    Args:
        individual (Sequence | Solution): Individual to evaluate

    Returns:
        Tuple[float]: Falkenauer Fitness
    """
    if len(individual) != self._dimension:
        raise ValueError(
            f"Mismatch between individual ({len(individual)}) "
            f"and problem dimension ({self._dimension}) in BPP."
        )

    used_bins = np.max(individual).astype(np.int32) + 1
    filled_bins = np.zeros(used_bins)

    # For each bin in the solution
    # we set is weight as the sum of the items they store
    # The individual is encoded as follows
    # Each index, refers to the ith item in the instance
    # The value of individual[i] refers to the bin where
    # such item is store
    for item_index, bin in enumerate(individual):
        filled_bins[bin] += self._items[item_index]

    ratio = filled_bins / self._maximum_capacity
    fitness = np.sum(ratio * ratio) / used_bins

    try:
        # We asume that individual is a Solution object
        # Therefore, it must have a fitness and objective attributes
        # Otherwise, we got sequence/ndarray and we just return the fitness
        individual.fitness = fitness
        individual.objectives = (fitness,)
    except Exception:
        return (fitness,)

    return (fitness,)

from_file(filename) classmethod

Loads a BPP problem from a file

Parameters:
  • filename (str | Path) –

    Name of the filename to load the problem from. It can be either a string or a Path.

Raises:
  • RuntimeError

    If something goes wrong when loading the problem.

Returns:
  • BPP( Self ) –

    Returns a new BPP object with the content of the file

Source code in digneapy/domains/bpp.py
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
@classmethod
def from_file(cls, filename: str | Path) -> Self:
    """Loads a BPP problem from a file

    Args:
        filename (str | Path):  Name of the filename to load the problem from.
            It can be either a string or a Path.

    Raises:
        RuntimeError: If something goes wrong when loading the problem.

    Returns:
        BPP: Returns a new BPP object with the content of the file
    """
    try:
        with open(filename) as f:
            lines = f.readlines()
            lines = [line.rstrip() for line in lines]

        (_, capacity) = lines[0].split()
        items = list(int(i) for i in lines[2:])

        return cls(items=items, maximum_capacity=int(capacity))
    except Exception as exc:
        raise RuntimeError(
            f"Failed to load BPP problem from file {filename}"
        ) from exc

to_file(filename='instance.bpp')

Saves the BPP problem to a file

Parameters:
  • filename (str | Path, default: 'instance.bpp' ) –

    Name of the filename to store the problem. It can be either a string or a Path. Defaults to "instance.bpp".

Raises:
  • RuntimeError

    If something goes wrong when saving the problem.

Source code in digneapy/domains/bpp.py
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
def to_file(self, filename: str | Path = "instance.bpp"):
    """Saves the BPP problem to a file

    Args:
        filename (str | Path, optional): Name of the filename to store the problem.
            It can be either a string or a Path. Defaults to "instance.bpp".

    Raises:
        RuntimeError: If something goes wrong when saving the problem.
    """
    try:
        with open(filename, "w") as file:
            file.write(f"{len(self)}\t{self._maximum_capacity}\n\n")
            content = "\n".join(str(i) for i in self._items)
            file.write(content)

    except Exception as exc:
        raise RuntimeError("Failed to save BPP problem to file.") from exc

to_instance()

Generates an Instance with the information of the Problem

Returns:
  • Instance( Instance ) –

    New Instance object that defines this BPP

Source code in digneapy/domains/bpp.py
231
232
233
234
235
236
237
238
def to_instance(self) -> Instance:
    """Generates an Instance with the information of the Problem

    Returns:
        Instance: New Instance object that defines this BPP
    """
    _variables = [self._maximum_capacity, *self._items]
    return Instance(variables=_variables)

BPPDomain

Bases: Domain

Source code in digneapy/domains/bpp.py
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
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
class BPPDomain(Domain):
    capacity_approaches = Literal["evolved", "percentage", "fixed"]

    def __init__(
        self,
        number_of_items: np.uint32 | int = 50,
        minimum_weight: np.uint32 = np.uint32(1),
        maximum_weight: np.uint32 = np.uint32(1_000),
        maximum_capacity: np.uint32 = np.uint32(100),
        capacity_approach: capacity_approaches = "fixed",
        capacity_ratio: float = 0.8,
        seed: Optional[int | np.random.SeedSequence] = None,
    ):
        """Bin Packing Problem Domain

        Creates a new domain to generate instances for the Bin Packing Problem (BPP).

        Args:
            number_of_items (np.uint32 | int, optional): Number of items that the instance must contain. Defaults to 50.
            minimum_weight (np.uint32 | int, optional): Minimum value of each item. ç
                This is the lowest weight that an item can have. Defaults to np.uint32(1).
            maximum_weight (np.uint32, optional): Maximum value of each item. This is the highest
                weight that an item can have. Defaults to np.uint32(1_000).
            maximum_capacity (np.uint32, optional): Maximum capacity of each bin in the instance.
                Defaults to np.uint32(100).
            capacity_approach (capacity_approaches, optional): Literal to define how the capacities
                of the instances will be computed. If fixed, the capacity is defined as the
                maximum_capacity value. If evolved, the capacity can be updated during the evolution,
                and finally if `percentage` the capacity is defined as capacity_ratio * capacity
                during the evolution. Defaults to "fixed".
            capacity_ratio (float, optional): Capacity ratio used when the capacity_approach is set
                to percentage. It must be a float value in the range (0.0, 1.0]. Defaults to 0.8.
            seed (Optional[int | np.random.SeedSequence], optional): Seed for the random number engine. Defaults to None.

        Raises:
            ValueError: If the minimum_weight or maximum_weight are negative
            ValueError: If the minimum_weight > maximum_weight
            ValueError: If the maximum_capacity is not a valid integer, or it's <= 0
            ValueError: If the capacity_approach is not available
            ValueError: If the capacity_ratio is not a float or it's outside the range (0.0, 1.0]
        """
        try:
            self.number_of_items = int(number_of_items)
            if self.number_of_items <= 0:
                raise ValueError(
                    "number_of_items must be a "
                    "postive integer in BPPDomain. "
                    f"Got: {number_of_items}"
                )
        except (TypeError, ValueError) as exc:
            raise ValueError from exc

        try:
            self._minimum_weight = int(minimum_weight)
            self._maximum_weight = int(maximum_weight)
            # If we have negative bounds or min > max raise ValueError
            if (
                minimum_weight < 0
                or maximum_weight < 0
                or minimum_weight > maximum_weight
            ):
                raise ValueError()

        except (TypeError, ValueError) as exc:
            raise ValueError(
                "Invalid min_i and/or max_i in BPPDomain."
                f"Expected min_i ({minimum_weight}) to be >= 0 and < max_i ({maximum_weight}) "
                f"and max_i to be >= 0."
            ) from exc

        try:
            self._max_capacity = int(maximum_capacity)
            self._capacity_ratio = float(capacity_ratio)
            if maximum_capacity <= 0:
                raise ValueError("invalid max_capacity value")
            if self._capacity_ratio <= 0 or self._capacity_ratio > 1:
                raise ValueError("invalid capacity_ratio value")

        except (TypeError, ValueError) as exc:
            raise ValueError(
                "Invalid maximum capacity and/or capacity ratio for BPPDomain. "
                f"Capacity ({maximum_capacity}) was expected to be a positive integer, "
                f"and capacity_ratio ({capacity_ratio}) must be a float in the range (0.0, 1.0]."
            ) from exc

        if capacity_approach not in self.capacity_approaches.__args__:
            invalid_approach_msg = (
                f"The capacity approach {capacity_approach} is not available. "
                f" Please, consider choosing between {self.capacity_approaches.__args__}. "
                f" Set `evolved` approach set as fallback."
            )
            warnings.warn(invalid_approach_msg, RuntimeWarning)
            self._capacity_approach = "fixed"
        else:
            self._capacity_approach = capacity_approach

        bounds = [(1.0, self._max_capacity)] + [
            (self._minimum_weight, self._maximum_weight) for _ in range(number_of_items)
        ]
        features_names = "mean,std,median,max,min,tiny,small,medium,large,huge".split(
            ","
        )

        super().__init__(
            dimension=self.number_of_items + 1,
            bounds=bounds,
            domain_name="BPP",
            features_names=features_names,
            seed=seed,
        )

    @property
    def capacity_approach(self):
        return self._capacity_approach

    @property
    def capacity_ratio(self):
        if self._capacity_approach == "percentage":
            return self._capacity_ratio
        else:
            return 1.0

    @property
    def maximum_capacity(self) -> int:
        return self._max_capacity

    @property
    def minimum_weight(self) -> int:
        return self._minimum_weight

    @property
    def maximum_weight(self) -> int:
        return self._maximum_weight

    def generate_instances(self, n: np.uint32 = np.uint32(1)) -> Sequence[Instance]:
        """Generates N new instances for the BPP domain.

        Args:
            n (int, optional): Number of instances to generate. Defaults to 1.

        Returns:
            List[Instance]: A list of Instance objects created from the raw numpy generation
        """
        # Dimension is set correctly to number_of_items + 1 to
        # allow the random generation of capacities
        instances = self._rng.integers(
            low=self._minimum_weight,
            high=self._maximum_weight,
            size=(n, self._dimension),
            dtype=int,
        )
        # Sets the capacity according to the method
        match self.capacity_approach:
            case "evolved":
                instances[:, 0] = self._rng.integers(1, self._max_capacity, size=n)
            case "percentage":
                instances[:, 0] = (
                    np.sum(instances[:, 1:], axis=1, dtype=np.int32)
                    * self._capacity_ratio
                )
            case "fixed":
                instances[:, 0] = self._max_capacity

        return list(Instance(variables=i) for i in instances)

    def extract_features(
        self, instances: Sequence[Instance] | np.ndarray
    ) -> np.ndarray:
        """Extract the features of the instance based on the BPP domain.

        For the BPP domain, the features consist of:
           - N as the number of items,
           - Capacity as the maximum capacity of each bin, MeanWeights of the items,
           - MedianWeights of the items, VarianceWeights of the weights of the items,
           - MaxWeight of the items in the instance, MinWeight of the items,
           - Huge as the ratio of items with normalised weights > 0.5,
           - Large as the ratio of items with normalised weights between 0.333 and 0.5,
           - Medium as the ratio of items with normalised weights between 0.25 and 0.333,
           - Small as the ratio of items with normalised weights >= 0.25,
           - Tiny as the ratio of items with normalised weights >= 0.1

        Args:
            instances (Instance): Instances to extract the features from

        Returns:
            np.ndarray: Values of each feature
        """
        if not isinstance(instances, np.ndarray):
            instances = np.asarray(instances)

        norm_variables = np.asarray(instances, copy=True, dtype=np.float64)
        norm_variables[:, 1:] = norm_variables[:, 1:] / norm_variables[:, 0:1]
        huge = 0.5
        medium = 0.33333
        large = 0.25
        tiny = 0.1
        return np.column_stack(
            [
                np.mean(norm_variables, axis=1),
                np.std(norm_variables, axis=1),
                np.median(norm_variables, axis=1),
                np.max(norm_variables, axis=1),
                np.min(norm_variables, axis=1),
                np.mean(norm_variables > huge, axis=1),  # Huge
                np.mean((huge >= norm_variables) & (norm_variables > medium), axis=1),
                np.mean((medium >= norm_variables) & (norm_variables > large), axis=1),
                np.mean(large >= norm_variables, axis=1),  # Small
                np.mean(tiny >= norm_variables, axis=1),  # Tiny
            ],
        ).astype(np.float64)

    def extract_features_as_dict(
        self, instances: Sequence[Instance] | np.ndarray
    ) -> List[Dict[str, np.float64]]:
        """Creates a dictionary with the features of the instances.

        The key are the names of each feature and the values are
        the values extracted from instance.
        For the BPP domain, the features consist of:
           - N as the number of items,
           - Capacity as the maximum capacity of each bin, MeanWeights of the items,
           - MedianWeights of the items, VarianceWeights of the weights of the items,
           - MaxWeight of the items in the instance, MinWeight of the items,
           - Huge as the ratio of items with normalised weights > 0.5,
           - Large as the ratio of items with normalised weights between 0.333 and 0.5,
           - Medium as the ratio of items with normalised weights between 0.25 and 0.333,
           - Small as the ratio of items with normalised weights >= 0.25,
           - Tiny as the ratio of items with normalised weights >= 0.1

        Args:
            instances (Sequence[Instance]): Instances to extract the features from.

        Returns:
            Dict[str, np.float64]: Dictionary with the names/values of each feature
        """
        features = self.extract_features(instances)
        named_features = []
        for instance_features in features:
            named_features.append({
                k: v for k, v in zip(self.features_names, instance_features)
            })

        return named_features

    def generate_problems_from_instances(
        self, instances: Sequence[Instance] | np.ndarray
    ) -> List[BPP]:
        """Generates BPP problems from the given instances


        This method is used to generate a collection of (objects)
        of the BPP class ready to be solved from the definition of the instances.

        Args:
            instances (Sequence[Instance] | np.ndarray): Instances to generate
                the problems from.

        Returns:
            List[BPP]: List of BPP objects created from the instances
        """
        if not isinstance(instances, np.ndarray):
            instances = np.asarray(instances)

        # Assume evolved capacities
        capacities = instances[:, 0].astype(np.int32)
        match self.capacity_approach:
            case "percentage":
                capacities[:] = (
                    np.sum(instances[:, 1:], axis=1) * self._capacity_ratio
                ).astype(np.int32)
                instances[:, 0] = capacities[:]
            case "fixed":
                capacities[:] = self._max_capacity
                instances[:, 0] = self._max_capacity
        # The first item of each valid BPP instance is the capacity
        print(capacities)
        return list(
            BPP(items=instances[i, 1:], maximum_capacity=capacities[i])
            for i in range(len(instances))
        )

__init__(number_of_items=50, minimum_weight=np.uint32(1), maximum_weight=np.uint32(1000), maximum_capacity=np.uint32(100), capacity_approach='fixed', capacity_ratio=0.8, seed=None)

Bin Packing Problem Domain

Creates a new domain to generate instances for the Bin Packing Problem (BPP).

Parameters:
  • number_of_items (uint32 | int, default: 50 ) –

    Number of items that the instance must contain. Defaults to 50.

  • minimum_weight (uint32 | int, default: uint32(1) ) –

    Minimum value of each item. ç This is the lowest weight that an item can have. Defaults to np.uint32(1).

  • maximum_weight (uint32, default: uint32(1000) ) –

    Maximum value of each item. This is the highest weight that an item can have. Defaults to np.uint32(1_000).

  • maximum_capacity (uint32, default: uint32(100) ) –

    Maximum capacity of each bin in the instance. Defaults to np.uint32(100).

  • capacity_approach (capacity_approaches, default: 'fixed' ) –

    Literal to define how the capacities of the instances will be computed. If fixed, the capacity is defined as the maximum_capacity value. If evolved, the capacity can be updated during the evolution, and finally if percentage the capacity is defined as capacity_ratio * capacity during the evolution. Defaults to "fixed".

  • capacity_ratio (float, default: 0.8 ) –

    Capacity ratio used when the capacity_approach is set to percentage. It must be a float value in the range (0.0, 1.0]. Defaults to 0.8.

  • seed (Optional[int | SeedSequence], default: None ) –

    Seed for the random number engine. Defaults to None.

Raises:
  • ValueError

    If the minimum_weight or maximum_weight are negative

  • ValueError

    If the minimum_weight > maximum_weight

  • ValueError

    If the maximum_capacity is not a valid integer, or it's <= 0

  • ValueError

    If the capacity_approach is not available

  • ValueError

    If the capacity_ratio is not a float or it's outside the range (0.0, 1.0]

Source code in digneapy/domains/bpp.py
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
def __init__(
    self,
    number_of_items: np.uint32 | int = 50,
    minimum_weight: np.uint32 = np.uint32(1),
    maximum_weight: np.uint32 = np.uint32(1_000),
    maximum_capacity: np.uint32 = np.uint32(100),
    capacity_approach: capacity_approaches = "fixed",
    capacity_ratio: float = 0.8,
    seed: Optional[int | np.random.SeedSequence] = None,
):
    """Bin Packing Problem Domain

    Creates a new domain to generate instances for the Bin Packing Problem (BPP).

    Args:
        number_of_items (np.uint32 | int, optional): Number of items that the instance must contain. Defaults to 50.
        minimum_weight (np.uint32 | int, optional): Minimum value of each item. ç
            This is the lowest weight that an item can have. Defaults to np.uint32(1).
        maximum_weight (np.uint32, optional): Maximum value of each item. This is the highest
            weight that an item can have. Defaults to np.uint32(1_000).
        maximum_capacity (np.uint32, optional): Maximum capacity of each bin in the instance.
            Defaults to np.uint32(100).
        capacity_approach (capacity_approaches, optional): Literal to define how the capacities
            of the instances will be computed. If fixed, the capacity is defined as the
            maximum_capacity value. If evolved, the capacity can be updated during the evolution,
            and finally if `percentage` the capacity is defined as capacity_ratio * capacity
            during the evolution. Defaults to "fixed".
        capacity_ratio (float, optional): Capacity ratio used when the capacity_approach is set
            to percentage. It must be a float value in the range (0.0, 1.0]. Defaults to 0.8.
        seed (Optional[int | np.random.SeedSequence], optional): Seed for the random number engine. Defaults to None.

    Raises:
        ValueError: If the minimum_weight or maximum_weight are negative
        ValueError: If the minimum_weight > maximum_weight
        ValueError: If the maximum_capacity is not a valid integer, or it's <= 0
        ValueError: If the capacity_approach is not available
        ValueError: If the capacity_ratio is not a float or it's outside the range (0.0, 1.0]
    """
    try:
        self.number_of_items = int(number_of_items)
        if self.number_of_items <= 0:
            raise ValueError(
                "number_of_items must be a "
                "postive integer in BPPDomain. "
                f"Got: {number_of_items}"
            )
    except (TypeError, ValueError) as exc:
        raise ValueError from exc

    try:
        self._minimum_weight = int(minimum_weight)
        self._maximum_weight = int(maximum_weight)
        # If we have negative bounds or min > max raise ValueError
        if (
            minimum_weight < 0
            or maximum_weight < 0
            or minimum_weight > maximum_weight
        ):
            raise ValueError()

    except (TypeError, ValueError) as exc:
        raise ValueError(
            "Invalid min_i and/or max_i in BPPDomain."
            f"Expected min_i ({minimum_weight}) to be >= 0 and < max_i ({maximum_weight}) "
            f"and max_i to be >= 0."
        ) from exc

    try:
        self._max_capacity = int(maximum_capacity)
        self._capacity_ratio = float(capacity_ratio)
        if maximum_capacity <= 0:
            raise ValueError("invalid max_capacity value")
        if self._capacity_ratio <= 0 or self._capacity_ratio > 1:
            raise ValueError("invalid capacity_ratio value")

    except (TypeError, ValueError) as exc:
        raise ValueError(
            "Invalid maximum capacity and/or capacity ratio for BPPDomain. "
            f"Capacity ({maximum_capacity}) was expected to be a positive integer, "
            f"and capacity_ratio ({capacity_ratio}) must be a float in the range (0.0, 1.0]."
        ) from exc

    if capacity_approach not in self.capacity_approaches.__args__:
        invalid_approach_msg = (
            f"The capacity approach {capacity_approach} is not available. "
            f" Please, consider choosing between {self.capacity_approaches.__args__}. "
            f" Set `evolved` approach set as fallback."
        )
        warnings.warn(invalid_approach_msg, RuntimeWarning)
        self._capacity_approach = "fixed"
    else:
        self._capacity_approach = capacity_approach

    bounds = [(1.0, self._max_capacity)] + [
        (self._minimum_weight, self._maximum_weight) for _ in range(number_of_items)
    ]
    features_names = "mean,std,median,max,min,tiny,small,medium,large,huge".split(
        ","
    )

    super().__init__(
        dimension=self.number_of_items + 1,
        bounds=bounds,
        domain_name="BPP",
        features_names=features_names,
        seed=seed,
    )

extract_features(instances)

Extract the features of the instance based on the BPP domain.

For the BPP domain, the features consist of: - N as the number of items, - Capacity as the maximum capacity of each bin, MeanWeights of the items, - MedianWeights of the items, VarianceWeights of the weights of the items, - MaxWeight of the items in the instance, MinWeight of the items, - Huge as the ratio of items with normalised weights > 0.5, - Large as the ratio of items with normalised weights between 0.333 and 0.5, - Medium as the ratio of items with normalised weights between 0.25 and 0.333, - Small as the ratio of items with normalised weights >= 0.25, - Tiny as the ratio of items with normalised weights >= 0.1

Parameters:
  • instances (Instance) –

    Instances to extract the features from

Returns:
  • ndarray

    np.ndarray: Values of each feature

Source code in digneapy/domains/bpp.py
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
def extract_features(
    self, instances: Sequence[Instance] | np.ndarray
) -> np.ndarray:
    """Extract the features of the instance based on the BPP domain.

    For the BPP domain, the features consist of:
       - N as the number of items,
       - Capacity as the maximum capacity of each bin, MeanWeights of the items,
       - MedianWeights of the items, VarianceWeights of the weights of the items,
       - MaxWeight of the items in the instance, MinWeight of the items,
       - Huge as the ratio of items with normalised weights > 0.5,
       - Large as the ratio of items with normalised weights between 0.333 and 0.5,
       - Medium as the ratio of items with normalised weights between 0.25 and 0.333,
       - Small as the ratio of items with normalised weights >= 0.25,
       - Tiny as the ratio of items with normalised weights >= 0.1

    Args:
        instances (Instance): Instances to extract the features from

    Returns:
        np.ndarray: Values of each feature
    """
    if not isinstance(instances, np.ndarray):
        instances = np.asarray(instances)

    norm_variables = np.asarray(instances, copy=True, dtype=np.float64)
    norm_variables[:, 1:] = norm_variables[:, 1:] / norm_variables[:, 0:1]
    huge = 0.5
    medium = 0.33333
    large = 0.25
    tiny = 0.1
    return np.column_stack(
        [
            np.mean(norm_variables, axis=1),
            np.std(norm_variables, axis=1),
            np.median(norm_variables, axis=1),
            np.max(norm_variables, axis=1),
            np.min(norm_variables, axis=1),
            np.mean(norm_variables > huge, axis=1),  # Huge
            np.mean((huge >= norm_variables) & (norm_variables > medium), axis=1),
            np.mean((medium >= norm_variables) & (norm_variables > large), axis=1),
            np.mean(large >= norm_variables, axis=1),  # Small
            np.mean(tiny >= norm_variables, axis=1),  # Tiny
        ],
    ).astype(np.float64)

extract_features_as_dict(instances)

Creates a dictionary with the features of the instances.

The key are the names of each feature and the values are the values extracted from instance. For the BPP domain, the features consist of: - N as the number of items, - Capacity as the maximum capacity of each bin, MeanWeights of the items, - MedianWeights of the items, VarianceWeights of the weights of the items, - MaxWeight of the items in the instance, MinWeight of the items, - Huge as the ratio of items with normalised weights > 0.5, - Large as the ratio of items with normalised weights between 0.333 and 0.5, - Medium as the ratio of items with normalised weights between 0.25 and 0.333, - Small as the ratio of items with normalised weights >= 0.25, - Tiny as the ratio of items with normalised weights >= 0.1

Parameters:
  • instances (Sequence[Instance]) –

    Instances to extract the features from.

Returns:
  • List[Dict[str, float64]]

    Dict[str, np.float64]: Dictionary with the names/values of each feature

Source code in digneapy/domains/bpp.py
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
def extract_features_as_dict(
    self, instances: Sequence[Instance] | np.ndarray
) -> List[Dict[str, np.float64]]:
    """Creates a dictionary with the features of the instances.

    The key are the names of each feature and the values are
    the values extracted from instance.
    For the BPP domain, the features consist of:
       - N as the number of items,
       - Capacity as the maximum capacity of each bin, MeanWeights of the items,
       - MedianWeights of the items, VarianceWeights of the weights of the items,
       - MaxWeight of the items in the instance, MinWeight of the items,
       - Huge as the ratio of items with normalised weights > 0.5,
       - Large as the ratio of items with normalised weights between 0.333 and 0.5,
       - Medium as the ratio of items with normalised weights between 0.25 and 0.333,
       - Small as the ratio of items with normalised weights >= 0.25,
       - Tiny as the ratio of items with normalised weights >= 0.1

    Args:
        instances (Sequence[Instance]): Instances to extract the features from.

    Returns:
        Dict[str, np.float64]: Dictionary with the names/values of each feature
    """
    features = self.extract_features(instances)
    named_features = []
    for instance_features in features:
        named_features.append({
            k: v for k, v in zip(self.features_names, instance_features)
        })

    return named_features

generate_instances(n=np.uint32(1))

Generates N new instances for the BPP domain.

Parameters:
  • n (int, default: uint32(1) ) –

    Number of instances to generate. Defaults to 1.

Returns:
  • Sequence[Instance]

    List[Instance]: A list of Instance objects created from the raw numpy generation

Source code in digneapy/domains/bpp.py
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
403
404
def generate_instances(self, n: np.uint32 = np.uint32(1)) -> Sequence[Instance]:
    """Generates N new instances for the BPP domain.

    Args:
        n (int, optional): Number of instances to generate. Defaults to 1.

    Returns:
        List[Instance]: A list of Instance objects created from the raw numpy generation
    """
    # Dimension is set correctly to number_of_items + 1 to
    # allow the random generation of capacities
    instances = self._rng.integers(
        low=self._minimum_weight,
        high=self._maximum_weight,
        size=(n, self._dimension),
        dtype=int,
    )
    # Sets the capacity according to the method
    match self.capacity_approach:
        case "evolved":
            instances[:, 0] = self._rng.integers(1, self._max_capacity, size=n)
        case "percentage":
            instances[:, 0] = (
                np.sum(instances[:, 1:], axis=1, dtype=np.int32)
                * self._capacity_ratio
            )
        case "fixed":
            instances[:, 0] = self._max_capacity

    return list(Instance(variables=i) for i in instances)

generate_problems_from_instances(instances)

Generates BPP problems from the given instances

This method is used to generate a collection of (objects) of the BPP class ready to be solved from the definition of the instances.

Parameters:
  • instances (Sequence[Instance] | ndarray) –

    Instances to generate the problems from.

Returns:
  • List[BPP]

    List[BPP]: List of BPP objects created from the instances

Source code in digneapy/domains/bpp.py
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
def generate_problems_from_instances(
    self, instances: Sequence[Instance] | np.ndarray
) -> List[BPP]:
    """Generates BPP problems from the given instances


    This method is used to generate a collection of (objects)
    of the BPP class ready to be solved from the definition of the instances.

    Args:
        instances (Sequence[Instance] | np.ndarray): Instances to generate
            the problems from.

    Returns:
        List[BPP]: List of BPP objects created from the instances
    """
    if not isinstance(instances, np.ndarray):
        instances = np.asarray(instances)

    # Assume evolved capacities
    capacities = instances[:, 0].astype(np.int32)
    match self.capacity_approach:
        case "percentage":
            capacities[:] = (
                np.sum(instances[:, 1:], axis=1) * self._capacity_ratio
            ).astype(np.int32)
            instances[:, 0] = capacities[:]
        case "fixed":
            capacities[:] = self._max_capacity
            instances[:, 0] = self._max_capacity
    # The first item of each valid BPP instance is the capacity
    print(capacities)
    return list(
        BPP(items=instances[i, 1:], maximum_capacity=capacities[i])
        for i in range(len(instances))
    )

Knapsack

Bases: Problem

Representation of a 0/1 Knapsack Problem.

Each item contributes a profit and consumes a weight. A solution is encoded as a binary vector where each entry indicates whether the corresponding item is selected.

The objective rewards profit while penalizing solutions that exceed the assigned capacity.

Source code in digneapy/domains/kp.py
 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
class Knapsack(Problem):
    """Representation of a 0/1 Knapsack Problem.

    Each item contributes a profit and consumes a weight.
    A solution is encoded as a binary vector where each entry indicates
    whether the corresponding item is selected.

    The objective rewards profit while penalizing solutions that exceed the assigned capacity.
    """

    def __init__(
        self,
        capacity: int,
        profits: Sequence[np.uint32] | np.ndarray,
        weights: Sequence[np.uint32] | np.ndarray,
        seed: Optional[int | np.random.SeedSequence] = None,
        penalty_factor: float = 100.0,
        *args,
        **kwargs,
    ):
        """Create a new knapsack problem from the given profit/weight data.

        Args:
            profits (Sequence[np.uint32] | np.ndarray): Profit associated with each item.
            weights (Sequence[np.uint32] | np.ndarray): Weight associated with each item.
            capacity (np.uint64, optional): Maximum total weight allowed in the knapsack.
            seed (Optional[int | np.random.SeedSequence], optional): Seed used to initialize the random generator.

        Raises:
            ValueError: If the profit and weight sequences do not have the same length
                        or if the capacity is not positive.
        """
        try:
            self.capacity = int(capacity)
            if self.capacity <= 0:
                raise ValueError("capacity cannot be negative")

        except (TypeError, ValueError) as exc:
            raise ValueError(
                f"invalid capacity ({capacity}) for Knapsack Problem"
            ) from exc

        if len(profits) != len(weights):
            raise ValueError(
                f"Mismatch of weights and profits in Knapsack. "
                f" Got {len(weights)} weights and {len(profits)} profits."
            )

        self.weights = np.asarray(weights, dtype=np.uint32)
        self.profits = np.asarray(profits, dtype=np.uint32)
        self._penalization_factor = float(penalty_factor)

        super().__init__(dimension=len(profits), bounds=[], name="KP", seed=seed)

    def get_bounds_at(self, i: int) -> tuple:
        """Return the valid bounds for one decision variable.

        Each item is handled as a binary decision: selecting it corresponds to value 1,
        while leaving it out corresponds to value 0.

        Args:
            i (int): Index of the variable to inspect.

        Raises:
            IndexError: If the index is outside the valid range of the problem.

        Returns:
            tuple: A tuple containing the lower and upper bounds for the variable.
        """
        if i < 0 or i > self._dimension:
            raise IndexError(
                f"Index {i} out-of-range. The bounds are 0-{self._dimension} "
            )
        return (0, 1)

    @property
    def bounds(self):
        """Return the binary bounds for every decision variable in the problem."""

        return list((0, 1) for _ in range(self._dimension))

    def evaluate(self, individual: Sequence | Solution | np.ndarray) -> Tuple[float]:
        """Evaluate a candidate solution and compute its objective value.

        The score is the total profit of the selected items minus a penalty for any
        excess weight beyond the knapsack capacity. This makes infeasible solutions
        receive a lower fitness than feasible ones.

        Args:
            individual (Sequence | Solution): Candidate solution to evaluate.

        Raises:
            ValueError: If the individual length does not match the number of items.

        Returns:
            Tuple[float]: A one-element tuple containing the objective value.
        """

        if len(individual) != self._dimension:
            raise ValueError(
                f"Mismatch between individual dimension ({len(individual)}) "
                f"and Knapsack problem ({self._dimension})"
            )

        mask = np.asarray(individual, dtype=bool)
        profit = np.sum(self.profits[mask], dtype=np.int32)
        packed = np.sum(self.weights[mask], dtype=np.int32)
        difference = max(0, packed - self.capacity)
        penalty = self._penalization_factor * difference
        profit -= penalty

        return (profit,)

    def __call__(self, individual: Sequence | Solution | np.ndarray) -> Tuple[float]:
        """Evaluate a candidate solution and compute its objective value.

        The score is the total profit of the selected items minus a penalty for any
        excess weight beyond the knapsack capacity. This makes infeasible solutions
        receive a lower fitness than feasible ones.

        Args:
            individual (Sequence | Solution): Candidate solution to evaluate.

        Raises:
            ValueError: If the individual length does not match the number of items.

        Returns:
            Tuple[float]: A one-element tuple containing the objective value.
        """

        return self.evaluate(individual)

    def __array__(self, dtype=np.uint32, copy: Optional[bool] = None) -> np.ndarray:
        """Return a NumPy array representation of the Knapsack Problem.

        The representation stores the capacity first and then alternates weight/profit
        pairs for each item, which is convenient for serialization and downstream processing.

        Returns:
            np.ndarray: A one-dimensional array describing the instance.
        """
        return np.asarray(
            [
                self.capacity,
                *list(
                    itertools.chain.from_iterable([*zip(self.weights, self.profits)])
                ),
            ],
            dtype=dtype,
            copy=copy,
        )

    def __str__(self):
        """Return a compact string representation of the problem instance."""

        return f"KP(n={self._dimension},C={self.capacity})"

    def __len__(self):
        """Return the number of items defined by the problem."""

        return len(self.weights)

    def create_solution(self) -> Solution:
        """Create a random initial solution for the Knapsack Problem.

        The returned solution is a binary vector that can be used as a starting point
        for an optimizer, although it may be infeasible if the selected items exceed the capacity.

        Returns:
            Solution: A solution object with binary decision variables and empty objectives (1d).
        """
        chromosome = self._rng.integers(low=0, high=1, size=self._dimension)
        return Solution(
            variables=chromosome, objectives=np.zeros(1), constraints=np.zeros(1)
        )

    def to_file(self, filename: str | Path = "instance.kp"):
        """Stores the Knapsack Problem in a plain text file.

        The file format contains the number of items and the capacity on the first line,
        followed by one row per item containing its weight and profit.

        Args:
            filename (str | Path, optional): Destination file for the serialized instance.
        """
        try:
            with open(filename, "w") as file:
                file.write(f"{len(self)}\t{self.capacity}\n\n")
                content = "\n".join(
                    f"{w_i}\t{p_i}" for w_i, p_i in zip(self.weights, self.profits)
                )
                file.write(content)
        except Exception as exc:
            raise RuntimeError(
                "Something went wrong when saving the Knapsack problem."
            ) from exc

    @classmethod
    def from_file(cls, filename: str | Path) -> Self:
        """Load a Knapsack Problem from a text file.

        Args:
            filename (str | Path): Path to the file containing the instance definition.

        Returns:
            Knapsack: A knapsack problem rebuilt from the stored contents.
        """
        try:
            content = np.loadtxt(filename, dtype=np.uint64)
            capacity = content[0][1]
            weights, profits = content[1:, 0], content[1:, 1]
            return cls(profits=profits, weights=weights, capacity=capacity)

        except Exception as exc:
            raise RuntimeError(
                f"Something went wrong when loading the Knapsack problem from {filename}."
            ) from exc

    def to_instance(self) -> Instance:
        """Convert the Knapsack Problem into an Instance object used by Digneapy.

        Returns:
            Instance: An instance object containing the capacity and all item weights/profits.
                The capacity is the first item in the instance variables,
                followed by interleaved weights and profits w_0, p_0, w_1, p_1, etc.
        """
        _variables = [self.capacity] + list(
            itertools.chain.from_iterable([*zip(self.weights, self.profits)])
        )
        return Instance(variables=_variables)

bounds property

Return the binary bounds for every decision variable in the problem.

__array__(dtype=np.uint32, copy=None)

Return a NumPy array representation of the Knapsack Problem.

The representation stores the capacity first and then alternates weight/profit pairs for each item, which is convenient for serialization and downstream processing.

Returns:
  • ndarray

    np.ndarray: A one-dimensional array describing the instance.

Source code in digneapy/domains/kp.py
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
def __array__(self, dtype=np.uint32, copy: Optional[bool] = None) -> np.ndarray:
    """Return a NumPy array representation of the Knapsack Problem.

    The representation stores the capacity first and then alternates weight/profit
    pairs for each item, which is convenient for serialization and downstream processing.

    Returns:
        np.ndarray: A one-dimensional array describing the instance.
    """
    return np.asarray(
        [
            self.capacity,
            *list(
                itertools.chain.from_iterable([*zip(self.weights, self.profits)])
            ),
        ],
        dtype=dtype,
        copy=copy,
    )

__call__(individual)

Evaluate a candidate solution and compute its objective value.

The score is the total profit of the selected items minus a penalty for any excess weight beyond the knapsack capacity. This makes infeasible solutions receive a lower fitness than feasible ones.

Parameters:
  • individual (Sequence | Solution) –

    Candidate solution to evaluate.

Raises:
  • ValueError

    If the individual length does not match the number of items.

Returns:
  • Tuple[float]

    Tuple[float]: A one-element tuple containing the objective value.

Source code in digneapy/domains/kp.py
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
def __call__(self, individual: Sequence | Solution | np.ndarray) -> Tuple[float]:
    """Evaluate a candidate solution and compute its objective value.

    The score is the total profit of the selected items minus a penalty for any
    excess weight beyond the knapsack capacity. This makes infeasible solutions
    receive a lower fitness than feasible ones.

    Args:
        individual (Sequence | Solution): Candidate solution to evaluate.

    Raises:
        ValueError: If the individual length does not match the number of items.

    Returns:
        Tuple[float]: A one-element tuple containing the objective value.
    """

    return self.evaluate(individual)

__init__(capacity, profits, weights, seed=None, penalty_factor=100.0, *args, **kwargs)

Create a new knapsack problem from the given profit/weight data.

Parameters:
  • profits (Sequence[uint32] | ndarray) –

    Profit associated with each item.

  • weights (Sequence[uint32] | ndarray) –

    Weight associated with each item.

  • capacity (uint64) –

    Maximum total weight allowed in the knapsack.

  • seed (Optional[int | SeedSequence], default: None ) –

    Seed used to initialize the random generator.

Raises:
  • ValueError

    If the profit and weight sequences do not have the same length or if the capacity is not positive.

Source code in digneapy/domains/kp.py
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
def __init__(
    self,
    capacity: int,
    profits: Sequence[np.uint32] | np.ndarray,
    weights: Sequence[np.uint32] | np.ndarray,
    seed: Optional[int | np.random.SeedSequence] = None,
    penalty_factor: float = 100.0,
    *args,
    **kwargs,
):
    """Create a new knapsack problem from the given profit/weight data.

    Args:
        profits (Sequence[np.uint32] | np.ndarray): Profit associated with each item.
        weights (Sequence[np.uint32] | np.ndarray): Weight associated with each item.
        capacity (np.uint64, optional): Maximum total weight allowed in the knapsack.
        seed (Optional[int | np.random.SeedSequence], optional): Seed used to initialize the random generator.

    Raises:
        ValueError: If the profit and weight sequences do not have the same length
                    or if the capacity is not positive.
    """
    try:
        self.capacity = int(capacity)
        if self.capacity <= 0:
            raise ValueError("capacity cannot be negative")

    except (TypeError, ValueError) as exc:
        raise ValueError(
            f"invalid capacity ({capacity}) for Knapsack Problem"
        ) from exc

    if len(profits) != len(weights):
        raise ValueError(
            f"Mismatch of weights and profits in Knapsack. "
            f" Got {len(weights)} weights and {len(profits)} profits."
        )

    self.weights = np.asarray(weights, dtype=np.uint32)
    self.profits = np.asarray(profits, dtype=np.uint32)
    self._penalization_factor = float(penalty_factor)

    super().__init__(dimension=len(profits), bounds=[], name="KP", seed=seed)

__len__()

Return the number of items defined by the problem.

Source code in digneapy/domains/kp.py
184
185
186
187
def __len__(self):
    """Return the number of items defined by the problem."""

    return len(self.weights)

__str__()

Return a compact string representation of the problem instance.

Source code in digneapy/domains/kp.py
179
180
181
182
def __str__(self):
    """Return a compact string representation of the problem instance."""

    return f"KP(n={self._dimension},C={self.capacity})"

create_solution()

Create a random initial solution for the Knapsack Problem.

The returned solution is a binary vector that can be used as a starting point for an optimizer, although it may be infeasible if the selected items exceed the capacity.

Returns:
  • Solution( Solution ) –

    A solution object with binary decision variables and empty objectives (1d).

Source code in digneapy/domains/kp.py
189
190
191
192
193
194
195
196
197
198
199
200
201
def create_solution(self) -> Solution:
    """Create a random initial solution for the Knapsack Problem.

    The returned solution is a binary vector that can be used as a starting point
    for an optimizer, although it may be infeasible if the selected items exceed the capacity.

    Returns:
        Solution: A solution object with binary decision variables and empty objectives (1d).
    """
    chromosome = self._rng.integers(low=0, high=1, size=self._dimension)
    return Solution(
        variables=chromosome, objectives=np.zeros(1), constraints=np.zeros(1)
    )

evaluate(individual)

Evaluate a candidate solution and compute its objective value.

The score is the total profit of the selected items minus a penalty for any excess weight beyond the knapsack capacity. This makes infeasible solutions receive a lower fitness than feasible ones.

Parameters:
  • individual (Sequence | Solution) –

    Candidate solution to evaluate.

Raises:
  • ValueError

    If the individual length does not match the number of items.

Returns:
  • Tuple[float]

    Tuple[float]: A one-element tuple containing the objective value.

Source code in digneapy/domains/kp.py
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
def evaluate(self, individual: Sequence | Solution | np.ndarray) -> Tuple[float]:
    """Evaluate a candidate solution and compute its objective value.

    The score is the total profit of the selected items minus a penalty for any
    excess weight beyond the knapsack capacity. This makes infeasible solutions
    receive a lower fitness than feasible ones.

    Args:
        individual (Sequence | Solution): Candidate solution to evaluate.

    Raises:
        ValueError: If the individual length does not match the number of items.

    Returns:
        Tuple[float]: A one-element tuple containing the objective value.
    """

    if len(individual) != self._dimension:
        raise ValueError(
            f"Mismatch between individual dimension ({len(individual)}) "
            f"and Knapsack problem ({self._dimension})"
        )

    mask = np.asarray(individual, dtype=bool)
    profit = np.sum(self.profits[mask], dtype=np.int32)
    packed = np.sum(self.weights[mask], dtype=np.int32)
    difference = max(0, packed - self.capacity)
    penalty = self._penalization_factor * difference
    profit -= penalty

    return (profit,)

from_file(filename) classmethod

Load a Knapsack Problem from a text file.

Parameters:
  • filename (str | Path) –

    Path to the file containing the instance definition.

Returns:
  • Knapsack( Self ) –

    A knapsack problem rebuilt from the stored contents.

Source code in digneapy/domains/kp.py
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
@classmethod
def from_file(cls, filename: str | Path) -> Self:
    """Load a Knapsack Problem from a text file.

    Args:
        filename (str | Path): Path to the file containing the instance definition.

    Returns:
        Knapsack: A knapsack problem rebuilt from the stored contents.
    """
    try:
        content = np.loadtxt(filename, dtype=np.uint64)
        capacity = content[0][1]
        weights, profits = content[1:, 0], content[1:, 1]
        return cls(profits=profits, weights=weights, capacity=capacity)

    except Exception as exc:
        raise RuntimeError(
            f"Something went wrong when loading the Knapsack problem from {filename}."
        ) from exc

get_bounds_at(i)

Return the valid bounds for one decision variable.

Each item is handled as a binary decision: selecting it corresponds to value 1, while leaving it out corresponds to value 0.

Parameters:
  • i (int) –

    Index of the variable to inspect.

Raises:
  • IndexError

    If the index is outside the valid range of the problem.

Returns:
  • tuple( tuple ) –

    A tuple containing the lower and upper bounds for the variable.

Source code in digneapy/domains/kp.py
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
def get_bounds_at(self, i: int) -> tuple:
    """Return the valid bounds for one decision variable.

    Each item is handled as a binary decision: selecting it corresponds to value 1,
    while leaving it out corresponds to value 0.

    Args:
        i (int): Index of the variable to inspect.

    Raises:
        IndexError: If the index is outside the valid range of the problem.

    Returns:
        tuple: A tuple containing the lower and upper bounds for the variable.
    """
    if i < 0 or i > self._dimension:
        raise IndexError(
            f"Index {i} out-of-range. The bounds are 0-{self._dimension} "
        )
    return (0, 1)

to_file(filename='instance.kp')

Stores the Knapsack Problem in a plain text file.

The file format contains the number of items and the capacity on the first line, followed by one row per item containing its weight and profit.

Parameters:
  • filename (str | Path, default: 'instance.kp' ) –

    Destination file for the serialized instance.

Source code in digneapy/domains/kp.py
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
def to_file(self, filename: str | Path = "instance.kp"):
    """Stores the Knapsack Problem in a plain text file.

    The file format contains the number of items and the capacity on the first line,
    followed by one row per item containing its weight and profit.

    Args:
        filename (str | Path, optional): Destination file for the serialized instance.
    """
    try:
        with open(filename, "w") as file:
            file.write(f"{len(self)}\t{self.capacity}\n\n")
            content = "\n".join(
                f"{w_i}\t{p_i}" for w_i, p_i in zip(self.weights, self.profits)
            )
            file.write(content)
    except Exception as exc:
        raise RuntimeError(
            "Something went wrong when saving the Knapsack problem."
        ) from exc

to_instance()

Convert the Knapsack Problem into an Instance object used by Digneapy.

Returns:
  • Instance( Instance ) –

    An instance object containing the capacity and all item weights/profits. The capacity is the first item in the instance variables, followed by interleaved weights and profits w_0, p_0, w_1, p_1, etc.

Source code in digneapy/domains/kp.py
245
246
247
248
249
250
251
252
253
254
255
256
def to_instance(self) -> Instance:
    """Convert the Knapsack Problem into an Instance object used by Digneapy.

    Returns:
        Instance: An instance object containing the capacity and all item weights/profits.
            The capacity is the first item in the instance variables,
            followed by interleaved weights and profits w_0, p_0, w_1, p_1, etc.
    """
    _variables = [self.capacity] + list(
        itertools.chain.from_iterable([*zip(self.weights, self.profits)])
    )
    return Instance(variables=_variables)

KnapsackDomain

Bases: Domain

Knapsack Domain for synthesizing Knapsack Problem instances.

This class allows to create benchmark instances by sampling item weights and profits and then assigning a capacity using one of several strategies. Note that the number of dimensions defined produces instances of N = dimension items. Which means that the results Instance objects will have 2 * dimension + 1 variables: - Q, w_0, p_0, w_1, p_1, ..., w_N-1, p_N-1

It also provides utilities to extract descriptive features and build concrete Knapsack problems from the generated data.

Source code in digneapy/domains/kp.py
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
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
class KnapsackDomain(Domain):
    """Knapsack Domain for synthesizing Knapsack Problem instances.

    This class allows to create benchmark instances by sampling
    item weights and profits and then assigning a capacity using one of several strategies.
    Note that the number of dimensions defined produces instances of N = dimension items.
    Which means that the results Instance objects will have 2 * dimension + 1 variables:
        - Q, w_0, p_0, w_1, p_1, ..., w_N-1, p_N-1

    It also provides utilities to extract descriptive features
    and build concrete Knapsack problems from the generated data.
    """

    capacity_approaches = Literal["evolved", "percentage", "fixed"]

    def __init__(
        self,
        number_of_items: np.uint32 | int = np.uint32(50),
        minimum_weight: np.uint32 = np.uint32(1),
        maximum_weight: np.uint32 = np.uint32(1_000),
        minimum_profit: np.uint32 = np.uint32(1),
        maximum_profit: np.uint32 = np.uint32(1_000),
        maximum_capacity: np.uint32 = np.uint32(1e5),
        capacity_approach: str = "evolved",
        capacity_ratio: float = 0.8,
        seed: Optional[int | np.random.SeedSequence] = None,
    ):
        """Create a domain that can generate knapsack instances with configurable difficulty.

        Args:
            number_of_items (np.uint32, optional): Number of items in each generated instance. Note that
                the dimension of the domain will be calculated as 2 * number_of_items + 1. Defaults to 50.
            minimum_weight (np.uint32, optional): Lower bound for the weight of each item. Defaults to 1.
            maximum_weight (np.uint32, optional): Upper bound for the weight of each item. Defaults to 1,000.
            minimum_profit (np.uint32, optional): Lower bound for the profit of each item. Defaults to 1.
            maximum_profit (np.uint32, optional): Upper bound for the profit of each item. Defaults to 1,000.
            maximum_capacity (np.uint32, optional): Maximum capacity that can be assigned to a
                Knapsack instance when using the evolved or fixed strategy. Defaults to 100,000.
            capacity_approach (str, optional): Strategy used to assign capacities to generated instances. Defaults to evolved.
            capacity_ratio (float, optional): Ratio used to derive the capacity when the percentage strategy is selected. Defaults to 0.8.
            seed (Optional[int | np.random.SeedSequence], optional): Seed used to initialize the random generator. Default to None.
        """
        try:
            self._number_of_items = int(number_of_items)

            if number_of_items <= 0:
                raise ValueError()

        except (TypeError, ValueError) as exc:
            raise ValueError(
                f"invalid dimension for KnapsackDomain. Got: {number_of_items}"
            ) from exc

        try:
            self._minimum_profit = int(minimum_profit)
            self._minimum_weight = int(minimum_weight)
            self._maximum_profit = int(maximum_profit)
            self._maximum_weight = int(maximum_weight)
            self._maximum_capacity = int(maximum_capacity)

            if self._maximum_capacity <= 0:
                raise ValueError(
                    f"maximum_capacity cannot be negative: {self._maximum_capacity}"
                )

            if (
                self._minimum_profit <= 0
                or self._maximum_profit <= 0
                or self._minimum_profit >= self._maximum_profit
            ):
                raise ValueError(
                    f"error in profit ranges: ({self._minimum_profit}, {self._maximum_profit})"
                )

            if (
                self._minimum_weight <= 0
                or self._minimum_weight <= 0
                or self._minimum_weight >= self._maximum_weight
            ):
                raise ValueError(
                    f"error in weight ranges: ({self._minimum_weight}, {self._maximum_weight})"
                )

        except (TypeError, ValueError) as exc:
            raise ValueError(
                "capacity, minimum and maximum ranges must be valid positive integers. "
                f"Expects capacity ({maximum_capacity}). "
                f"Expects minimum_profit ({minimum_profit}) to be greater "
                f"than zero and less than maximum_profit ({maximum_profit}).\n"
                f"Expects minimum_weight ({minimum_weight}) to be greater "
                f"than zero and less than maximum_weight ({maximum_weight}).\n"
            ) from exc

        try:
            self._capacity_ratio = float(capacity_ratio)
            if self._capacity_ratio <= 0 or self._capacity_ratio > 1:
                raise ValueError(
                    "capacity_ratio  must be a positive float in the range [0.0, 1.0]."
                )

        except (TypeError, ValueError) as exc:
            raise ValueError from exc

        if capacity_approach not in self.capacity_approaches.__args__:
            warnings.warn(
                f"The capacity approach {capacity_approach} is not available. "
                f"Please, consider choosing from {self.capacity_approaches.__args__}. "
                "Set evolved approach set as fallback.",
                RuntimeWarning,
            )
            self._capacity_approach = "evolved"
        else:
            self._capacity_approach = capacity_approach

        _bounds = [(1.0, self._maximum_capacity)] + [
            (minimum_weight, maximum_weight)
            if i % 2 == 0
            else (minimum_profit, maximum_profit)
            for i in range(number_of_items * 2)  # Remove the capacity dimension
        ]
        _features_names = "capacity,max_p,max_w,min_p,min_w,avg_eff,mean,std".split(",")
        # The dimension of a KnapsackDomain is 2 times number of items plus the capacity
        _dimension = (self._number_of_items * 2) + 1
        super().__init__(
            dimension=_dimension,
            bounds=_bounds,
            domain_name="Knapsack",
            features_names=_features_names,
            seed=seed,
        )

    @property
    def capacity_approach(self):
        """Return the strategy currently used to assign capacities to generated instances."""
        return self._capacity_approach

    @property
    def capacity_ratio(self):
        """Returns the ratio to which the capacity is update when using percentage approach"""
        return self._capacity_ratio

    def generate_instances(self, n: np.uint32 | int = np.uint32(1)) -> List[Instance]:
        """Generate a batch of knapsack instances.

        The method samples item weights and profits for each instance and then assigns a
        capacity according to the selected strategy. This creates instances with varying
        levels of difficulty and tightness.

        Args:
            n (int, optional): Number of instances to generate. Defaults to 1.

        Returns:
            List[Instance]: A list of generated instance objects.
        """
        weights_and_profits = np.empty(
            shape=(n, self._number_of_items * 2), dtype=np.uint32
        )
        weights_and_profits[:, 0::2] = self._rng.integers(
            low=self._minimum_weight,
            high=self._maximum_weight,
            size=(n, self._number_of_items),
        )
        weights_and_profits[:, 1::2] = self._rng.integers(
            low=self._minimum_profit,
            high=self._maximum_profit,
            size=(n, self._number_of_items),
        )
        # Assume fixed
        capacities = np.full(n, fill_value=self._maximum_capacity, dtype=np.int32)
        match self.capacity_approach:
            case "evolved":
                capacities[:] = self._rng.integers(1, self._maximum_capacity, size=n)

            case "percentage":
                capacities[:] = (
                    np.sum(weights_and_profits[:, 1::2], axis=1) * self.capacity_ratio
                ).astype(np.int32)

        return list(
            Instance(i) for i in np.column_stack((capacities, weights_and_profits))
        )

    def extract_features(
        self, instances: Sequence[Instance] | np.ndarray
    ) -> np.ndarray:
        """Compute a compact set of numerical features for the supplied instances.

        These features summarize the Knapsack instance structure, they include:
            - Capacity
            - Maximum profit
            - Maximum weight
            - Minimum profit
            - Minimum weight
            - Average efficiency as the average ratio of profits / weights
            - Mean of the values (both profits and weights)
            - Standard deviation of the values (both profits and weights)

        Args:
            instances (Sequence[Instance]): Instances to characterize.

        Returns:
            np.ndarray: A two-dimensional array where each row contains the features of one instance.
        """

        _instances = np.asarray(instances)

        features = np.empty(shape=(len(_instances), 8), dtype=np.float64)
        weights = _instances[:, 1::2]
        profits = _instances[:, 2::2]
        efficiency = np.mean(profits / weights, axis=1, dtype=np.float64)
        features[:, 0] = _instances[:, 0]  # Qs
        features[:, 1] = np.max(profits, axis=1)
        features[:, 2] = np.max(weights, axis=1)
        features[:, 3] = np.min(profits, axis=1)
        features[:, 4] = np.min(weights, axis=1)
        features[:, 5] = efficiency
        features[:, 6] = np.mean(_instances[:, 1:], axis=1)
        features[:, 7] = np.std(_instances[:, 1:], axis=1)
        return features

    def extract_features_as_dict(
        self, instances: Sequence[Instance] | np.ndarray
    ) -> List[Dict[str, np.float64]]:
        """Return the extracted features as dictionaries.

        These features summarize the Knapsack instance structure, they include:
            - Capacity
            - Maximum profit
            - Maximum weight
            - Minimum profit
            - Minimum weight
            - Average efficiency as the average ratio of profits / weights
            - Mean of the values (both profits and weights)
            - Standard deviation of the values (both profits and weights)

        Args:
            instances (Sequence[Instance]): Instances whose features should be extracted.

        Returns:
            List[Dict[str, np.float64]]: One dictionary per instance containing the named features.
        """
        features = self.extract_features(instances)
        named_features = []
        for instance_features in features:
            named_features.append({
                k: v for k, v in zip(self.features_names, instance_features)
            })

        return named_features

    def generate_problems_from_instances(
        self, instances: Sequence[Instance] | np.ndarray
    ) -> List:
        """Create Knapsack Problem objects from the given instances.

        This method converts the numerical representation of each instance into a fully
        functional Knapsack Problem that can be passed directly to a solver.

        Args:
            instances (Sequence[Instance]): Instances to transform into problems.

        Returns:
            List: A list containing one Knapsack problem per instance.
        """
        _instances = np.asarray(instances)

        capacities = _instances[:, 0].astype(np.int32)
        weights = _instances[:, 1::2].astype(np.uint32)
        profits = _instances[:, 2::2].astype(np.uint32)
        # Sets the capacity according to the method
        if self.capacity_approach == "percentage":
            capacities[:] = (np.sum(weights, axis=1) * self.capacity_ratio).astype(
                np.int32
            )
            _instances[:, 0] = capacities[:]
        elif self.capacity_approach == "fixed":
            capacities[:] = self._maximum_capacity
            _instances[:, 0] = capacities[:]

        return list(
            Knapsack(profits=profits[i], weights=weights[i], capacity=capacities[i])
            for i in range(len(_instances))
        )

capacity_approach property

Return the strategy currently used to assign capacities to generated instances.

capacity_ratio property

Returns the ratio to which the capacity is update when using percentage approach

__init__(number_of_items=np.uint32(50), minimum_weight=np.uint32(1), maximum_weight=np.uint32(1000), minimum_profit=np.uint32(1), maximum_profit=np.uint32(1000), maximum_capacity=np.uint32(100000.0), capacity_approach='evolved', capacity_ratio=0.8, seed=None)

Create a domain that can generate knapsack instances with configurable difficulty.

Parameters:
  • number_of_items (uint32, default: uint32(50) ) –

    Number of items in each generated instance. Note that the dimension of the domain will be calculated as 2 * number_of_items + 1. Defaults to 50.

  • minimum_weight (uint32, default: uint32(1) ) –

    Lower bound for the weight of each item. Defaults to 1.

  • maximum_weight (uint32, default: uint32(1000) ) –

    Upper bound for the weight of each item. Defaults to 1,000.

  • minimum_profit (uint32, default: uint32(1) ) –

    Lower bound for the profit of each item. Defaults to 1.

  • maximum_profit (uint32, default: uint32(1000) ) –

    Upper bound for the profit of each item. Defaults to 1,000.

  • maximum_capacity (uint32, default: uint32(100000.0) ) –

    Maximum capacity that can be assigned to a Knapsack instance when using the evolved or fixed strategy. Defaults to 100,000.

  • capacity_approach (str, default: 'evolved' ) –

    Strategy used to assign capacities to generated instances. Defaults to evolved.

  • capacity_ratio (float, default: 0.8 ) –

    Ratio used to derive the capacity when the percentage strategy is selected. Defaults to 0.8.

  • seed (Optional[int | SeedSequence], default: None ) –

    Seed used to initialize the random generator. Default to None.

Source code in digneapy/domains/kp.py
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
def __init__(
    self,
    number_of_items: np.uint32 | int = np.uint32(50),
    minimum_weight: np.uint32 = np.uint32(1),
    maximum_weight: np.uint32 = np.uint32(1_000),
    minimum_profit: np.uint32 = np.uint32(1),
    maximum_profit: np.uint32 = np.uint32(1_000),
    maximum_capacity: np.uint32 = np.uint32(1e5),
    capacity_approach: str = "evolved",
    capacity_ratio: float = 0.8,
    seed: Optional[int | np.random.SeedSequence] = None,
):
    """Create a domain that can generate knapsack instances with configurable difficulty.

    Args:
        number_of_items (np.uint32, optional): Number of items in each generated instance. Note that
            the dimension of the domain will be calculated as 2 * number_of_items + 1. Defaults to 50.
        minimum_weight (np.uint32, optional): Lower bound for the weight of each item. Defaults to 1.
        maximum_weight (np.uint32, optional): Upper bound for the weight of each item. Defaults to 1,000.
        minimum_profit (np.uint32, optional): Lower bound for the profit of each item. Defaults to 1.
        maximum_profit (np.uint32, optional): Upper bound for the profit of each item. Defaults to 1,000.
        maximum_capacity (np.uint32, optional): Maximum capacity that can be assigned to a
            Knapsack instance when using the evolved or fixed strategy. Defaults to 100,000.
        capacity_approach (str, optional): Strategy used to assign capacities to generated instances. Defaults to evolved.
        capacity_ratio (float, optional): Ratio used to derive the capacity when the percentage strategy is selected. Defaults to 0.8.
        seed (Optional[int | np.random.SeedSequence], optional): Seed used to initialize the random generator. Default to None.
    """
    try:
        self._number_of_items = int(number_of_items)

        if number_of_items <= 0:
            raise ValueError()

    except (TypeError, ValueError) as exc:
        raise ValueError(
            f"invalid dimension for KnapsackDomain. Got: {number_of_items}"
        ) from exc

    try:
        self._minimum_profit = int(minimum_profit)
        self._minimum_weight = int(minimum_weight)
        self._maximum_profit = int(maximum_profit)
        self._maximum_weight = int(maximum_weight)
        self._maximum_capacity = int(maximum_capacity)

        if self._maximum_capacity <= 0:
            raise ValueError(
                f"maximum_capacity cannot be negative: {self._maximum_capacity}"
            )

        if (
            self._minimum_profit <= 0
            or self._maximum_profit <= 0
            or self._minimum_profit >= self._maximum_profit
        ):
            raise ValueError(
                f"error in profit ranges: ({self._minimum_profit}, {self._maximum_profit})"
            )

        if (
            self._minimum_weight <= 0
            or self._minimum_weight <= 0
            or self._minimum_weight >= self._maximum_weight
        ):
            raise ValueError(
                f"error in weight ranges: ({self._minimum_weight}, {self._maximum_weight})"
            )

    except (TypeError, ValueError) as exc:
        raise ValueError(
            "capacity, minimum and maximum ranges must be valid positive integers. "
            f"Expects capacity ({maximum_capacity}). "
            f"Expects minimum_profit ({minimum_profit}) to be greater "
            f"than zero and less than maximum_profit ({maximum_profit}).\n"
            f"Expects minimum_weight ({minimum_weight}) to be greater "
            f"than zero and less than maximum_weight ({maximum_weight}).\n"
        ) from exc

    try:
        self._capacity_ratio = float(capacity_ratio)
        if self._capacity_ratio <= 0 or self._capacity_ratio > 1:
            raise ValueError(
                "capacity_ratio  must be a positive float in the range [0.0, 1.0]."
            )

    except (TypeError, ValueError) as exc:
        raise ValueError from exc

    if capacity_approach not in self.capacity_approaches.__args__:
        warnings.warn(
            f"The capacity approach {capacity_approach} is not available. "
            f"Please, consider choosing from {self.capacity_approaches.__args__}. "
            "Set evolved approach set as fallback.",
            RuntimeWarning,
        )
        self._capacity_approach = "evolved"
    else:
        self._capacity_approach = capacity_approach

    _bounds = [(1.0, self._maximum_capacity)] + [
        (minimum_weight, maximum_weight)
        if i % 2 == 0
        else (minimum_profit, maximum_profit)
        for i in range(number_of_items * 2)  # Remove the capacity dimension
    ]
    _features_names = "capacity,max_p,max_w,min_p,min_w,avg_eff,mean,std".split(",")
    # The dimension of a KnapsackDomain is 2 times number of items plus the capacity
    _dimension = (self._number_of_items * 2) + 1
    super().__init__(
        dimension=_dimension,
        bounds=_bounds,
        domain_name="Knapsack",
        features_names=_features_names,
        seed=seed,
    )

extract_features(instances)

Compute a compact set of numerical features for the supplied instances.

These features summarize the Knapsack instance structure, they include: - Capacity - Maximum profit - Maximum weight - Minimum profit - Minimum weight - Average efficiency as the average ratio of profits / weights - Mean of the values (both profits and weights) - Standard deviation of the values (both profits and weights)

Parameters:
  • instances (Sequence[Instance]) –

    Instances to characterize.

Returns:
  • ndarray

    np.ndarray: A two-dimensional array where each row contains the features of one instance.

Source code in digneapy/domains/kp.py
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
def extract_features(
    self, instances: Sequence[Instance] | np.ndarray
) -> np.ndarray:
    """Compute a compact set of numerical features for the supplied instances.

    These features summarize the Knapsack instance structure, they include:
        - Capacity
        - Maximum profit
        - Maximum weight
        - Minimum profit
        - Minimum weight
        - Average efficiency as the average ratio of profits / weights
        - Mean of the values (both profits and weights)
        - Standard deviation of the values (both profits and weights)

    Args:
        instances (Sequence[Instance]): Instances to characterize.

    Returns:
        np.ndarray: A two-dimensional array where each row contains the features of one instance.
    """

    _instances = np.asarray(instances)

    features = np.empty(shape=(len(_instances), 8), dtype=np.float64)
    weights = _instances[:, 1::2]
    profits = _instances[:, 2::2]
    efficiency = np.mean(profits / weights, axis=1, dtype=np.float64)
    features[:, 0] = _instances[:, 0]  # Qs
    features[:, 1] = np.max(profits, axis=1)
    features[:, 2] = np.max(weights, axis=1)
    features[:, 3] = np.min(profits, axis=1)
    features[:, 4] = np.min(weights, axis=1)
    features[:, 5] = efficiency
    features[:, 6] = np.mean(_instances[:, 1:], axis=1)
    features[:, 7] = np.std(_instances[:, 1:], axis=1)
    return features

extract_features_as_dict(instances)

Return the extracted features as dictionaries.

These features summarize the Knapsack instance structure, they include: - Capacity - Maximum profit - Maximum weight - Minimum profit - Minimum weight - Average efficiency as the average ratio of profits / weights - Mean of the values (both profits and weights) - Standard deviation of the values (both profits and weights)

Parameters:
  • instances (Sequence[Instance]) –

    Instances whose features should be extracted.

Returns:
  • List[Dict[str, float64]]

    List[Dict[str, np.float64]]: One dictionary per instance containing the named features.

Source code in digneapy/domains/kp.py
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
def extract_features_as_dict(
    self, instances: Sequence[Instance] | np.ndarray
) -> List[Dict[str, np.float64]]:
    """Return the extracted features as dictionaries.

    These features summarize the Knapsack instance structure, they include:
        - Capacity
        - Maximum profit
        - Maximum weight
        - Minimum profit
        - Minimum weight
        - Average efficiency as the average ratio of profits / weights
        - Mean of the values (both profits and weights)
        - Standard deviation of the values (both profits and weights)

    Args:
        instances (Sequence[Instance]): Instances whose features should be extracted.

    Returns:
        List[Dict[str, np.float64]]: One dictionary per instance containing the named features.
    """
    features = self.extract_features(instances)
    named_features = []
    for instance_features in features:
        named_features.append({
            k: v for k, v in zip(self.features_names, instance_features)
        })

    return named_features

generate_instances(n=np.uint32(1))

Generate a batch of knapsack instances.

The method samples item weights and profits for each instance and then assigns a capacity according to the selected strategy. This creates instances with varying levels of difficulty and tightness.

Parameters:
  • n (int, default: uint32(1) ) –

    Number of instances to generate. Defaults to 1.

Returns:
  • List[Instance]

    List[Instance]: A list of generated instance objects.

Source code in digneapy/domains/kp.py
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
def generate_instances(self, n: np.uint32 | int = np.uint32(1)) -> List[Instance]:
    """Generate a batch of knapsack instances.

    The method samples item weights and profits for each instance and then assigns a
    capacity according to the selected strategy. This creates instances with varying
    levels of difficulty and tightness.

    Args:
        n (int, optional): Number of instances to generate. Defaults to 1.

    Returns:
        List[Instance]: A list of generated instance objects.
    """
    weights_and_profits = np.empty(
        shape=(n, self._number_of_items * 2), dtype=np.uint32
    )
    weights_and_profits[:, 0::2] = self._rng.integers(
        low=self._minimum_weight,
        high=self._maximum_weight,
        size=(n, self._number_of_items),
    )
    weights_and_profits[:, 1::2] = self._rng.integers(
        low=self._minimum_profit,
        high=self._maximum_profit,
        size=(n, self._number_of_items),
    )
    # Assume fixed
    capacities = np.full(n, fill_value=self._maximum_capacity, dtype=np.int32)
    match self.capacity_approach:
        case "evolved":
            capacities[:] = self._rng.integers(1, self._maximum_capacity, size=n)

        case "percentage":
            capacities[:] = (
                np.sum(weights_and_profits[:, 1::2], axis=1) * self.capacity_ratio
            ).astype(np.int32)

    return list(
        Instance(i) for i in np.column_stack((capacities, weights_and_profits))
    )

generate_problems_from_instances(instances)

Create Knapsack Problem objects from the given instances.

This method converts the numerical representation of each instance into a fully functional Knapsack Problem that can be passed directly to a solver.

Parameters:
  • instances (Sequence[Instance]) –

    Instances to transform into problems.

Returns:
  • List( List ) –

    A list containing one Knapsack problem per instance.

Source code in digneapy/domains/kp.py
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
def generate_problems_from_instances(
    self, instances: Sequence[Instance] | np.ndarray
) -> List:
    """Create Knapsack Problem objects from the given instances.

    This method converts the numerical representation of each instance into a fully
    functional Knapsack Problem that can be passed directly to a solver.

    Args:
        instances (Sequence[Instance]): Instances to transform into problems.

    Returns:
        List: A list containing one Knapsack problem per instance.
    """
    _instances = np.asarray(instances)

    capacities = _instances[:, 0].astype(np.int32)
    weights = _instances[:, 1::2].astype(np.uint32)
    profits = _instances[:, 2::2].astype(np.uint32)
    # Sets the capacity according to the method
    if self.capacity_approach == "percentage":
        capacities[:] = (np.sum(weights, axis=1) * self.capacity_ratio).astype(
            np.int32
        )
        _instances[:, 0] = capacities[:]
    elif self.capacity_approach == "fixed":
        capacities[:] = self._maximum_capacity
        _instances[:, 0] = capacities[:]

    return list(
        Knapsack(profits=profits[i], weights=weights[i], capacity=capacities[i])
        for i in range(len(_instances))
    )

Sphere

Bases: Problem

Minimises the shifted sphere: f(x) = Σ (xᵢ − centerᵢ)²

Fitness is returned as −f(x) so that higher is better, matching the maximisation convention used throughout digneapy.

Source code in digneapy/domains/sphere.py
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
class Sphere(Problem):
    """Minimises the shifted sphere: f(x) = Σ (xᵢ − centerᵢ)²

    Fitness is returned as *−f(x)* so that higher is better, matching the
    maximisation convention used throughout digneapy.
    """

    def __init__(
        self,
        dimension: np.uint32,
        seed: Optional[int | np.random.SeedSequence] = None,
    ):

        bounds = [(-5.12, 5.12)] * dimension
        super().__init__(
            dimension=dimension,
            bounds=bounds,
            name="Sphere",
            dtype=np.float64,
            seed=seed,
        )

    def evaluate(
        self, individual: Sequence | Solution | np.ndarray
    ) -> tuple[np.float64]:
        """Returns (−sphere_value,) so higher fitness = closer to centre."""
        x = np.asarray(individual, dtype=np.float64)
        sphere_val = np.float64(np.sum(x**2))
        return (sphere_val,)

    def create_solution(self) -> Solution:
        """Creates a random feasible solution."""
        variables = self._rng.uniform(self._lbs, self._ubs)
        (fitness,) = self.evaluate(variables)
        return Solution(
            variables=variables,
            objectives=[fitness],
            fitness=fitness,
            dtype=np.float64,
            otype=np.float64,
        )

    def __call__(self, individual: Sequence | Solution | np.ndarray) -> tuple[float]:
        """Alias for evaluate — makes SphereProblem directly callable."""
        return self.evaluate(individual)

    def to_instance(self) -> Instance:
        """Converts this problem back to an Instance (center as variables)."""
        return Instance(variables=self._rng.uniform(self.lbs, self.ubs, size=2))

    def to_file(self, filename: str):
        """Persists the problem centre to a plain text file."""
        raise NotImplementedError("Not implemented here. Sphere is just for testing.")

    def __array__(self, dtype=None, copy: Optional[bool] = None) -> np.ndarray:
        raise NotImplementedError("Not implemented here. Sphere is just for testing.")

    def __repr__(self) -> str:  # pragma: no cover
        raise NotImplementedError("Not implemented here. Sphere is just for testing.")

__call__(individual)

Alias for evaluate — makes SphereProblem directly callable.

Source code in digneapy/domains/sphere.py
62
63
64
def __call__(self, individual: Sequence | Solution | np.ndarray) -> tuple[float]:
    """Alias for evaluate — makes SphereProblem directly callable."""
    return self.evaluate(individual)

create_solution()

Creates a random feasible solution.

Source code in digneapy/domains/sphere.py
50
51
52
53
54
55
56
57
58
59
60
def create_solution(self) -> Solution:
    """Creates a random feasible solution."""
    variables = self._rng.uniform(self._lbs, self._ubs)
    (fitness,) = self.evaluate(variables)
    return Solution(
        variables=variables,
        objectives=[fitness],
        fitness=fitness,
        dtype=np.float64,
        otype=np.float64,
    )

evaluate(individual)

Returns (−sphere_value,) so higher fitness = closer to centre.

Source code in digneapy/domains/sphere.py
42
43
44
45
46
47
48
def evaluate(
    self, individual: Sequence | Solution | np.ndarray
) -> tuple[np.float64]:
    """Returns (−sphere_value,) so higher fitness = closer to centre."""
    x = np.asarray(individual, dtype=np.float64)
    sphere_val = np.float64(np.sum(x**2))
    return (sphere_val,)

to_file(filename)

Persists the problem centre to a plain text file.

Source code in digneapy/domains/sphere.py
70
71
72
def to_file(self, filename: str):
    """Persists the problem centre to a plain text file."""
    raise NotImplementedError("Not implemented here. Sphere is just for testing.")

to_instance()

Converts this problem back to an Instance (center as variables).

Source code in digneapy/domains/sphere.py
66
67
68
def to_instance(self) -> Instance:
    """Converts this problem back to an Instance (center as variables)."""
    return Instance(variables=self._rng.uniform(self.lbs, self.ubs, size=2))

SphereDomain

Bases: Domain

Domain of 2-D shifted sphere problems.

Each instance is a 2-D point (x₀, x₁) that serves as the centre of a sphere. The two coordinates are also the features used as descriptors, so they map directly onto a 2-D GridArchive.

Parameters:
  • dimension (int, default: uint32(2) ) –

    Dimensionality of the sphere. Defaults to 2.

  • lb (float, default: -5.12 ) –

    Lower bound for each variable. Defaults to −5.12.

  • ub (float, default: 5.12 ) –

    Upper bound for each variable. Defaults to +5.12.

  • seed (int | None, default: None ) –

    RNG seed.

Source code in digneapy/domains/sphere.py
 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
class SphereDomain(Domain):
    """Domain of 2-D shifted sphere problems.

    Each *instance* is a 2-D point (x₀, x₁) that serves as the centre of a
    sphere. The two coordinates are also the features used as descriptors,
    so they map directly onto a 2-D GridArchive.

    Args:
        dimension (int): Dimensionality of the sphere. Defaults to 2.
        lb (float): Lower bound for each variable. Defaults to −5.12.
        ub (float): Upper bound for each variable. Defaults to +5.12.
        seed (int | None): RNG seed.
    """

    FEAT_NAMES = ["x0", "x1"]

    def __init__(
        self,
        dimension: np.uint32 = np.uint32(2),
        lb: float = -5.12,
        ub: float = 5.12,
        seed: Optional[int | np.random.SeedSequence] = None,
    ):
        bounds = [(lb, ub)] * dimension
        feat_names = [f"x{i}" for i in range(dimension)]
        super().__init__(
            dimension=dimension,
            bounds=bounds,
            name="Sphere",
            feat_names=feat_names,
            dtype=np.float64,
            seed=seed,
        )

    def generate_instances(self, n: np.uint32 = np.uint32(1)) -> List[Instance]:
        """Generates n random sphere centre points as Instance objects."""
        points = self._rng.uniform(
            low=self._lbs, high=self._ubs, size=(n, self._dimension)
        )
        return [
            Instance(variables=row, otype=np.float64, dtype=np.float64)
            for row in points
        ]

    def generate_problems_from_instances(
        self, instances: Sequence[Instance] | np.ndarray
    ) -> List:
        """Creates one SphereProblem per instance (using variables as centre)."""
        return [Sphere(dimension=2) for _ in instances]

    def extract_features(
        self, instances: Sequence[Instance] | np.ndarray
    ) -> np.ndarray:
        """Returns the raw instance coordinates as features.

        Shape: (n_instances, dimension).
        For a 2-D domain these directly populate a 2-D GridArchive.
        """
        arr = np.asarray(instances, dtype=np.float64)
        if arr.ndim == 1:
            arr = arr.reshape(1, -1)
        return arr.astype(np.float32)

    def extract_features_as_dict(
        self, instances: Sequence[Instance] | np.ndarray
    ) -> List[Dict[str, np.float32]]:
        """Returns a list of {feat_name: value} dicts, one per instance."""
        features = self.extract_features(instances)
        return [
            {name: val for name, val in zip(self.feat_names, row)} for row in features
        ]

extract_features(instances)

Returns the raw instance coordinates as features.

Shape: (n_instances, dimension). For a 2-D domain these directly populate a 2-D GridArchive.

Source code in digneapy/domains/sphere.py
131
132
133
134
135
136
137
138
139
140
141
142
def extract_features(
    self, instances: Sequence[Instance] | np.ndarray
) -> np.ndarray:
    """Returns the raw instance coordinates as features.

    Shape: (n_instances, dimension).
    For a 2-D domain these directly populate a 2-D GridArchive.
    """
    arr = np.asarray(instances, dtype=np.float64)
    if arr.ndim == 1:
        arr = arr.reshape(1, -1)
    return arr.astype(np.float32)

extract_features_as_dict(instances)

Returns a list of {feat_name: value} dicts, one per instance.

Source code in digneapy/domains/sphere.py
144
145
146
147
148
149
150
151
def extract_features_as_dict(
    self, instances: Sequence[Instance] | np.ndarray
) -> List[Dict[str, np.float32]]:
    """Returns a list of {feat_name: value} dicts, one per instance."""
    features = self.extract_features(instances)
    return [
        {name: val for name, val in zip(self.feat_names, row)} for row in features
    ]

generate_instances(n=np.uint32(1))

Generates n random sphere centre points as Instance objects.

Source code in digneapy/domains/sphere.py
115
116
117
118
119
120
121
122
123
def generate_instances(self, n: np.uint32 = np.uint32(1)) -> List[Instance]:
    """Generates n random sphere centre points as Instance objects."""
    points = self._rng.uniform(
        low=self._lbs, high=self._ubs, size=(n, self._dimension)
    )
    return [
        Instance(variables=row, otype=np.float64, dtype=np.float64)
        for row in points
    ]

generate_problems_from_instances(instances)

Creates one SphereProblem per instance (using variables as centre).

Source code in digneapy/domains/sphere.py
125
126
127
128
129
def generate_problems_from_instances(
    self, instances: Sequence[Instance] | np.ndarray
) -> List:
    """Creates one SphereProblem per instance (using variables as centre)."""
    return [Sphere(dimension=2) for _ in instances]

TSP

Bases: Problem

Representation of the Symmetric Travelling Salesman Problem (TSP).

Given a set of cities, each described by a pair of 2D coordinates, the objective is to find the shortest possible tour that visits every city exactly once and returns to the starting city. This implementation uses the Euclidean distance between coordinate pairs as the inter-city travel cost.

A candidate solution is encoded as a sequence of city indices of length N + 1, where N is the number of cities. The first and last elements must both be 0 (the depot / starting city), and every city index from 1 to N-1 must appear exactly once in between.

The objective value is the reciprocal of the total tour length (1 / distance), so higher values correspond to shorter, better tours. Infeasible tours are those that violate the cyclic constraint (meaning that the start and end of the tour should be the node number 0) or those that visit a node more than once.

Source code in digneapy/domains/tsp.py
 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
class TSP(Problem):
    """Representation of the Symmetric Travelling Salesman Problem (TSP).

    Given a set of cities, each described by a pair of 2D coordinates, the objective
    is to find the shortest possible tour that visits every city exactly once and
    returns to the starting city. This implementation uses the Euclidean distance
    between coordinate pairs as the inter-city travel cost.

    A candidate solution is encoded as a sequence of city indices of length N + 1,
    where N is the number of cities. The first and last elements must both be 0
    (the depot / starting city), and every city index from 1 to N-1 must appear
    exactly once in between.

    The objective value is the reciprocal of the total tour length (1 / distance),
    so higher values correspond to shorter, better tours. Infeasible tours are
    those that violate the cyclic constraint (meaning that the start and end of the
    tour should be the node number 0) or those that visit a node more than once.

    """

    def __init__(
        self,
        number_of_nodes: np.uint | int,
        coords: np.ndarray,
        penalty_factor: float | np.float64 = 10.0,
        postpone_dist_comp: bool = False,
        save_distances_as_1d: bool = False,
        seed: Optional[int | np.random.SeedSequence] = None,
        *args,
        **kwargs,
    ):
        """Create a new Symmetric Travelling Salesman Problem instance.

        The full Euclidean distance matrix between all pairs of cities is
        pre-computed during initialisation so that repeated evaluations are
        fast.

        Args:
            number_of_nodes (int): Number of cities (nodes) in the instance. It must
                be a positive integer.
            coords (np.ndarray): A two-dimensional array of shape (N, 2) containing
                the x and y coordinates of each city. If a plain sequence is
                supplied it is automatically converted to a NumPy array.
            penalty_factor (np.float64 | float, optional): Penalisation factor used
                to lower the fitness of unfeasible solutions. Defaults to 10.0.
                matrix. Defaults to False.
            postpone_dist_comp (bool, optional): Boolean flag used to indicate that the
                distance matrix should not be precomputed. When True, distances
                are calculated on-the-fly in evaluate, rather than computing all
                the pairwise distances between nodes and creating a flattened 1D distance
                matrix. Defaults to False.
            save_distances_as_1d (bool, optional): Boolean flag to tell the problem
                that the distance matrix must be stored as a flattened 1d array
                using only the upper right triangular matrix. Defaults to False,
                and stores it as a 2d (number_of_nodes x number_of_nodes) matrix.
            seed (Optional[int | np.random.SeedSequence], optional): Seed used to
                initialise the internal random number generator, which is inherited
                from the parent ``Problem`` class. Defaults to None.

        Raises:
            ValueError: If ``coords`` does not have exactly two columns, i.e., its
                shape is not (N, 2).
        """

        try:
            number_of_nodes = int(number_of_nodes)
        except (TypeError, ValueError) as exc:
            raise ValueError(
                f"invalid number_of_nodes in TSP. Got: {number_of_nodes}"
            ) from exc

        try:
            if not isinstance(coords, np.ndarray):
                coords = np.asarray(coords)

            if coords.shape != (number_of_nodes, 2):
                raise ValueError(
                    f"Expected coordinates shape to be ({number_of_nodes}, 2). "
                    f"Instead coords has the following shape: {coords.shape}."
                )

            self._coordinates = np.asarray(coords, dtype=np.float64)
        except (TypeError, ValueError) as exc:
            raise ValueError from exc

        try:
            self._penalty_factor = float(penalty_factor)
            if self._penalty_factor <= 0.0:
                raise ValueError()
        except (TypeError, ValueError) as exc:
            raise ValueError(
                "penalty_factor must be a valid positive float value. "
                f"Got {penalty_factor}. From: {exc}"
            ) from exc

        x_min, y_min = np.min(self._coordinates, axis=0)
        x_max, y_max = np.max(self._coordinates, axis=0)
        _bounds = list(((x_min, y_min), (x_max, y_max)) for _ in range(number_of_nodes))

        super().__init__(
            dimension=number_of_nodes, bounds=_bounds, name="TSP", seed=seed
        )
        self._distances = np.empty(1)
        if postpone_dist_comp:
            # If we postpone the computation of the distance
            # matrix, we're essentially working the same as
            # such matrix doesn't fit in memory
            self._too_large_to_fit = True

            self._saved_as_1d = False
        else:
            # This is a Symmetric TSP problem
            # we can store only the upper right triangular matrix
            try:
                self._distances = cdist(self._coordinates, self._coordinates)
                self._saved_as_1d = save_distances_as_1d
                if save_distances_as_1d:
                    self._saved_as_1d = True
                    self._distances = self._distances[
                        np.triu_indices(number_of_nodes, k=1)
                    ]
                self._too_large_to_fit = False
            except Exception:
                # The distance matrix is too large to fit in memory
                # we keep the coordinates and compute the distances
                # on the fly each time
                self._too_large_to_fit = True
                self._saved_as_1d = False

    @property
    def coordinates(self) -> np.ndarray:
        return self._coordinates

    @property
    def distances(self) -> np.ndarray:
        if self._too_large_to_fit or self._distances is None:
            warnings.warn(
                "Distance matrix no calculated in TSP "
                f"for {self.dimension} nodes because it doesn't fit in memory or it was postponed."
                "Returning np.empty()",
                RuntimeWarning,
                stacklevel=2,
            )
            return np.empty(0)
        else:
            return self._distances

    def evaluate(
        self, individual: Sequence | Solution | np.ndarray
    ) -> Tuple[float, ...]:
        """Evaluate a candidate tour and compute its objective value.

        The fitness of a feasible solution is the reciprocal of the total Euclidean
        tour length (``1.0 / distance``). This formulation turns the minimisation
        problem into a maximisation problem, which is consistent with the Digneapy
        framework's convention. Fitness, always to maximise.

        Infeasible tours—those that repeat cities are assigned a penalty
        proportional to the number of repetitions.

        If the supplied ``individual`` is a ``Solution`` object, its ``fitness``,
        ``objectives``, and ``constraints`` attributes are updated in-place.

        Args:
            individual (Sequence | Solution | np.ndarray): The candidate tour to
                evaluate. Must have length ``N``, where ``N`` is the number of
                cities or nodes.

        Raises:
            ValueError: If the length of ``individual`` does not equal ``N ``.

        Returns:
            Tuple[float, float]: A two-element tuple containing the objective value
                and the number of duplicated nodes in the inner tour. This second value
                should be zero for a feasible solution, meaning that all nodes are only
                visited once.
        """
        if len(individual) != self.dimension:
            raise ValueError(
                f"Mismatch between individual variables ({len(individual)})"
                f" and instance variables ({self.dimension}) in TSP. "
                f"A solution for the TSP must be a sequence of len {self.dimension} items. "
                f"Instead got {len(individual)}."
            )

        duplicated_count = len(individual) - len(set(individual))

        # to_node shifts left by 1 to wrap the last node with the first one
        from_node = np.asarray(individual)
        to_node = np.roll(np.asarray(individual), -1)

        if self._too_large_to_fit:
            from_coords = self._coordinates[from_node]
            to_coords = self._coordinates[to_node]
            distance = np.sum(
                np.linalg.norm(from_coords - to_coords, axis=1), dtype=np.float64
            )
        else:
            if self._saved_as_1d:
                # Now calculate from the flatted indices to extract
                # the distances from the flattened 1D distance matrix
                i = np.minimum(from_node, to_node)
                j = np.maximum(from_node, to_node)
                flat_indices = i * self.dimension - (i * (i + 1)) // 2 + (j - i - 1)
                distance = np.sum(self._distances[flat_indices], dtype=np.float64)
            else:
                distance = np.sum(self._distances[from_node, to_node], dtype=np.float64)

        if duplicated_count != 0:
            penalty = np.float64(duplicated_count) * distance * self._penalty_factor
            fitness = 1.0 / (distance + penalty)
        else:
            fitness = 1.0 / distance

        try:
            # We assume that individual is a Solution object
            # and in that case we can update its attributes
            individual.fitness = fitness
            individual.objectives = (fitness,)
            individual.constraints = (duplicated_count,)
        except Exception:
            pass

        return (fitness, duplicated_count)

    def __call__(
        self, individual: Sequence | Solution | np.ndarray
    ) -> Tuple[float, ...]:
        """Evaluate a candidate tour and compute its objective value.

        Delegates directly to :meth:`evaluate`. This makes the problem instance
        callable, allowing it to be used wherever a plain function is expected
        (e.g. as an argument to a solver).

        Args:
            individual (Sequence | Solution | np.ndarray): The candidate tour to
                evaluate. Must have length ``N``.

        Returns:
            Tuple[float, float]: A two-element tuple containing the objective value
                and the number of duplicated nodes in the inner tour. This second value
                should be zero for a feasible solution, meaning that all nodes are only
                visited once.
        """
        return self.evaluate(individual)

    def __str__(self):
        return f"TSP(n={self.dimension})"

    def __len__(self):
        return self.dimension

    def __array__(self, dtype=np.float64, copy: Optional[bool] = None) -> np.ndarray:
        """Return a NumPy array representation of the TSP instance.

        The returned array is the (N, 2) coordinate matrix, where each row stores
        the ``[x, y]`` position of one city. This is useful for serialisation and
        for passing the instance to downstream NumPy-based tools.

        Args:
            dtype: NumPy data type for the returned array. Defaults to
                ``np.float64``.
            copy (Optional[bool]): Whether to force a copy of the underlying data.
                Defaults to ``None``.

        Returns:
            npt.ndarray: The coordinate matrix of shape (N, 2).
        """
        return np.asarray(self._coordinates, dtype=dtype, copy=copy)

    def create_solution(self, random: bool = False, start_node: int = 0) -> Solution:
        """Create a trivial initial solution for the TSP.

        The solution visits cities in natural order: 0 → 1 → 2 → … → N-1 → 0.
        This is a feasible but almost certainly non-optimal tour that can be used
        as a starting point for local search or population initialisation.

        Args:
            random(bool): If True, generate a random permutation of nodes
                instead of the natural-order tour. Defaults to False.
            start_node(int): Starting and endind node of the cyclic tour.
                Defaults to standard initial node (0).
        Returns:
            Solution: A ``Solution`` object whose ``variables`` encode the tour,
                with zeroed ``objectives`` and ``constraints`` arrays.
        """
        items = np.zeros(self.dimension, dtype=np.uint32)
        items[0] = start_node
        remaining_nodes = np.array(
            [c for c in range(self.dimension) if c != start_node], dtype=np.uint32
        )
        if random:
            self._rng.shuffle(remaining_nodes)

        items[1:] = remaining_nodes
        fitness, duplicated = self.evaluate(items)
        return Solution(
            variables=items,
            objectives=(fitness,),
            constraints=(duplicated,),
            fitness=fitness,
        )

    def to_file(self, filename: str | Path = "instance.tsp"):
        """Serialise the TSP instance to a plain text file.

        The file will follow this format:
        - Line 1: number of cities.
        - Line 2: blank separator.
        - Lines3+: one city per line as ``x<TAB>y``.

        Args:
            filename (str | Path, optional): Destination path for the serialised instance.
                Defaults to ``"instance.tsp"``.

        Raises:
            RuntimeError: If something goes wrong.
        """
        try:
            with open(filename, "w") as file:
                file.write(f"{len(self)}\n\n")
                content = "\n".join(f"{x}\t{y}" for (x, y) in self._coordinates)
                file.write(content)

        except Exception as exc:
            raise RuntimeError(
                "Something went wrong when saving the TSP object."
            ) from exc

    @classmethod
    def from_file(cls, filename: str | Path) -> Self:
        """Load a TSP instance from a text file produced by :meth:`to_file`.

        The expected file format is:
        - Line 1: number of cities.
        - Line 2: blank separator.
        - Lines 3+: one city per line as ``x<TAB>y``.

        Args:
            filename (str): Path to the file containing the serialised instance.

        Raises:
            RuntimeError: If something goes wrong.

        Returns:
            TSP: A new ``TSP`` object rebuilt from the stored city coordinates.
        """
        try:
            with open(filename) as f:
                lines = f.readlines()
                lines = [line.rstrip() for line in lines]

            nodes = np.uint64(lines[0])
            coordinates = [
                [float(coord) for coord in node.split()] for node in lines[2:]
            ]
            coordinates = np.asarray(coordinates, dtype=np.float64)

            return cls(number_of_nodes=nodes, coords=coordinates)
        except Exception as exc:
            raise RuntimeError(
                f"Something went wrong when loading the TSP object: {exc}"
            ) from exc

    def to_instance(self) -> Instance:
        """Convert the TSP problem into an ``Instance`` object used by Digneapy.

        The instance variables are the coordinate array flattened into a
        one-dimensional sequence: ``[x_0, y_0, x_1, y_1, …, x_{N-1}, y_{N-1}]``.

        Returns:
            Instance: An instance object whose ``variables`` contain the
                interleaved x/y coordinates of all cities.
        """
        return Instance(variables=self._coordinates.flatten(), dtype=np.float64)

__array__(dtype=np.float64, copy=None)

Return a NumPy array representation of the TSP instance.

The returned array is the (N, 2) coordinate matrix, where each row stores the [x, y] position of one city. This is useful for serialisation and for passing the instance to downstream NumPy-based tools.

Parameters:
  • dtype

    NumPy data type for the returned array. Defaults to np.float64.

  • copy (Optional[bool], default: None ) –

    Whether to force a copy of the underlying data. Defaults to None.

Returns:
  • ndarray

    npt.ndarray: The coordinate matrix of shape (N, 2).

Source code in digneapy/domains/tsp.py
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
def __array__(self, dtype=np.float64, copy: Optional[bool] = None) -> np.ndarray:
    """Return a NumPy array representation of the TSP instance.

    The returned array is the (N, 2) coordinate matrix, where each row stores
    the ``[x, y]`` position of one city. This is useful for serialisation and
    for passing the instance to downstream NumPy-based tools.

    Args:
        dtype: NumPy data type for the returned array. Defaults to
            ``np.float64``.
        copy (Optional[bool]): Whether to force a copy of the underlying data.
            Defaults to ``None``.

    Returns:
        npt.ndarray: The coordinate matrix of shape (N, 2).
    """
    return np.asarray(self._coordinates, dtype=dtype, copy=copy)

__call__(individual)

Evaluate a candidate tour and compute its objective value.

Delegates directly to :meth:evaluate. This makes the problem instance callable, allowing it to be used wherever a plain function is expected (e.g. as an argument to a solver).

Parameters:
  • individual (Sequence | Solution | ndarray) –

    The candidate tour to evaluate. Must have length N.

Returns:
  • Tuple[float, ...]

    Tuple[float, float]: A two-element tuple containing the objective value and the number of duplicated nodes in the inner tour. This second value should be zero for a feasible solution, meaning that all nodes are only visited once.

Source code in digneapy/domains/tsp.py
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
def __call__(
    self, individual: Sequence | Solution | np.ndarray
) -> Tuple[float, ...]:
    """Evaluate a candidate tour and compute its objective value.

    Delegates directly to :meth:`evaluate`. This makes the problem instance
    callable, allowing it to be used wherever a plain function is expected
    (e.g. as an argument to a solver).

    Args:
        individual (Sequence | Solution | np.ndarray): The candidate tour to
            evaluate. Must have length ``N``.

    Returns:
        Tuple[float, float]: A two-element tuple containing the objective value
            and the number of duplicated nodes in the inner tour. This second value
            should be zero for a feasible solution, meaning that all nodes are only
            visited once.
    """
    return self.evaluate(individual)

__init__(number_of_nodes, coords, penalty_factor=10.0, postpone_dist_comp=False, save_distances_as_1d=False, seed=None, *args, **kwargs)

Create a new Symmetric Travelling Salesman Problem instance.

The full Euclidean distance matrix between all pairs of cities is pre-computed during initialisation so that repeated evaluations are fast.

Parameters:
  • number_of_nodes (int) –

    Number of cities (nodes) in the instance. It must be a positive integer.

  • coords (ndarray) –

    A two-dimensional array of shape (N, 2) containing the x and y coordinates of each city. If a plain sequence is supplied it is automatically converted to a NumPy array.

  • penalty_factor (float64 | float, default: 10.0 ) –

    Penalisation factor used to lower the fitness of unfeasible solutions. Defaults to 10.0. matrix. Defaults to False.

  • postpone_dist_comp (bool, default: False ) –

    Boolean flag used to indicate that the distance matrix should not be precomputed. When True, distances are calculated on-the-fly in evaluate, rather than computing all the pairwise distances between nodes and creating a flattened 1D distance matrix. Defaults to False.

  • save_distances_as_1d (bool, default: False ) –

    Boolean flag to tell the problem that the distance matrix must be stored as a flattened 1d array using only the upper right triangular matrix. Defaults to False, and stores it as a 2d (number_of_nodes x number_of_nodes) matrix.

  • seed (Optional[int | SeedSequence], default: None ) –

    Seed used to initialise the internal random number generator, which is inherited from the parent Problem class. Defaults to None.

Raises:
  • ValueError

    If coords does not have exactly two columns, i.e., its shape is not (N, 2).

Source code in digneapy/domains/tsp.py
 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
def __init__(
    self,
    number_of_nodes: np.uint | int,
    coords: np.ndarray,
    penalty_factor: float | np.float64 = 10.0,
    postpone_dist_comp: bool = False,
    save_distances_as_1d: bool = False,
    seed: Optional[int | np.random.SeedSequence] = None,
    *args,
    **kwargs,
):
    """Create a new Symmetric Travelling Salesman Problem instance.

    The full Euclidean distance matrix between all pairs of cities is
    pre-computed during initialisation so that repeated evaluations are
    fast.

    Args:
        number_of_nodes (int): Number of cities (nodes) in the instance. It must
            be a positive integer.
        coords (np.ndarray): A two-dimensional array of shape (N, 2) containing
            the x and y coordinates of each city. If a plain sequence is
            supplied it is automatically converted to a NumPy array.
        penalty_factor (np.float64 | float, optional): Penalisation factor used
            to lower the fitness of unfeasible solutions. Defaults to 10.0.
            matrix. Defaults to False.
        postpone_dist_comp (bool, optional): Boolean flag used to indicate that the
            distance matrix should not be precomputed. When True, distances
            are calculated on-the-fly in evaluate, rather than computing all
            the pairwise distances between nodes and creating a flattened 1D distance
            matrix. Defaults to False.
        save_distances_as_1d (bool, optional): Boolean flag to tell the problem
            that the distance matrix must be stored as a flattened 1d array
            using only the upper right triangular matrix. Defaults to False,
            and stores it as a 2d (number_of_nodes x number_of_nodes) matrix.
        seed (Optional[int | np.random.SeedSequence], optional): Seed used to
            initialise the internal random number generator, which is inherited
            from the parent ``Problem`` class. Defaults to None.

    Raises:
        ValueError: If ``coords`` does not have exactly two columns, i.e., its
            shape is not (N, 2).
    """

    try:
        number_of_nodes = int(number_of_nodes)
    except (TypeError, ValueError) as exc:
        raise ValueError(
            f"invalid number_of_nodes in TSP. Got: {number_of_nodes}"
        ) from exc

    try:
        if not isinstance(coords, np.ndarray):
            coords = np.asarray(coords)

        if coords.shape != (number_of_nodes, 2):
            raise ValueError(
                f"Expected coordinates shape to be ({number_of_nodes}, 2). "
                f"Instead coords has the following shape: {coords.shape}."
            )

        self._coordinates = np.asarray(coords, dtype=np.float64)
    except (TypeError, ValueError) as exc:
        raise ValueError from exc

    try:
        self._penalty_factor = float(penalty_factor)
        if self._penalty_factor <= 0.0:
            raise ValueError()
    except (TypeError, ValueError) as exc:
        raise ValueError(
            "penalty_factor must be a valid positive float value. "
            f"Got {penalty_factor}. From: {exc}"
        ) from exc

    x_min, y_min = np.min(self._coordinates, axis=0)
    x_max, y_max = np.max(self._coordinates, axis=0)
    _bounds = list(((x_min, y_min), (x_max, y_max)) for _ in range(number_of_nodes))

    super().__init__(
        dimension=number_of_nodes, bounds=_bounds, name="TSP", seed=seed
    )
    self._distances = np.empty(1)
    if postpone_dist_comp:
        # If we postpone the computation of the distance
        # matrix, we're essentially working the same as
        # such matrix doesn't fit in memory
        self._too_large_to_fit = True

        self._saved_as_1d = False
    else:
        # This is a Symmetric TSP problem
        # we can store only the upper right triangular matrix
        try:
            self._distances = cdist(self._coordinates, self._coordinates)
            self._saved_as_1d = save_distances_as_1d
            if save_distances_as_1d:
                self._saved_as_1d = True
                self._distances = self._distances[
                    np.triu_indices(number_of_nodes, k=1)
                ]
            self._too_large_to_fit = False
        except Exception:
            # The distance matrix is too large to fit in memory
            # we keep the coordinates and compute the distances
            # on the fly each time
            self._too_large_to_fit = True
            self._saved_as_1d = False

create_solution(random=False, start_node=0)

Create a trivial initial solution for the TSP.

The solution visits cities in natural order: 0 → 1 → 2 → … → N-1 → 0. This is a feasible but almost certainly non-optimal tour that can be used as a starting point for local search or population initialisation.

Parameters:
  • random (bool, default: False ) –

    If True, generate a random permutation of nodes instead of the natural-order tour. Defaults to False.

  • start_node (int, default: 0 ) –

    Starting and endind node of the cyclic tour. Defaults to standard initial node (0).

Returns: Solution: A Solution object whose variables encode the tour, with zeroed objectives and constraints arrays.

Source code in digneapy/domains/tsp.py
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
def create_solution(self, random: bool = False, start_node: int = 0) -> Solution:
    """Create a trivial initial solution for the TSP.

    The solution visits cities in natural order: 0 → 1 → 2 → … → N-1 → 0.
    This is a feasible but almost certainly non-optimal tour that can be used
    as a starting point for local search or population initialisation.

    Args:
        random(bool): If True, generate a random permutation of nodes
            instead of the natural-order tour. Defaults to False.
        start_node(int): Starting and endind node of the cyclic tour.
            Defaults to standard initial node (0).
    Returns:
        Solution: A ``Solution`` object whose ``variables`` encode the tour,
            with zeroed ``objectives`` and ``constraints`` arrays.
    """
    items = np.zeros(self.dimension, dtype=np.uint32)
    items[0] = start_node
    remaining_nodes = np.array(
        [c for c in range(self.dimension) if c != start_node], dtype=np.uint32
    )
    if random:
        self._rng.shuffle(remaining_nodes)

    items[1:] = remaining_nodes
    fitness, duplicated = self.evaluate(items)
    return Solution(
        variables=items,
        objectives=(fitness,),
        constraints=(duplicated,),
        fitness=fitness,
    )

evaluate(individual)

Evaluate a candidate tour and compute its objective value.

The fitness of a feasible solution is the reciprocal of the total Euclidean tour length (1.0 / distance). This formulation turns the minimisation problem into a maximisation problem, which is consistent with the Digneapy framework's convention. Fitness, always to maximise.

Infeasible tours—those that repeat cities are assigned a penalty proportional to the number of repetitions.

If the supplied individual is a Solution object, its fitness, objectives, and constraints attributes are updated in-place.

Parameters:
  • individual (Sequence | Solution | ndarray) –

    The candidate tour to evaluate. Must have length N, where N is the number of cities or nodes.

Raises:
  • ValueError

    If the length of individual does not equal N.

Returns:
  • Tuple[float, ...]

    Tuple[float, float]: A two-element tuple containing the objective value and the number of duplicated nodes in the inner tour. This second value should be zero for a feasible solution, meaning that all nodes are only visited once.

Source code in digneapy/domains/tsp.py
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
def evaluate(
    self, individual: Sequence | Solution | np.ndarray
) -> Tuple[float, ...]:
    """Evaluate a candidate tour and compute its objective value.

    The fitness of a feasible solution is the reciprocal of the total Euclidean
    tour length (``1.0 / distance``). This formulation turns the minimisation
    problem into a maximisation problem, which is consistent with the Digneapy
    framework's convention. Fitness, always to maximise.

    Infeasible tours—those that repeat cities are assigned a penalty
    proportional to the number of repetitions.

    If the supplied ``individual`` is a ``Solution`` object, its ``fitness``,
    ``objectives``, and ``constraints`` attributes are updated in-place.

    Args:
        individual (Sequence | Solution | np.ndarray): The candidate tour to
            evaluate. Must have length ``N``, where ``N`` is the number of
            cities or nodes.

    Raises:
        ValueError: If the length of ``individual`` does not equal ``N ``.

    Returns:
        Tuple[float, float]: A two-element tuple containing the objective value
            and the number of duplicated nodes in the inner tour. This second value
            should be zero for a feasible solution, meaning that all nodes are only
            visited once.
    """
    if len(individual) != self.dimension:
        raise ValueError(
            f"Mismatch between individual variables ({len(individual)})"
            f" and instance variables ({self.dimension}) in TSP. "
            f"A solution for the TSP must be a sequence of len {self.dimension} items. "
            f"Instead got {len(individual)}."
        )

    duplicated_count = len(individual) - len(set(individual))

    # to_node shifts left by 1 to wrap the last node with the first one
    from_node = np.asarray(individual)
    to_node = np.roll(np.asarray(individual), -1)

    if self._too_large_to_fit:
        from_coords = self._coordinates[from_node]
        to_coords = self._coordinates[to_node]
        distance = np.sum(
            np.linalg.norm(from_coords - to_coords, axis=1), dtype=np.float64
        )
    else:
        if self._saved_as_1d:
            # Now calculate from the flatted indices to extract
            # the distances from the flattened 1D distance matrix
            i = np.minimum(from_node, to_node)
            j = np.maximum(from_node, to_node)
            flat_indices = i * self.dimension - (i * (i + 1)) // 2 + (j - i - 1)
            distance = np.sum(self._distances[flat_indices], dtype=np.float64)
        else:
            distance = np.sum(self._distances[from_node, to_node], dtype=np.float64)

    if duplicated_count != 0:
        penalty = np.float64(duplicated_count) * distance * self._penalty_factor
        fitness = 1.0 / (distance + penalty)
    else:
        fitness = 1.0 / distance

    try:
        # We assume that individual is a Solution object
        # and in that case we can update its attributes
        individual.fitness = fitness
        individual.objectives = (fitness,)
        individual.constraints = (duplicated_count,)
    except Exception:
        pass

    return (fitness, duplicated_count)

from_file(filename) classmethod

Load a TSP instance from a text file produced by :meth:to_file.

The expected file format is: - Line 1: number of cities. - Line 2: blank separator. - Lines 3+: one city per line as x<TAB>y.

Parameters:
  • filename (str) –

    Path to the file containing the serialised instance.

Raises:
  • RuntimeError

    If something goes wrong.

Returns:
  • TSP( Self ) –

    A new TSP object rebuilt from the stored city coordinates.

Source code in digneapy/domains/tsp.py
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
@classmethod
def from_file(cls, filename: str | Path) -> Self:
    """Load a TSP instance from a text file produced by :meth:`to_file`.

    The expected file format is:
    - Line 1: number of cities.
    - Line 2: blank separator.
    - Lines 3+: one city per line as ``x<TAB>y``.

    Args:
        filename (str): Path to the file containing the serialised instance.

    Raises:
        RuntimeError: If something goes wrong.

    Returns:
        TSP: A new ``TSP`` object rebuilt from the stored city coordinates.
    """
    try:
        with open(filename) as f:
            lines = f.readlines()
            lines = [line.rstrip() for line in lines]

        nodes = np.uint64(lines[0])
        coordinates = [
            [float(coord) for coord in node.split()] for node in lines[2:]
        ]
        coordinates = np.asarray(coordinates, dtype=np.float64)

        return cls(number_of_nodes=nodes, coords=coordinates)
    except Exception as exc:
        raise RuntimeError(
            f"Something went wrong when loading the TSP object: {exc}"
        ) from exc

to_file(filename='instance.tsp')

Serialise the TSP instance to a plain text file.

The file will follow this format: - Line 1: number of cities. - Line 2: blank separator. - Lines3+: one city per line as x<TAB>y.

Parameters:
  • filename (str | Path, default: 'instance.tsp' ) –

    Destination path for the serialised instance. Defaults to "instance.tsp".

Raises:
  • RuntimeError

    If something goes wrong.

Source code in digneapy/domains/tsp.py
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
def to_file(self, filename: str | Path = "instance.tsp"):
    """Serialise the TSP instance to a plain text file.

    The file will follow this format:
    - Line 1: number of cities.
    - Line 2: blank separator.
    - Lines3+: one city per line as ``x<TAB>y``.

    Args:
        filename (str | Path, optional): Destination path for the serialised instance.
            Defaults to ``"instance.tsp"``.

    Raises:
        RuntimeError: If something goes wrong.
    """
    try:
        with open(filename, "w") as file:
            file.write(f"{len(self)}\n\n")
            content = "\n".join(f"{x}\t{y}" for (x, y) in self._coordinates)
            file.write(content)

    except Exception as exc:
        raise RuntimeError(
            "Something went wrong when saving the TSP object."
        ) from exc

to_instance()

Convert the TSP problem into an Instance object used by Digneapy.

The instance variables are the coordinate array flattened into a one-dimensional sequence: [x_0, y_0, x_1, y_1, …, x_{N-1}, y_{N-1}].

Returns:
  • Instance( Instance ) –

    An instance object whose variables contain the interleaved x/y coordinates of all cities.

Source code in digneapy/domains/tsp.py
390
391
392
393
394
395
396
397
398
399
400
def to_instance(self) -> Instance:
    """Convert the TSP problem into an ``Instance`` object used by Digneapy.

    The instance variables are the coordinate array flattened into a
    one-dimensional sequence: ``[x_0, y_0, x_1, y_1, …, x_{N-1}, y_{N-1}]``.

    Returns:
        Instance: An instance object whose ``variables`` contain the
            interleaved x/y coordinates of all cities.
    """
    return Instance(variables=self._coordinates.flatten(), dtype=np.float64)

TSPDomain

Bases: Domain

Domain for synthesising Symmetric TSP instances.

This class generates benchmark TSP instances by sampling city coordinates uniformly within configurable rectangular bounds. It also provides utilities for extracting a rich set of geometric and structural features from the generated instances and for converting raw instance data back into TSP problem objects ready for a solver.

Each generated Instance contains 2 * number_of_nodes variables arranged as interleaved x/y coordinates: x_0, y_0, x_1, y_1, …, x_{N-1}, y_{N-1}.

The eleven descriptive features extracted by this domain are:

+-----+----------------------+-----------------------------------------------+ | # | Name | Description | +=====+======================+===============================================+ | 0 | size | Number of cities (eq. number_of_nodes * 2)| +-----+----------------------+-----------------------------------------------+ | 1 | std_distances | Standard deviation of all pairwise distances | +-----+----------------------+-----------------------------------------------+ | 2 | centroid_x | x coordinate of the geometric centroid | +-----+----------------------+-----------------------------------------------+ | 3 | centroid_y | y coordinate of the geometric centroid | +-----+----------------------+-----------------------------------------------+ | 4 | radius | Mean distance from each city to the centroid | +-----+----------------------+-----------------------------------------------+ | 5 | fraction_distances | Fraction of unique pairwise distances | +-----+----------------------+-----------------------------------------------+ | 6 | area | Bounding-box area of all city coordinates | +-----+----------------------+-----------------------------------------------+ | 7 | variance_nnNds | Variance of normalised nearest-neighbour | | | | distances (top-5) | +-----+----------------------+-----------------------------------------------+ | 8 | variation_nnNds | Coefficient of variation of the normalised | | | | nearest-neighbour distances | +-----+----------------------+-----------------------------------------------+ | 9 | cluster_ratio | Ratio of DBSCAN clusters to number of cities | +-----+----------------------+-----------------------------------------------+ | 10 | mean_cluster_radius | Mean radius of the DBSCAN-identified clusters | +-----+----------------------+-----------------------------------------------+

Source code in digneapy/domains/tsp.py
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
class TSPDomain(Domain):
    """Domain for synthesising Symmetric TSP instances.

    This class generates benchmark TSP instances by sampling city coordinates
    uniformly within configurable rectangular bounds. It also provides utilities
    for extracting a rich set of geometric and structural features from the
    generated instances and for converting raw instance data back into
    ``TSP`` problem objects ready for a solver.

    Each generated ``Instance`` contains ``2 * number_of_nodes`` variables arranged as
    interleaved x/y coordinates: ``x_0, y_0, x_1, y_1, …, x_{N-1}, y_{N-1}``.

    The eleven descriptive features extracted by this domain are:

    +-----+----------------------+-----------------------------------------------+
    | #   | Name                 | Description                                   |
    +=====+======================+===============================================+
    | 0   | size                 | Number of cities (eq. ``number_of_nodes * 2``)|
    +-----+----------------------+-----------------------------------------------+
    | 1   | std_distances        | Standard deviation of all pairwise distances  |
    +-----+----------------------+-----------------------------------------------+
    | 2   | centroid_x           | x coordinate of the geometric centroid        |
    +-----+----------------------+-----------------------------------------------+
    | 3   | centroid_y           | y coordinate of the geometric centroid        |
    +-----+----------------------+-----------------------------------------------+
    | 4   | radius               | Mean distance from each city to the centroid  |
    +-----+----------------------+-----------------------------------------------+
    | 5   | fraction_distances   | Fraction of unique pairwise distances         |
    +-----+----------------------+-----------------------------------------------+
    | 6   | area                 | Bounding-box area of all city coordinates     |
    +-----+----------------------+-----------------------------------------------+
    | 7   | variance_nnNds       | Variance of normalised nearest-neighbour      |
    |     |                      | distances (top-5)                             |
    +-----+----------------------+-----------------------------------------------+
    | 8   | variation_nnNds      | Coefficient of variation of the normalised    |
    |     |                      | nearest-neighbour distances                   |
    +-----+----------------------+-----------------------------------------------+
    | 9   | cluster_ratio        | Ratio of DBSCAN clusters to number of cities  |
    +-----+----------------------+-----------------------------------------------+
    | 10  | mean_cluster_radius  | Mean radius of the DBSCAN-identified clusters |
    +-----+----------------------+-----------------------------------------------+
    """

    features_names = "size,std_distances,centroid_x,centroid_y,radius,fraction_distances,area,variance_nnNds,variation_nnNds,cluster_ratio,mean_cluster_radius".split(
        ","
    )

    def __init__(
        self,
        number_of_nodes: np.uint32 | int = np.uint32(100),
        x_range: Tuple[int, int] = (0, 1000),
        y_range: Tuple[int, int] = (0, 1000),
        seed: Optional[int | np.random.SeedSequence] = None,
    ):
        """Create a new TSPDomain for generating Symmetric TSP instances.

        Args:
            number_of_nodes (np.uint32, optional): Number of cities/nodes in each generated
                instance. Defaults to 100.
            x_range (Tuple[int, int], optional): Inclusive lower and upper bounds
                for the x coordinates of sampled cities, expressed as
                ``(x_min, x_max)``. Defaults to ``(0, 1000)``.
            y_range (Tuple[int, int], optional): Inclusive lower and upper bounds
                for the y coordinates of sampled cities, expressed as
                ``(y_min, y_max)``. Defaults to ``(0, 1000)``.
            seed (Optional[int | np.random.SeedSequence], optional): Seed used to
                initialise the internal random number generator. Defaults to None.

        Raises:
            ValueError: If either ``x_range`` or ``y_range`` does not contain
                exactly two elements.
            ValueError: If ``x_min < 0`` or ``x_max <= x_min``.
            ValueError: If ``y_min < 0`` or ``y_max <= y_min``.
        """
        try:
            if len(x_range) != 2 or len(y_range) != 2:
                raise ValueError(
                    "x_range and y_range must be 2d sequences only to values. "
                    f" Got: x_range = {x_range} and y_range = {y_range}."
                )
            x_min, x_max = x_range
            y_min, y_max = y_range
            if x_min >= x_max:
                raise ValueError(
                    "x_range  must be a 2d sequence (x_min, x_max) "
                    f"where x_min < x_max. Got: x_range {x_range}."
                )
            if y_min >= y_max:
                raise ValueError(
                    "y_range  must be a 2d sequence (y_min, y_max) "
                    f"where y_min < y_max. Got: y_range {y_range}."
                )
        except (TypeError, ValueError) as exc:
            raise ValueError(exc) from exc

        self._x_range = x_range
        self._y_range = y_range
        _bounds = [
            (x_min, x_max) if i % 2 == 0 else (y_min, y_max)
            for i in range(number_of_nodes * 2)
        ]

        super().__init__(
            dimension=number_of_nodes * 2,
            bounds=_bounds,
            domain_name="TSP",
            features_names=self.features_names,
            seed=seed,
        )

    def generate_instances(self, n: np.uint32 | int = np.uint32(1)) -> List[Instance]:
        """Generate a batch of TSP instances by sampling city coordinates at random.

        City x-coordinates are drawn uniformly from ``x_range`` and y-coordinates
        from ``y_range``. Each instance stores the coordinates as a flat vector of
        length ``2 * number_of_nodes`` in the interleaved form
            ``[x_0, y_0, x_1, y_1, …, x_{N-1}, y_{N-1}]``.

        Args:
            n (np.uint32, optional): Number of instances to generate. Defaults to 1.

        Returns:
            List[Instance]: A list of ``n`` ``Instance`` objects, each encoding the
                coordinates of ``dimension`` cities.
        """
        instances = np.empty(shape=(n, self.dimension), dtype=np.float64)
        instances[:, 0::2] = self._rng.uniform(
            low=self._x_range[0],
            high=self._x_range[1],
            size=(n, (self.dimension // 2)),
        )
        instances[:, 1::2] = self._rng.uniform(
            low=self._y_range[0],
            high=self._y_range[1],
            size=(n, (self.dimension // 2)),
        )
        return list(Instance(coords) for coords in instances)

    def generate_problems_from_instances(
        self, instances: Sequence[Instance] | np.ndarray
    ) -> List[TSP]:
        """Create ``TSP`` problem objects from a collection of raw instances.

        Each instance is converted from its flat interleaved representation into
        an (N, 2) coordinate array and wrapped in a ``TSP`` object.
        The instances variables are expected to be in the interleaved format
        ``[x_0, y_0, x_1, y_1, …]`` produced by :meth:`generate_instances`.
        The number of cities is inferred as ``len(instance) // 2``.

        Args:
            instances (Sequence[Instance] | np.ndarray): Collection of instances
                to transform. If not already a NumPy array, it is converted
                automatically.

        Returns:
            List[Problem]: A list containing one ``TSP`` problem per input
                instance, in the same order as the input.
        """
        _instances = np.asarray(instances)
        _n_instances, _total_coordinates = _instances.shape
        _n_nodes = _total_coordinates // 2
        # The coordinates of the instances in the batch are reshaped into a 3d matrix
        # (M, 2N) --> (M, N, 2)
        # M = Number of instances
        # N = Number of nodes per instance
        _coordinates = _instances.reshape(_n_instances, _n_nodes, 2)
        return [
            TSP(
                number_of_nodes=_n_nodes,
                coords=coords,
            )
            for coords in _coordinates
        ]

    def extract_features(
        self, instances: Sequence[Instance] | np.ndarray
    ) -> np.ndarray:
        """Compute an eleven-dimensional feature vector for each supplied instance.

        The features capture the geometric structure of the city layout and are
        intended for use in instance-space analysis and algorithm selection.
        All computations are performed in a vectorised, batch-friendly manner
        where possible; the DBSCAN-based cluster features require a per-instance
        loop.

        Feature descriptions:

        * **size** – Number of cities. Constant across all instances generated
          by the same domain, but included for completeness.
        * **std_distances** – Standard deviation of all pairwise Euclidean
          distances, excluding self-distances. Captures the overall spread of
          city separations.
        * **centroid_x / centroid_y** – The x and y coordinates of the geometric
          centroid (mean position of all cities).
        * **radius** – Mean Euclidean distance from each city to the centroid.
          Indicates how tightly the cities cluster around their centre of mass.
        * **fraction_distances** – The number of unique pairwise distances divided
          by the total number of city pairs ``N*(N-1)/2``. Values close to 1
          indicate that few distances are identical.
        * **area** – Bounding-box area, calculated as
          ``(x_max - x_min) * (y_max - y_min)``.
        * **variance_nnNds** – Variance of the top-5 normalised nearest-neighbour
          distances (normalised by the maximum distance in the instance).
        * **variation_nnNds** – Coefficient of variation (variance / mean) of the
          top-5 normalised nearest-neighbour distances.
        * **cluster_ratio** – Ratio of the number of clusters found by DBSCAN to
          the total number of cities. A low ratio indicates dense clustering; a
          ratio close to 1 indicates that every city forms its own cluster.
        * **mean_cluster_radius** – Average radius of the DBSCAN clusters, where
          each cluster radius is the mean distance of its member cities to the
          cluster centroid.

        Args:
            instances (Sequence[Instance] | np.ndarray): The instances to
                characterise. Each instance must encode ``2 * N`` coordinate
                values in interleaved x/y order.

        Returns:
            np.ndarray: A two-dimensional array of shape
                ``(len(instances), 11)`` where each row contains the feature
                values for the corresponding instance. All values are cast to
                ``np.float64``.
        """
        _instances = np.asarray(instances)
        n_instances_batch = len(_instances)
        n_nodes = len(_instances[0]) // 2

        # The coordinates of the instances in the batch are reshaped into a 3d matrix
        # (M, 2N) --> (M, N, 2)
        # M = Number of instances
        # N = Number of nodes per instance
        coords = np.asarray(_instances).reshape((
            n_instances_batch,
            n_nodes,
            2,
        ))
        xs = coords[:, :, 0]
        ys = coords[:, :, 1]
        areas = (
            (np.max(xs, axis=1) - np.min(xs, axis=1))
            * (np.max(ys, axis=1) - np.min(ys, axis=1))
        ).astype(np.float64)

        # Compute distances for all instances
        distances = np.zeros((n_instances_batch, n_nodes, n_nodes))
        differences = coords[:, :, np.newaxis, :] - coords[:, np.newaxis, :, :]
        distances = np.sqrt(np.sum(differences**2, axis=-1))
        mask = ~np.eye(n_nodes, dtype=bool)
        std_distances = np.std(distances[:, mask], axis=1)

        centroids = np.mean(coords, axis=1)
        expanded_centroids = centroids[:, np.newaxis, :]
        centroids_distances = np.linalg.norm(coords - expanded_centroids, axis=-1)
        radius = np.mean(centroids_distances, axis=1)

        fractions = np.asarray([
            np.unique(d[np.triu_indices_from(d, k=1)]).size
            / (n_nodes * (n_nodes - 1) / 2)
            for d in distances
        ])
        # Top five only
        norm_distances = np.sort(distances, axis=2)[:, :, ::-1][:, :, :5] / np.max(
            distances, axis=(1, 2), keepdims=True
        )

        variance_nnds = np.var(norm_distances, axis=(1, 2))
        variation_nnds = variance_nnds / np.mean(norm_distances, axis=(1, 2))

        cluster_ratio = np.empty(shape=n_instances_batch, dtype=np.float64)
        mean_cluster_radius = np.empty(shape=n_instances_batch, dtype=np.float64)

        for i in range(n_instances_batch):
            scale = np.mean(np.std(coords[i], axis=0))
            eps = 0.2 * scale
            adjacent = distances[i] <= eps
            n_components, labels = connected_components(
                csr_matrix(adjacent), directed=False
            )
            cluster_ratio[i] = n_components / n_nodes

            counts = np.bincount(labels, minlength=n_components)
            cluster_centroids = np.zeros((n_components, 2))
            for dimension in range(2):
                cluster_centroids[:, dimension] = (
                    np.bincount(
                        labels, weights=coords[i][:, dimension], minlength=n_components
                    )
                    / counts
                )
            distances_to_centroid = np.linalg.norm(
                coords[i] - cluster_centroids[labels], axis=1
            )
            radii = (
                np.bincount(
                    labels, weights=distances_to_centroid, minlength=n_components
                )
                / counts
            )
            mean_cluster_radius[i] = np.mean(radii) if radii.size > 0 else 0.0

        return np.column_stack([
            np.full(shape=len(_instances), fill_value=n_nodes),
            std_distances,
            centroids[:, 0],
            centroids[:, 1],
            radius,
            fractions,
            areas,
            variance_nnds,
            variation_nnds,
            cluster_ratio,
            mean_cluster_radius,
        ]).astype(np.float64)

    def extract_features_as_dict(
        self, instances: Sequence[Instance] | np.ndarray
    ) -> List[Dict[str, np.float64]]:
        """Return the extracted features as a list of named dictionaries.

        This is a convenience wrapper around :meth:`extract_features` that pairs
        each numeric value with its human-readable feature name, making the
        output easier to inspect, log, or pass to downstream tools that expect
        labelled data (e.g. ``pandas.DataFrame``).

        Args:
            instances (Sequence[Instance] | np.ndarray): Instances whose features
                should be extracted.

        Returns:
            List[Dict[str, np.float64]]: One dictionary per instance mapping each
                feature name (``size``, ``std_distances``, ``centroid_x``, etc.)
                to its corresponding value.
        """
        features = self.extract_features(instances)
        named_features: list[dict[str, np.float64]] = [{}] * len(features)
        for i, feats in enumerate(features):
            named_features[i] = {k: v for k, v in zip(self.features_names, feats)}
        return named_features

__init__(number_of_nodes=np.uint32(100), x_range=(0, 1000), y_range=(0, 1000), seed=None)

Create a new TSPDomain for generating Symmetric TSP instances.

Parameters:
  • number_of_nodes (uint32, default: uint32(100) ) –

    Number of cities/nodes in each generated instance. Defaults to 100.

  • x_range (Tuple[int, int], default: (0, 1000) ) –

    Inclusive lower and upper bounds for the x coordinates of sampled cities, expressed as (x_min, x_max). Defaults to (0, 1000).

  • y_range (Tuple[int, int], default: (0, 1000) ) –

    Inclusive lower and upper bounds for the y coordinates of sampled cities, expressed as (y_min, y_max). Defaults to (0, 1000).

  • seed (Optional[int | SeedSequence], default: None ) –

    Seed used to initialise the internal random number generator. Defaults to None.

Raises:
  • ValueError

    If either x_range or y_range does not contain exactly two elements.

  • ValueError

    If x_min < 0 or x_max <= x_min.

  • ValueError

    If y_min < 0 or y_max <= y_min.

Source code in digneapy/domains/tsp.py
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
def __init__(
    self,
    number_of_nodes: np.uint32 | int = np.uint32(100),
    x_range: Tuple[int, int] = (0, 1000),
    y_range: Tuple[int, int] = (0, 1000),
    seed: Optional[int | np.random.SeedSequence] = None,
):
    """Create a new TSPDomain for generating Symmetric TSP instances.

    Args:
        number_of_nodes (np.uint32, optional): Number of cities/nodes in each generated
            instance. Defaults to 100.
        x_range (Tuple[int, int], optional): Inclusive lower and upper bounds
            for the x coordinates of sampled cities, expressed as
            ``(x_min, x_max)``. Defaults to ``(0, 1000)``.
        y_range (Tuple[int, int], optional): Inclusive lower and upper bounds
            for the y coordinates of sampled cities, expressed as
            ``(y_min, y_max)``. Defaults to ``(0, 1000)``.
        seed (Optional[int | np.random.SeedSequence], optional): Seed used to
            initialise the internal random number generator. Defaults to None.

    Raises:
        ValueError: If either ``x_range`` or ``y_range`` does not contain
            exactly two elements.
        ValueError: If ``x_min < 0`` or ``x_max <= x_min``.
        ValueError: If ``y_min < 0`` or ``y_max <= y_min``.
    """
    try:
        if len(x_range) != 2 or len(y_range) != 2:
            raise ValueError(
                "x_range and y_range must be 2d sequences only to values. "
                f" Got: x_range = {x_range} and y_range = {y_range}."
            )
        x_min, x_max = x_range
        y_min, y_max = y_range
        if x_min >= x_max:
            raise ValueError(
                "x_range  must be a 2d sequence (x_min, x_max) "
                f"where x_min < x_max. Got: x_range {x_range}."
            )
        if y_min >= y_max:
            raise ValueError(
                "y_range  must be a 2d sequence (y_min, y_max) "
                f"where y_min < y_max. Got: y_range {y_range}."
            )
    except (TypeError, ValueError) as exc:
        raise ValueError(exc) from exc

    self._x_range = x_range
    self._y_range = y_range
    _bounds = [
        (x_min, x_max) if i % 2 == 0 else (y_min, y_max)
        for i in range(number_of_nodes * 2)
    ]

    super().__init__(
        dimension=number_of_nodes * 2,
        bounds=_bounds,
        domain_name="TSP",
        features_names=self.features_names,
        seed=seed,
    )

extract_features(instances)

Compute an eleven-dimensional feature vector for each supplied instance.

The features capture the geometric structure of the city layout and are intended for use in instance-space analysis and algorithm selection. All computations are performed in a vectorised, batch-friendly manner where possible; the DBSCAN-based cluster features require a per-instance loop.

Feature descriptions:

  • size – Number of cities. Constant across all instances generated by the same domain, but included for completeness.
  • std_distances – Standard deviation of all pairwise Euclidean distances, excluding self-distances. Captures the overall spread of city separations.
  • centroid_x / centroid_y – The x and y coordinates of the geometric centroid (mean position of all cities).
  • radius – Mean Euclidean distance from each city to the centroid. Indicates how tightly the cities cluster around their centre of mass.
  • fraction_distances – The number of unique pairwise distances divided by the total number of city pairs N*(N-1)/2. Values close to 1 indicate that few distances are identical.
  • area – Bounding-box area, calculated as (x_max - x_min) * (y_max - y_min).
  • variance_nnNds – Variance of the top-5 normalised nearest-neighbour distances (normalised by the maximum distance in the instance).
  • variation_nnNds – Coefficient of variation (variance / mean) of the top-5 normalised nearest-neighbour distances.
  • cluster_ratio – Ratio of the number of clusters found by DBSCAN to the total number of cities. A low ratio indicates dense clustering; a ratio close to 1 indicates that every city forms its own cluster.
  • mean_cluster_radius – Average radius of the DBSCAN clusters, where each cluster radius is the mean distance of its member cities to the cluster centroid.
Parameters:
  • instances (Sequence[Instance] | ndarray) –

    The instances to characterise. Each instance must encode 2 * N coordinate values in interleaved x/y order.

Returns:
  • ndarray

    np.ndarray: A two-dimensional array of shape (len(instances), 11) where each row contains the feature values for the corresponding instance. All values are cast to np.float64.

Source code in digneapy/domains/tsp.py
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
def extract_features(
    self, instances: Sequence[Instance] | np.ndarray
) -> np.ndarray:
    """Compute an eleven-dimensional feature vector for each supplied instance.

    The features capture the geometric structure of the city layout and are
    intended for use in instance-space analysis and algorithm selection.
    All computations are performed in a vectorised, batch-friendly manner
    where possible; the DBSCAN-based cluster features require a per-instance
    loop.

    Feature descriptions:

    * **size** – Number of cities. Constant across all instances generated
      by the same domain, but included for completeness.
    * **std_distances** – Standard deviation of all pairwise Euclidean
      distances, excluding self-distances. Captures the overall spread of
      city separations.
    * **centroid_x / centroid_y** – The x and y coordinates of the geometric
      centroid (mean position of all cities).
    * **radius** – Mean Euclidean distance from each city to the centroid.
      Indicates how tightly the cities cluster around their centre of mass.
    * **fraction_distances** – The number of unique pairwise distances divided
      by the total number of city pairs ``N*(N-1)/2``. Values close to 1
      indicate that few distances are identical.
    * **area** – Bounding-box area, calculated as
      ``(x_max - x_min) * (y_max - y_min)``.
    * **variance_nnNds** – Variance of the top-5 normalised nearest-neighbour
      distances (normalised by the maximum distance in the instance).
    * **variation_nnNds** – Coefficient of variation (variance / mean) of the
      top-5 normalised nearest-neighbour distances.
    * **cluster_ratio** – Ratio of the number of clusters found by DBSCAN to
      the total number of cities. A low ratio indicates dense clustering; a
      ratio close to 1 indicates that every city forms its own cluster.
    * **mean_cluster_radius** – Average radius of the DBSCAN clusters, where
      each cluster radius is the mean distance of its member cities to the
      cluster centroid.

    Args:
        instances (Sequence[Instance] | np.ndarray): The instances to
            characterise. Each instance must encode ``2 * N`` coordinate
            values in interleaved x/y order.

    Returns:
        np.ndarray: A two-dimensional array of shape
            ``(len(instances), 11)`` where each row contains the feature
            values for the corresponding instance. All values are cast to
            ``np.float64``.
    """
    _instances = np.asarray(instances)
    n_instances_batch = len(_instances)
    n_nodes = len(_instances[0]) // 2

    # The coordinates of the instances in the batch are reshaped into a 3d matrix
    # (M, 2N) --> (M, N, 2)
    # M = Number of instances
    # N = Number of nodes per instance
    coords = np.asarray(_instances).reshape((
        n_instances_batch,
        n_nodes,
        2,
    ))
    xs = coords[:, :, 0]
    ys = coords[:, :, 1]
    areas = (
        (np.max(xs, axis=1) - np.min(xs, axis=1))
        * (np.max(ys, axis=1) - np.min(ys, axis=1))
    ).astype(np.float64)

    # Compute distances for all instances
    distances = np.zeros((n_instances_batch, n_nodes, n_nodes))
    differences = coords[:, :, np.newaxis, :] - coords[:, np.newaxis, :, :]
    distances = np.sqrt(np.sum(differences**2, axis=-1))
    mask = ~np.eye(n_nodes, dtype=bool)
    std_distances = np.std(distances[:, mask], axis=1)

    centroids = np.mean(coords, axis=1)
    expanded_centroids = centroids[:, np.newaxis, :]
    centroids_distances = np.linalg.norm(coords - expanded_centroids, axis=-1)
    radius = np.mean(centroids_distances, axis=1)

    fractions = np.asarray([
        np.unique(d[np.triu_indices_from(d, k=1)]).size
        / (n_nodes * (n_nodes - 1) / 2)
        for d in distances
    ])
    # Top five only
    norm_distances = np.sort(distances, axis=2)[:, :, ::-1][:, :, :5] / np.max(
        distances, axis=(1, 2), keepdims=True
    )

    variance_nnds = np.var(norm_distances, axis=(1, 2))
    variation_nnds = variance_nnds / np.mean(norm_distances, axis=(1, 2))

    cluster_ratio = np.empty(shape=n_instances_batch, dtype=np.float64)
    mean_cluster_radius = np.empty(shape=n_instances_batch, dtype=np.float64)

    for i in range(n_instances_batch):
        scale = np.mean(np.std(coords[i], axis=0))
        eps = 0.2 * scale
        adjacent = distances[i] <= eps
        n_components, labels = connected_components(
            csr_matrix(adjacent), directed=False
        )
        cluster_ratio[i] = n_components / n_nodes

        counts = np.bincount(labels, minlength=n_components)
        cluster_centroids = np.zeros((n_components, 2))
        for dimension in range(2):
            cluster_centroids[:, dimension] = (
                np.bincount(
                    labels, weights=coords[i][:, dimension], minlength=n_components
                )
                / counts
            )
        distances_to_centroid = np.linalg.norm(
            coords[i] - cluster_centroids[labels], axis=1
        )
        radii = (
            np.bincount(
                labels, weights=distances_to_centroid, minlength=n_components
            )
            / counts
        )
        mean_cluster_radius[i] = np.mean(radii) if radii.size > 0 else 0.0

    return np.column_stack([
        np.full(shape=len(_instances), fill_value=n_nodes),
        std_distances,
        centroids[:, 0],
        centroids[:, 1],
        radius,
        fractions,
        areas,
        variance_nnds,
        variation_nnds,
        cluster_ratio,
        mean_cluster_radius,
    ]).astype(np.float64)

extract_features_as_dict(instances)

Return the extracted features as a list of named dictionaries.

This is a convenience wrapper around :meth:extract_features that pairs each numeric value with its human-readable feature name, making the output easier to inspect, log, or pass to downstream tools that expect labelled data (e.g. pandas.DataFrame).

Parameters:
  • instances (Sequence[Instance] | ndarray) –

    Instances whose features should be extracted.

Returns:
  • List[Dict[str, float64]]

    List[Dict[str, np.float64]]: One dictionary per instance mapping each feature name (size, std_distances, centroid_x, etc.) to its corresponding value.

Source code in digneapy/domains/tsp.py
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
def extract_features_as_dict(
    self, instances: Sequence[Instance] | np.ndarray
) -> List[Dict[str, np.float64]]:
    """Return the extracted features as a list of named dictionaries.

    This is a convenience wrapper around :meth:`extract_features` that pairs
    each numeric value with its human-readable feature name, making the
    output easier to inspect, log, or pass to downstream tools that expect
    labelled data (e.g. ``pandas.DataFrame``).

    Args:
        instances (Sequence[Instance] | np.ndarray): Instances whose features
            should be extracted.

    Returns:
        List[Dict[str, np.float64]]: One dictionary per instance mapping each
            feature name (``size``, ``std_distances``, ``centroid_x``, etc.)
            to its corresponding value.
    """
    features = self.extract_features(instances)
    named_features: list[dict[str, np.float64]] = [{}] * len(features)
    for i, feats in enumerate(features):
        named_features[i] = {k: v for k, v in zip(self.features_names, feats)}
    return named_features

generate_instances(n=np.uint32(1))

Generate a batch of TSP instances by sampling city coordinates at random.

City x-coordinates are drawn uniformly from x_range and y-coordinates from y_range. Each instance stores the coordinates as a flat vector of length 2 * number_of_nodes in the interleaved form [x_0, y_0, x_1, y_1, …, x_{N-1}, y_{N-1}].

Parameters:
  • n (uint32, default: uint32(1) ) –

    Number of instances to generate. Defaults to 1.

Returns:
  • List[Instance]

    List[Instance]: A list of n Instance objects, each encoding the coordinates of dimension cities.

Source code in digneapy/domains/tsp.py
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
def generate_instances(self, n: np.uint32 | int = np.uint32(1)) -> List[Instance]:
    """Generate a batch of TSP instances by sampling city coordinates at random.

    City x-coordinates are drawn uniformly from ``x_range`` and y-coordinates
    from ``y_range``. Each instance stores the coordinates as a flat vector of
    length ``2 * number_of_nodes`` in the interleaved form
        ``[x_0, y_0, x_1, y_1, …, x_{N-1}, y_{N-1}]``.

    Args:
        n (np.uint32, optional): Number of instances to generate. Defaults to 1.

    Returns:
        List[Instance]: A list of ``n`` ``Instance`` objects, each encoding the
            coordinates of ``dimension`` cities.
    """
    instances = np.empty(shape=(n, self.dimension), dtype=np.float64)
    instances[:, 0::2] = self._rng.uniform(
        low=self._x_range[0],
        high=self._x_range[1],
        size=(n, (self.dimension // 2)),
    )
    instances[:, 1::2] = self._rng.uniform(
        low=self._y_range[0],
        high=self._y_range[1],
        size=(n, (self.dimension // 2)),
    )
    return list(Instance(coords) for coords in instances)

generate_problems_from_instances(instances)

Create TSP problem objects from a collection of raw instances.

Each instance is converted from its flat interleaved representation into an (N, 2) coordinate array and wrapped in a TSP object. The instances variables are expected to be in the interleaved format [x_0, y_0, x_1, y_1, …] produced by :meth:generate_instances. The number of cities is inferred as len(instance) // 2.

Parameters:
  • instances (Sequence[Instance] | ndarray) –

    Collection of instances to transform. If not already a NumPy array, it is converted automatically.

Returns:
  • List[TSP]

    List[Problem]: A list containing one TSP problem per input instance, in the same order as the input.

Source code in digneapy/domains/tsp.py
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
def generate_problems_from_instances(
    self, instances: Sequence[Instance] | np.ndarray
) -> List[TSP]:
    """Create ``TSP`` problem objects from a collection of raw instances.

    Each instance is converted from its flat interleaved representation into
    an (N, 2) coordinate array and wrapped in a ``TSP`` object.
    The instances variables are expected to be in the interleaved format
    ``[x_0, y_0, x_1, y_1, …]`` produced by :meth:`generate_instances`.
    The number of cities is inferred as ``len(instance) // 2``.

    Args:
        instances (Sequence[Instance] | np.ndarray): Collection of instances
            to transform. If not already a NumPy array, it is converted
            automatically.

    Returns:
        List[Problem]: A list containing one ``TSP`` problem per input
            instance, in the same order as the input.
    """
    _instances = np.asarray(instances)
    _n_instances, _total_coordinates = _instances.shape
    _n_nodes = _total_coordinates // 2
    # The coordinates of the instances in the batch are reshaped into a 3d matrix
    # (M, 2N) --> (M, N, 2)
    # M = Number of instances
    # N = Number of nodes per instance
    _coordinates = _instances.reshape(_n_instances, _n_nodes, 2)
    return [
        TSP(
            number_of_nodes=_n_nodes,
            coords=coords,
        )
        for coords in _coordinates
    ]