Coverage for moptipy / spaces / ordered_choices.py: 92%

89 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2025-11-24 08:49 +0000

1""" 

2Permutations of a selection (or ordered choices) as strings. 

3 

4Imagine the following situation: We have a rectangle (two-dimensional) and 

5want to place `n` other, smaller rectangles into it. Let each rectangle have 

6a unique ID from `1` to `n`. We can use a permutation of `1..n` to represent 

7the order in which we insert the small rectangles. However, maybe we want to 

8also be able to rotate a rectangle by 90° and then place it. So additionally 

9to the insertion order, we may want to remember whether the rectangle is 

10placed as-is or rotated. We could do this with a "permutation of choices", 

11where we can place either `i` or `-i` for `i` from `1..n`. A negative value 

12could mean "insert rotated by 90°" whereas a positive value means "insert as 

13is". So we have `n` (disjoint) choices, each of which with two options. From 

14each choice, we can pick one value. Then the order in which the values appear 

15marks the insertion order. So this is basically a "super space" of 

16permutations, as it deals both with the order of the elements and their values 

17(resulting from the selected choice). 

18 

19A string consists of `n` elements. There also are `n` so-called "selections," 

20each of which being a set offering a choice from different values. Any two 

21selections must either be entirely disjoint or equal. The final string must 

22contain one value from each selection. 

23 

24Let's say that we have three selections, e.g., `[1, 2, 3]`, `[4, 5]`, and 

25`[6]`. Then the "selection permutations" space contains, e.g., the string 

26`[4, 3, 6]` or `[2, 5, 6]` -- one value from each selection. It does not 

27contain `[1, 3, 5]`, though, because that string has two values from the first 

28selection. 

29 

30This space is a super space of the :mod:`~moptipy.spaces.permutations`, i.e., 

31the space of permutations with repetitions. Sometimes (check 

32:meth:`OrderedChoices.is_compatible_with_permutations`), the search 

33operators defined in package :mod:`~moptipy.operators.permutations` can also 

34be applied to the elements of our space here, although they may not be able 

35to explore the space in-depth (as they will not alter the choices and only 

36permute the chosen elements). 

37""" 

38from collections import Counter 

39from math import factorial 

40from typing import Final, Iterable 

41 

42import numpy as np 

43from pycommons.types import type_error 

44 

45from moptipy.spaces.intspace import IntSpace 

46from moptipy.utils.logger import CSV_SEPARATOR, KeyValueLogSection 

47from moptipy.utils.nputils import array_to_str 

48 

49#: the different choices 

50KEY_CHOICES: Final[str] = "choices" 

51 

52 

53class OrderedChoices(IntSpace): 

54 """Permutations of selections, stored as :class:`numpy.ndarray`.""" 

55 

56 def __init__(self, selections: Iterable[Iterable[int]]) -> None: 

57 """ 

58 Create the space of permutations of selections. 

59 

60 :param selections: an iterable of selections 

61 :raises TypeError: if one of the parameters has the wrong type 

62 :raises ValueError: if the parameters have the wrong value 

63 """ 

64 if not isinstance(selections, Iterable): 

65 raise type_error(selections, "selections", Iterable) 

66 

67 sets: Final[dict[int, tuple[int, ...]]] = {} 

68 

69 min_v: int = 0 

70 max_v: int = 0 

71 total: int = 0 

72 counts: Counter[tuple[int, ...]] = Counter() 

73 blueprint: Final[list[int]] = [] 

74 

75 for i, sel in enumerate(selections): 

76 total += 1 

77 if not isinstance(sel, Iterable): 

78 raise type_error(sel, f"selections[{i}]", Iterable) 

79 sel_lst = tuple(sorted(sel)) 

80 len_sel = len(sel_lst) 

81 if len_sel <= 0: 

82 raise ValueError(f"empty selection at index {i}: {sel}.") 

83 sel_set = set(sel_lst) 

84 if len(sel_set) != len_sel: 

85 raise ValueError(f"invalid selection {sel} at index {i} " 

86 f"contains duplicate element") 

87 blueprint.append(sel_lst[0]) 

88 # build the selection map 

89 for e in sel_lst: 

90 if not isinstance(e, int): 

91 raise type_error(e, f"selections[{i}]={sel}", int) 

92 if e in sets: 

93 lst_found = sets[e] 

94 if lst_found != sel_lst: 

95 raise ValueError( 

96 f"value {e} appears both in selection {sel_lst} " 

97 f"(at permutation index {i}) and in selection " 

98 f"{lst_found}!") 

99 sel_lst = lst_found 

100 continue # if any sets[e] != sel_lst, we get error anyway 

101 sets[e] = sel_lst # remember value 

102 counts[sel_lst] += 1 

103 

104 # update the value range 

105 if total <= 1: 

106 min_v = sel_lst[0] 

107 max_v = sel_lst[-1] 

108 else: 

109 min_v = min(min_v, sel_lst[0]) 

110 max_v = max(max_v, sel_lst[-1]) 

111 

112 if total <= 0: 

113 raise ValueError( 

114 "there must be at least one selection, " 

115 f"but got {selections}.") 

116 

117 # creating super class 

118 super().__init__(total, min_v, max_v) 

119 

120 #: the selector map 

121 self.choices: Final[dict[int, tuple[int, ...]]] = sets 

122 #: how often does each element choice appear? 

123 self.__counts: Final[dict[tuple[int, ...], int]] = { 

124 k: counts[k] for k in sorted(counts.keys())} 

125 blueprint.sort() 

126 

127 #: The blueprint array, i.e., an ordered array holding the smallest 

128 #: value possible for each choice. 

