diff --git a/examples/order1_from_dat.py b/examples/order1_from_dat.py new file mode 100644 index 00000000..810cc9dc --- /dev/null +++ b/examples/order1_from_dat.py @@ -0,0 +1,174 @@ +""" +Find a reasonable one-dimensional order for permutations. + +The input format of this program are `dat` files of the format +``` +EVALS GENOTYPE FITNESS +1 [22, 7, 6, 26, 27, 19, 3, 1, ... 5, 21, 8, 17, 2, 16, 9, 23] 87018 +13 [20, 7, 6, 26, 18, 19, 9, 1, ... 25, 13, 23, 16, 15, 24] 85456 +20 [20, 7, 18, 26, 6, 16, 9, 1, ... 21, 13, 12, 19, 15, 17] 84152 +29 [20, 11, 14, 25, 5, 16, 15, 1, ... 21, 13, 12, 9, 19, 17] 83180 +32 [20, 10, 14, 25, 5, 12, 15, 1, ... 17, 13, 16, 9, 19, 21] 82846 +34 [20, 15, 14, 25, 5, 12, 10, 1, ... 6, 17, 13, 16, 9, 19, 21] 78204 +``` +""" + +import argparse +from os import listdir +from os.path import basename, isdir, isfile, join +from re import Pattern +from re import compile as re_compile +from typing import Any, Callable, Final + +import numpy as np +from moptipy.algorithms.so.rls import RLS +from moptipy.api.execution import Execution +from moptipy.operators.permutations.op0_shuffle import Op0Shuffle +from moptipy.operators.permutations.op1_swap2 import Op1Swap2 +from moptipy.utils.console import logger +from moptipy.utils.help import argparser +from moptipy.utils.path import Path +from moptipy.utils.types import check_to_int_range + +from moptipyapps.order1d.distances import swap_distance +from moptipyapps.order1d.instance import Instance +from moptipyapps.order1d.objective import OneDimensionalDistribution +from moptipyapps.order1d.space import OrderingSpace + + +def parse_data(path: str, collector: Callable[[ + tuple[str, int, int, np.ndarray]], Any], + fitness_limit: int, pattern: Pattern) -> None: + """ + Parse a dat file. + + :param path: the path + :param collector: the collector function to invoke when loading data + :param fitness_limit: the minimum acceptable fitness + :param pattern: the file name pattern + """ + the_path: Final[Path] = Path.path(path) + if isdir(the_path): # recursively parse directories + logger(f"recursing into directory '{the_path}'.") + for subpath in listdir(the_path): + parse_data(join(the_path, subpath), collector, fitness_limit, + pattern) + return + + if not isfile(the_path): + return # if it is not a file, we quit + the_name: Final[str] = basename(the_path) + if not pattern.match(the_name): + return # file does not match + + # parse the file + for oline in the_path.open_for_read(): + line = oline.strip() + if len(line) <= 0: + continue + bracket_open: int = line.find("[") + if bracket_open <= 0: + continue + bracket_close: int = line.find("]", bracket_open + 1) + if bracket_close <= bracket_open: + continue + f: int = check_to_int_range(line[bracket_close + 1:], + "fitness", 0, 1_000_000_000_000) + if f > fitness_limit: + continue + evals: int = check_to_int_range(line[:bracket_open].strip(), + "evals", 1, 1_000_000_000_000_000) + perm: list[int] = [ + check_to_int_range(s, "perm", 1, 1_000_000_000) - 1 + for s in line[bracket_open + 1:bracket_close].split(",")] + collector((the_name, evals, f, np.array(perm))) + + +def get_tags(data: tuple[str, int, int, np.ndarray]) -> tuple[str, str, str]: + """ + Get the tags to store along with the data. + + :param data: the data + :return: the tags + """ + return data[0], str(data[1]), str(data[2]) + + +def get_distance(a: tuple[str, int, int, np.ndarray], + b: tuple[str, int, int, np.ndarray]) -> int: + """ + Get the distance between two data elements. + + The distance here is the swap distance. + + :param a: the first element + :param b: the second element + :return: the swap distance + """ + return swap_distance(a[3], b[3]) + + +def run(source: str, dest: str, max_fes: int = 1_000_000, + fitness_limit: int = 1_000_000_000, + file_name_regex: str = ".*") -> None: + """ + Run the RLS algorithm to optimize a horizontal layout permutation. + + :param source: the source file or directory + :param dest: the destination file + :param max_fes: the maximum FEs + :param fitness_limit: the minimum acceptable fitness + :param file_name_regex: the file name regular expression + """ + logger(f"invoked program with source='{source}', dest='{dest}', " + f"max_fes={max_fes}, fitness_limit={fitness_limit}, and " + f"file_name_regex='{file_name_regex}'.") + # first, we load all the data to construct a distance rank matrix + pattern: Final[Pattern] = re_compile(file_name_regex) + logger(f"now loading data from '{source}' matching to '{pattern}'.") + + data: list[tuple[str, int, int, np.ndarray]] = [] + parse_data(source, data.append, fitness_limit, pattern) + logger(f"finished loading {len(data)} rows of data, " + "now constructing distance rank matrix.") + instance: Final[Instance] = Instance.from_sequence_and_distance( + data, get_tags, get_distance) + del data # free the now useless data + + # run the algorithm + logger(f"finished constructing matrix with {len(instance)} rows, " + "now doing optimization for " + f"{max_fes} FEs and writing result to '{dest}'.") + space: Final[OrderingSpace] = OrderingSpace(instance) + with (Execution().set_solution_space(space) + .set_objective(OneDimensionalDistribution(instance)) + .set_algorithm(RLS(Op0Shuffle(space), Op1Swap2())) + .set_max_fes(max_fes) + .set_log_improvements(True) + .set_log_file(dest).execute()): + pass + logger("all done.") + + +# Perform the optimization +if __name__ == "__main__": + parser: Final[argparse.ArgumentParser] = argparser( + __file__, "One-Dimensional Ordering of Permutations", + "Run the one-dimensional order of permutations experiment.") + parser.add_argument( + "source", help="the directory or file with the input data", + type=Path.path, nargs="?", default="./") + parser.add_argument( + "dest", help="the file to write the output to", + type=Path.path, nargs="?", default="./result.txt") + parser.add_argument("fitnessLimit", help="the minimum acceptable fitness", + type=int, nargs="?", default=1_000_000_000) + parser.add_argument("maxFEs", help="the maximum FEs to perform", + type=int, nargs="?", default=1_000_000) + parser.add_argument( + "fileNameRegEx", + help="a regular expression that file names must match", + type=str, nargs="?", default=".*") + args: Final[argparse.Namespace] = parser.parse_args() + run(args.source, args.dest, args.maxFEs, args.fitnessLimit, + args.fileNameRegEx) diff --git a/moptipyapps/order1d/__init__.py b/moptipyapps/order1d/__init__.py new file mode 100644 index 00000000..bdaee176 --- /dev/null +++ b/moptipyapps/order1d/__init__.py @@ -0,0 +1,28 @@ +""" +A set of tools for ordering objects in 1 dimension. + +Let's assume that we have `n` objects and a distance metric that can compute +the distance between two objects. We do not know and also do not care about in +how many dimension the objects exist - we just have objects and a distance +metric. + +Now we want to find a one-dimensional order of the objects that reflects their +original distance-based topology. For each object `a`, we want that its +closest neighbor in the order is also its actual closest neighbor according to +the distance metric. It's second-closest neighbor should be the actual +second-closest neighbor according to the distance metric. And so on. + +Since we only care about the object order and do not want to metrically map +the distances to one dimension, we can represent the solution as permutation +of natural numbers. + +Of course, in a one-dimensional order, each object has exactly two closest +neighbors (the one on its left and the one on its right) unless it is situated +either at the beginning or end of the order, in which case it has exactly one +closest neighbor. Based on the actual distance metric, an object may have any +number of closest neighbors, maybe only one, or maybe three equally-far away +objects. So it is not clear whether a perfect mapping to the one-dimensional +permutations even exists. + +But we can try to find one that comes as close as possible to the real deal. +""" diff --git a/moptipyapps/order1d/distances.py b/moptipyapps/order1d/distances.py new file mode 100644 index 00000000..2a7a25d4 --- /dev/null +++ b/moptipyapps/order1d/distances.py @@ -0,0 +1,65 @@ +"""Some examples for distance metrics.""" + +from typing import Final + +import numba # type: ignore +import numpy as np +from moptipy.utils.nputils import DEFAULT_BOOL + + +@numba.njit(cache=True, inline="always", fastmath=True, boundscheck=False) +def swap_distance(p1: np.ndarray, p2: np.ndarray) -> int: + """ + Compute the swap distance between two permutations `p1` and `p1`. + + This is the minimum number of swaps required to translate `p1` to `p2` and + vice versa. This function is symmatric. + + An upper bound for the number of maximum number of swaps that could be + required is the length of the permutation. This upper bound can be derived + from Selection Sort. Imagine that I want to translate the array `p1` to + `p2`. I go through `p1` from beginning to end. If, at index `i`, I find + the right element (`p1[i] == p2[i]`), then I do nothing. If not, then the + right element must come at some index `j>i` (because all elements before I + already have fixed). So I swap `p1[i]` with `p1[j]`. Now `p1[i] == p2[i]` + and I increment `i`. Once I arrive at the end of `p1`, it must hold that + `all(p1[i] == p2[i])`. At the same time, I have performed at most one swap + at each index during the iteration. Hence, I can never need more swaps + than the arrays are long. + + :param p1: the first permutation + :param p2: the second permutation + :return: the swap distance, always between `0` and `len(p1)` + + >>> swap_distance(np.array([0, 1, 2, 3]), np.array([3, 1, 2, 0])) + 1 + >>> swap_distance(np.array([0, 1, 2]), np.array([0, 1, 2])) + 0 + >>> swap_distance(np.array([1, 0, 2]), np.array([0, 1, 2])) + 1 + >>> swap_distance(np.array([0, 1, 2]), np.array([1, 0, 2])) + 1 + >>> swap_distance(np.array([0, 1, 2]), np.array([2, 0, 1])) + 2 + >>> swap_distance(np.array([2, 0, 1]), np.array([0, 1, 2])) + 2 + >>> swap_distance(np.arange(10), np.array([4, 8, 1, 5, 9, 3, 6, 0, 7, 2])) + 7 + >>> swap_distance(np.array([4, 8, 1, 5, 9, 3, 6, 0, 7, 2]), np.arange(10)) + 7 + """ + n: Final[int] = len(p1) + x: np.ndarray = p2[np.argsort(p1)] + unchecked: np.ndarray = np.ones(n, DEFAULT_BOOL) + result: int = 0 + + for i in range(n): + if unchecked[i]: + result += 1 + unchecked[i] = False + j = x[i] + while j != i: + unchecked[j] = False + j = x[j] + + return n - result diff --git a/moptipyapps/order1d/instance.py b/moptipyapps/order1d/instance.py new file mode 100644 index 00000000..5ece8692 --- /dev/null +++ b/moptipyapps/order1d/instance.py @@ -0,0 +1,219 @@ +"""An instance of the ordering problem.""" + + +from typing import Callable, Final, Iterable, TypeVar, cast + +import numpy as np +from moptipy.api.component import Component +from moptipy.utils.nputils import int_range_to_dtype +from moptipy.utils.types import check_int_range, check_to_int_range, type_error +from scipy.stats import rankdata # type: ignore + +#: the type variable for the source object +S = TypeVar("S") +#: the type variable for the object +OBJ = TypeVar("OBJ") + + +class Instance(Component, np.ndarray): + """ + An instance of the One-Dimensional Ordering Problem. + + Such an instance represents the ranking of objects by their distance to + each other. If we have `n` objects, then we compute the distance of each + object to each of its `n-1` neighbors. The distance of an object to itself + is `0`. Let's say that we have the three numbers `1, 2, 3` and the + distance be their absolute difference. Then `1` has distance `1` to `2` + and distance `2` to `3`. For `2`, the distance to `1` is `1` and to `3` is + also `1`. We compute the average rank of the objects to each other on a + per-row basis (including the object itself). For object `1`, this gives us + `(1, 2, 3)` and for object `2`, it gives `(2.5, 1, 2.5)`, the latter + because `1` and `3` are equally far from `2`. We multiply this with 2 to + get integer ranks and subtract 1, i.e., we would get + `[[1, 3, 5], [4, 1, 4], [5, 3, 1]]` for our three numbers. + + >>> def _dist(a, b): + ... return abs(a - b) + >>> def _tags(a): + ... return f"t{a}" + >>> the_instance = Instance.from_sequence_and_distance( + ... [1, 2, 3], _tags, _dist) + >>> print(np.array(the_instance)) + [[1 3 5] + [4 1 4] + [5 3 1]] + + However, it is possible that some objects appear twice or have zero + distance. In this case, they will be purged from the matrix: + + >>> the_instance = Instance.from_sequence_and_distance( + ... [1, 2, 3, 3, 2, 3], _tags, _dist) + >>> print(np.array(the_instance)) + [[1 3 5] + [4 1 4] + [5 3 1]] + + But they will still be visible in the tags: + + >>> print(the_instance.tags) + ((('t1',), 0), (('t2',), 1), (('t2',), 1), (('t3',), 2), \ +(('t3',), 2), (('t3',), 2)) + """ + + #: the assignment of string tags to object IDs + tags: tuple[tuple[tuple[str, ...], int], ...] + + def __new__(cls, matrix: np.ndarray, + tags: Iterable[tuple[Iterable[str] | str, int]]) -> "Instance": + """ + Create an instance of the one-dimensional ordering problem. + + :param cls: the class + :param matrix: the matrix with the rank data (will be copied) + :param tags: the assignment of rows to names + """ + n: Final[int] = len(matrix) + use_shape: Final[tuple[int, int]] = (n, n) + if isinstance(matrix, np.ndarray): + if matrix.shape != use_shape: + raise ValueError( + f"Unexpected shape {matrix.shape} for {n} objects, " + f"expected {use_shape}.") + else: + raise type_error(matrix, "matrix", np.ndarray) + + obj: Final[Instance] = super().__new__( + cls, use_shape, int_range_to_dtype(min_value=-n, max_value=n)) + np.copyto(obj, matrix, "unsafe") + for i in range(n): + for j in range(n): + if check_to_int_range( + obj[i, j], "element", 0, (2 * n) - 1) != matrix[i, j]: + raise ValueError( + f"error when copying: {obj[i, j]} != {matrix[i, j]}") + + #: the tags + obj.tags = tuple(((t, ) if isinstance(t, str) else tuple(t), + k) for t, k in tags) + if len(obj.tags) < n: + raise ValueError(f"there must be at least {n} tags, but got " + f"{len(obj.tags)}, i.e., {obj.tags}") + + req_len: int = len(obj.tags[0][0]) + if req_len <= 0: + raise ValueError(f"tag length must be >= 1, but is {req_len} " + f"for tag {obj.tags[0]}.") + for tag in obj.tags: + check_int_range(tag[1], "id", 0, n) + if len(tag[0]) != req_len: + raise ValueError( + f"all tags must have the same length. " + f"while the first tag ({obj.tags[0]}) has " + f"length {req_len}, {tag} has length {len(tag[0])}.") + return obj + + @staticmethod + def from_sequence_and_distance( + data: Iterable[S | None], + get_tags: Callable[[OBJ], str | Iterable[str]], + get_distance: Callable[[OBJ, OBJ], int], + unpack: Callable[[S], OBJ | None] = cast( + Callable[[S], OBJ], lambda x: x)) -> "Instance": + """ + Turn a sequence of objects into a One-Dimensional Ordering instance. + + :param data: the data source, i.e., an iterable of data elements + :param get_tags: the function for extracting tags from objects + :param get_distance: the function for getting the distance between + objects + :param unpack: a function unpacking a source element, e.g., a string, + to an object + :return: the ordering instance + + >>> def _dist(a, b): + ... return abs(a - b) + >>> def _tags(a): + ... return f"x{a}", f"b{a}" + >>> res = Instance.from_sequence_and_distance( + ... [4, 5, 1, 2, 3], _tags, _dist) + >>> print(res) + Instance + >>> print(np.array(res)) + [[1 4 9 7 4] + [3 1 9 7 5] + [7 9 1 3 5] + [7 9 4 1 4] + [4 8 8 4 1]] + >>> print(res.tags) + ((('x4', 'b4'), 0), (('x5', 'b5'), 1), (('x1', 'b1'), 2), \ +(('x2', 'b2'), 3), (('x3', 'b3'), 4)) + >>> res = Instance.from_sequence_and_distance( + ... [4, 5, 4, 2, 5], _tags, _dist) + >>> print(np.array(res)) + [[1 3 5] + [3 1 5] + [3 5 1]] + >>> print(res.tags) + ((('x4', 'b4'), 0), (('x4', 'b4'), 0), (('x5', 'b5'), 1), \ +(('x5', 'b5'), 1), (('x2', 'b2'), 2)) + >>> def _dist2(a, b): + ... return abs(abs(a) - abs(b)) + >>> res = Instance.from_sequence_and_distance( + ... [-4, 5, 4, 2, -5], _tags, _dist2) + >>> print(np.array(res)) + [[1 3 5] + [3 1 5] + [3 5 1]] + >>> print(res.tags) + ((('x4', 'b4'), 0), (('x-4', 'b-4'), 0), (('x-5', 'b-5'), 1), \ +(('x5', 'b5'), 1), (('x2', 'b2'), 2)) + """ + if not isinstance(data, Iterable): + raise type_error(data, "data", Iterable) + if not callable(get_tags): + raise type_error(get_tags, "get_tags", call=True) + if not callable(get_distance): + raise type_error(get_distance, "get_distance", call=True) + if not callable(unpack): + raise type_error(unpack, "unpack", call=True) + + # first extract all the objects + raw: list[OBJ] = [] + for d in data: + if d is None: + continue + objx: OBJ | None = unpack(d) + if objx is None: + continue + raw.append(objx) + + # build a distance matrix and purge all zero-distance elements + mappings: list[tuple[OBJ, int]] = [] + distances: list[list[int]] = [] + i: int = 0 + while i < len(raw): + o1: OBJ = raw[i] + j: int = i + 1 + current_dists: list[int] = [d[i] for d in distances] + current_dists.append(0) + while j < len(raw): + o2: OBJ = raw[j] + dist: int = check_int_range( + get_distance(o1, o2), "get_distance", 0, + 1_000_000_000_000) + if dist <= 0: # distance == 0, must purge + mappings.append((o2, i)) + del raw[j] + for ds in distances: + del ds[j] + continue + current_dists.append(dist) + j += 1 + mappings.append((o1, i)) + distances.append(current_dists) + i += 1 + + # we now got a full distance matrix, let's turn it into a rank matrix + return Instance( + (2.0 * rankdata(distances, axis=1, method="average") - 1.0), + ((get_tags(obj), idx) for obj, idx in mappings)) diff --git a/moptipyapps/order1d/objective.py b/moptipyapps/order1d/objective.py new file mode 100644 index 00000000..b58e05e6 --- /dev/null +++ b/moptipyapps/order1d/objective.py @@ -0,0 +1,128 @@ +""" +An objective function evaluating a permutation of objects. + +The goal is to find an order of objects such that the ranks of their distances +inside the permutation match to the distance ranks inside the instance data. +The objective function is symmetric with respect to the permutation, i.e., +ordering the objects à la `[0, 1, 2, 3]` is the same as ordering them in +`[3, 2, 1, 0]`. + +First, let's construct an instance. +>>> def _dist(a, b): +... return abs(a - b) +>>> def _tags(a): +... return f"t{a}" +>>> the_instance = Instance.from_sequence_and_distance( +... [1, 2, 3, 4, 5], _tags, _dist) + +Now let's create the objective function: +>>> the_objective = OneDimensionalDistribution(the_instance) +>>> the_x = np.array([0, 1, 2, 3, 4], int) +>>> the_objective.evaluate(the_x) +0.0 + +>>> the_x = np.array([1, 0, 2, 3, 4], int) +>>> the_objective.evaluate(the_x) +0.02878505920886873 + +>>> the_x = np.array([2, 1, 0, 3, 4], int) +>>> the_objective.evaluate(the_x) +0.04803169564121945 + +>>> the_x = np.array([4, 3, 2, 1, 0], int) +>>> the_objective.evaluate(the_x) +0.0 +""" + + +from typing import Final + +import numba # type: ignore +import numpy as np +from moptipy.api.objective import Objective +from moptipy.utils.types import type_error +from scipy.stats import rankdata # type: ignore + +from moptipyapps.order1d.instance import Instance + + +@numba.njit(cache=True, inline="always", fastmath=True, boundscheck=False) +def _get_dist(x: np.ndarray, out: np.ndarray) -> None: + """ + Fill a distance matrix. + + :param x: the permutation + :param out: the output matrix + + >>> a = np.array([0, 1, 2, 3], int) + >>> res = np.empty((len(a), len(a)), int) + >>> _get_dist(a, res) + >>> print(res) + [[0 1 2 3] + [1 0 1 2] + [2 1 0 1] + [3 2 1 0]] + >>> a = np.array([2, 1, 0, 3], int) + >>> _get_dist(a, res) + >>> print(res) + [[0 1 2 1] + [1 0 1 2] + [2 1 0 3] + [1 2 3 0]] + """ + lenx: Final[int] = len(x) + for i1, x1 in enumerate(x): + out[i1, i1] = 0 + for i2 in range(i1 + 1, lenx): + x2 = x[i2] + out[x1, x2] = out[x2, x1] = i2 - i1 + + +class OneDimensionalDistribution(Objective): + """A base class for figures of merit.""" + + def __init__(self, instance: Instance) -> None: + """ + Initialize the one-dimensional distribution objective function. + + :param instance: the one-dimensional ordering problem. + """ + super().__init__() + if not isinstance(instance, Instance): + raise type_error(instance, "instance", Instance) + #: the instance data + self.instance: Final[Instance] = instance + #: the instance data by the power of minus three, to speed up stuff + self.__inst3: Final[np.ndarray] = instance ** -2.0 + #: a temporary array + self.__temp: Final[np.ndarray] = np.empty( + instance.shape, instance.dtype) + + def evaluate(self, x) -> float: + """ + Get the difference between the element ordering and the rank goals. + + :param x: the permutation of elements + :return: the difference between the rank goals and orderings + """ + temp: Final[np.ndarray] = self.__temp + _get_dist(x, temp) + rd: np.ndarray = (2.0 * rankdata(temp, axis=1, method="average")) - 1.0 + return np.multiply(np.abs(np.subtract(rd, self.instance, rd), rd), + self.__inst3, rd).mean() + + def __str__(self): + """ + Get the name of this objective. + + :return: `"cubicRankDifference"` always + """ + return "cubicRankDifference" + + def lower_bound(self) -> float: + """ + Get the lower bound: always `0`. + + :return: `0.0` + """ + return 0.0 diff --git a/moptipyapps/order1d/space.py b/moptipyapps/order1d/space.py new file mode 100644 index 00000000..5719dbc0 --- /dev/null +++ b/moptipyapps/order1d/space.py @@ -0,0 +1,89 @@ +""" +An extension of the permutation space for one-dimensional ordering. + +The main difference is how the `to_str` result is noted. + +>>> def _dist(a, b): +... return abs(a - b) +>>> def _tags(a): +... return f"t{a}" +>>> the_instance = Instance.from_sequence_and_distance( +... [1, 2, 3, 3, 2, 3], _tags, _dist) +>>> the_space = OrderingSpace(the_instance) +>>> the_str = the_space.to_str(np.array([0, 2, 1])) +>>> the_str.splitlines() +['0;2;1', '', 'indexZeroBased;suggestedXin01;tag0', '0;0.25;t1', \ +'2;0.75;t2', '2;0.75;t2', '1;0.5;t3', '1;0.5;t3', '1;0.5;t3'] +>>> print(the_space.from_str(the_str)) +[0 2 1] +""" + +from io import StringIO +from typing import Final + +import numpy as np +from moptipy.spaces.permutations import Permutations +from moptipy.utils.logger import CSV_SEPARATOR +from moptipy.utils.strings import float_to_str +from moptipy.utils.types import type_error + +from moptipyapps.order1d.instance import Instance + + +class OrderingSpace(Permutations): + """A space for one-dimensional orderings.""" + + def __init__(self, instance: Instance) -> None: + """ + Create an ordering space from an instance. + + :param instance: the instance + """ + if not isinstance(instance, Instance): + raise type_error(instance, "instance", Instance) + super().__init__(range(len(instance))) + #: the instance + self.instance: Final[Instance] = instance + + def to_str(self, x: np.ndarray) -> str: + """ + Convert a solution to a string. + + :param x: the permutation + :return: the string + """ + tags: Final[tuple[tuple[tuple[str, ...], int], ...]] \ + = self.instance.tags + + total: Final[int] = len(x) + 1 + + with StringIO() as sio: + sio.writelines(super().to_str(x)) + sio.write("\n\n") + + row: list[str] = ["indexZeroBased", "suggestedXin01"] + row.extend(f"tag{i}" for i in range(len(tags[0][0]))) + sio.write(CSV_SEPARATOR.join(row)) + sio.write("\n") + + for tag, i in tags: + row.clear() + row.append(str(x[i])) + row.append(float_to_str((x[i] + 1) / total)) + row.extend(tag) + sio.write(CSV_SEPARATOR.join(row)) + sio.write("\n") + return sio.getvalue() + + def from_str(self, text: str) -> np.ndarray: + """ + Get the string version from the given text. + + :param text: the text + :return: the string + """ + text = text.lstrip() + idx: int = text.find("\n") + if idx > 0: + text = text[:idx] + return super().from_str(text.rstrip()) diff --git a/moptipyapps/version.py b/moptipyapps/version.py index 99155820..0c7341f6 100644 --- a/moptipyapps/version.py +++ b/moptipyapps/version.py @@ -1,4 +1,4 @@ """An internal file with the version of the `moptipyapps` package.""" from typing import Final -__version__: Final[str] = "0.8.31" +__version__: Final[str] = "0.8.32" diff --git a/requirements.txt b/requirements.txt index a6c1350e..43683923 100644 --- a/requirements.txt +++ b/requirements.txt @@ -22,7 +22,7 @@ # `moptipy` provides the basic optimization infrastructure and the spaces and # tools that we use for optimization. -moptipy == 0.9.97 +moptipy == 0.9.98 # `numpy` is needed for its efficient data structures. numpy == 1.26.1 diff --git a/setup.cfg b/setup.cfg index a89e4218..dabf72ac 100644 --- a/setup.cfg +++ b/setup.cfg @@ -41,7 +41,7 @@ include_package_data = True install_requires = certifi >= 2023.7.22 defusedxml >= 0.7.1 - moptipy >= 0.9.97 + moptipy >= 0.9.98 numpy >= 1.26.1 numba >= 0.58.1 matplotlib >= 3.8.0