129 self.blueprint: Final[np.ndarray] = np.array( 

130 blueprint, dtype=self.dtype) 

131 

132 def log_parameters_to(self, logger: KeyValueLogSection) -> None: 

133 """ 

134 Log the parameters of this space to the given logger. 

135 

136 :param logger: the logger for the parameters 

137 """ 

138 super().log_parameters_to(logger) 

139 

140 strings: Final[list[str]] = [] 

141 for sel, count in self.__counts.items(): 

142 s = CSV_SEPARATOR.join(map(str, sel)) 

143 strings.append(s if count <= 1 else (s + "*" + str(count))) 

144 logger.key_value(KEY_CHOICES, "/".join(strings)) 

145 

146 def create(self) -> np.ndarray: 

147 r""" 

148 Create an ordered choice, equal to :attr:`~OrderedChoices.blueprint`. 

149 

150 :return: the ordered choices, an instance of :class:`numpy.ndarray`. 

151 

152 >>> perm = OrderedChoices([[1, 3], [2, 4], [1, 3], [7, 5]]) 

153 >>> x = perm.create() 

154 >>> print(perm.to_str(x)) 

155 1;1;2;5 

156 """ 

157 return self.blueprint.copy() # Create copy of the blueprint. 

158 

159 def validate(self, x: np.ndarray) -> None: 

160 """ 

161 Validate a permutation of selections. 

162 

163 :param x: the integer string 

164 :raises TypeError: if the string is not an :class:`numpy.ndarray`. 

165 :raises ValueError: if the element is not a valid permutation of the 

166 given choices 

167 """ 

168 super().validate(x) 

169 

170 usage: Final[Counter[tuple[int, ...]]] = Counter() 

171 dct: Final[dict[int, tuple[int, ...]]] = self.choices 

172 for j, i in enumerate(x): 

173 if i not in dct: 

174 raise ValueError( 

175 f"invalid element {i} encountered at index" 

176 f" {j} of string {array_to_str(x)}.") 

177 usage[dct[i]] += 1 

178 counts: Final[dict[tuple[int, ...], int]] = self.__counts 

179 for tup, cnt in usage.items(): 

180 expected = counts[tup] 

181 if expected != cnt: 

182 raise ValueError( 

183 f"encountered value from {tup} exactly {cnt} times " 

184 f"instead of the expected {expected} times in {x}") 

185 

186 def n_points(self) -> int: 

187 """ 

188 Get the number of possible different permutations of the choices. 

189 

190 :return: [factorial(dimension) / Product(factorial(count(e)) 

191 for all e)] * Product(len(e)*count(e) for all e) 

192 """ 

193 # get the basic permutation numbers now multiply them with the choices 

194 size = factorial(self.dimension) 

195 for lst, cnt in self.__counts.items(): 

196 size //= factorial(cnt) 

197 size *= len(lst) ** cnt 

198 return size 

199 

200 def __str__(self) -> str: 

201 """ 

202 Get the name of this space. 

203 

204 :return: "selperm{n}", where {n} is the length 

205 """ 

206 return f"selperm{len(self.blueprint)}" 

207 

208 def is_compatible_with_permutations(self) -> bool: 

209 """ 

210 Check whether for compatibility with permutations with repetitions. 

211 

212 Or, in other words, check whether the operators in package 

213 :mod:`~moptipy.operators.permutations` can safely be applied for 

214 elements of this space here. 

215 

216 Permutations with repetitions are permutations where each element 

217 occurs exactly a given number of times. Our implementation of this 

218 space (:mod:`~moptipy.spaces.permutations`) ensures that there are 

219 at least two different elements. The unary and binary search 

220 operators defined in package :mod:`~moptipy.operators.permutations` 

221 rely on this fact. While these operators cannot explore the depth 

222 of the permutations-of-selections space here, they can be "compatible" 

223 to this space: Any element of the permutation-of-selections space is, 

224 by definition, a permutation with repetitions, as it contains one 

225 concrete manifestation per choice. Applying, for instance, a 

226 :mod:`~moptipy.operators.permutations.op1_swap2` operation to it, 

227 which swaps two different elements, still yields a valid and different 

228 permutation-of-selections. However, since the operators in 

229 :mod:`~moptipy.operators.permutations` always enforce that the 

230 resulting point is different from their input and *only* permute the 

231 elements, this can only work if we have at least two disjoint choices 

232 in our space definition. The function here checks this. 

233 

234 :returns: `True` if and only if the operators in package 

235 :mod:`~moptipy.operators.permutations` can safely be applied to 

236 elements of this space 

237 """ 

238 return len(self.__counts) > 1 

239 

240 @staticmethod 

241 def signed_permutations(n: int) -> "OrderedChoices": 

242 """ 

243 Create a space for signed permutations with values `1..n`. 

244 

245 You would be much better off using 

246 :mod:`~moptipy.spaces.signed_permutations` instead of this space for 

247 signed permutations, though. 

248 

249 :param n: the range of the values 

250 :returns: the permutations space 

251 

252 >>> perm = OrderedChoices.signed_permutations(3) 

253 >>> perm.validate(perm.blueprint) 

254 >>> print(perm.blueprint) 

255 [-3 -2 -1] 

256 >>> print(perm.n_points()) # 3! * (2 ** 3) = 6 * 8 = 48 

257 48 

258 >>> perm = OrderedChoices.signed_permutations(4) 

259 >>> perm.validate(perm.blueprint) 

260 >>> print(perm.blueprint) 

261 [-4 -3 -2 -1] 

262 >>> print(perm.n_points()) # 4! * (2 ** 4) = 24 * 16 = 384 

263 384 

264 """ 

265 return OrderedChoices([-i, i] for i in range(1, n + 1))