mdapy-v2 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
mdapy/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Maximum dissimilarity sampling."""
2
+
3
+ from .mda import DataColumn, max_diss_alg
4
+
5
+ __all__ = ["DataColumn", "max_diss_alg"]
mdapy/legacy.py ADDED
@@ -0,0 +1,141 @@
1
+ """Tuple-based API retained for existing users and historical comparison."""
2
+
3
+ from functools import cache
4
+
5
+ import numpy as np
6
+ from numpy.typing import ArrayLike, NDArray
7
+ from scipy.stats import circstd
8
+
9
+
10
+ def distance(
11
+ point1: NDArray,
12
+ point2: NDArray,
13
+ dir_indices: list[int] | None,
14
+ std_list: list[float],
15
+ ) -> float:
16
+ vector = np.zeros_like(point1)
17
+ for ind, (p1_ind, p2_ind) in enumerate(zip(point1, point2)):
18
+ if dir_indices is not None and ind in dir_indices:
19
+ dist = ((p1_ind - p2_ind) + 180) % 360 - 180
20
+ else:
21
+ dist = p1_ind - p2_ind
22
+ vector[ind] = dist / (np.sqrt(2) * std_list[ind])
23
+ return float(np.linalg.norm(vector))
24
+
25
+
26
+ def preprocess(data_tuple: tuple[ArrayLike, ...]) -> NDArray:
27
+ return np.array(data_tuple, dtype=np.float32).T
28
+
29
+
30
+ def std_list(
31
+ data_tuple: tuple[ArrayLike, ...],
32
+ dir_indices: list[int] | None,
33
+ ) -> list[float]:
34
+ return_list = []
35
+ for ind, array in enumerate(data_tuple):
36
+ array = np.asarray(array)
37
+ if dir_indices is not None and ind in dir_indices:
38
+ computed_std = circstd(array, high=360, low=0)
39
+ else:
40
+ computed_std = np.std(array)
41
+ return_list.append(computed_std)
42
+ return return_list
43
+
44
+
45
+ class DataMatrix:
46
+ def __init__(
47
+ self,
48
+ data_tuple: tuple[ArrayLike, ...],
49
+ dir_indices: list[int] | None,
50
+ ) -> None:
51
+ # Check if any of the arrays in the tuple has NaN values
52
+ for array in data_tuple:
53
+ if np.isnan(array).any():
54
+ raise ValueError("The data contains NaN values")
55
+ self.data = preprocess(data_tuple)
56
+ self.std_list = std_list(data_tuple, dir_indices)
57
+ self.dir_indices = dir_indices
58
+
59
+ def __getitem__(self, index: list[int] | int) -> NDArray:
60
+ return self.data[index]
61
+
62
+ @property
63
+ def num_points(self) -> int:
64
+ return len(self.data)
65
+
66
+ # Preserve the historical cache for comparisons; revisit with the algorithm.
67
+ @cache # noqa: B019
68
+ def distance_between(self, index1: int, index2: int) -> float:
69
+ point1, point2 = self.data[[index1, index2]]
70
+ return distance(point1, point2, self.dir_indices, self.std_list)
71
+
72
+
73
+ def distance_to_subset(
74
+ data_matrix: DataMatrix,
75
+ point_index: int,
76
+ subset_indices: list[int],
77
+ ) -> float:
78
+ distance_list = [
79
+ data_matrix.distance_between(point_index, i_point) for i_point in subset_indices
80
+ ]
81
+ return min(distance_list)
82
+
83
+
84
+ def max_diss_alg(
85
+ data_tuple: tuple[ArrayLike, ...],
86
+ n_clusters: int,
87
+ seed_index: int = 0,
88
+ dir_indices: list[int] | None = None,
89
+ *,
90
+ show_progress: bool = True,
91
+ ) -> tuple[NDArray, list[int]]:
92
+ """
93
+ This function implements the Maximum Dissimilarity Algorithm (MDA) to cluster a data.
94
+
95
+ Parameters
96
+ ----------
97
+ data_tuple : tuple[NDArray, ...]
98
+ A tuple of arrays with the data to cluster.
99
+ n_clusters : int
100
+ The number of clusters to generate.
101
+ seed_index : int, optional
102
+ The index of the seed point, by default 0
103
+ dir_indices : list[int], optional
104
+ The indices of directional arrays in the tuple, by default None
105
+ show_progress : bool, optional
106
+ Print progress while selecting points, by default True
107
+
108
+ Returns
109
+ -------
110
+ tuple[NDArray, list[int]]
111
+ A tuple with the clustered data points and their indices in the original data.
112
+ """
113
+
114
+ # Create the data matrix
115
+ data_matrix = DataMatrix(data_tuple, dir_indices)
116
+
117
+ # Initialize the centroids with the seed
118
+ subset_indices: list[int] = [seed_index]
119
+
120
+ while len(subset_indices) < n_clusters:
121
+ max_dist = next_point_index = -1
122
+ # Find the point with the maximum distance to the subset
123
+ for i_point in range(data_matrix.num_points):
124
+ if i_point in subset_indices:
125
+ continue
126
+ # Get the distance to the closest point in the subset
127
+ distance = distance_to_subset(data_matrix, i_point, subset_indices)
128
+ if distance > max_dist:
129
+ max_dist = distance
130
+ next_point_index = i_point
131
+ if show_progress:
132
+ progress = (i_point + 1) / data_matrix.num_points * 100
133
+ print(
134
+ f"Finding cluster nº{len(subset_indices) + 1} --> {progress:.2f}%",
135
+ end="\r",
136
+ )
137
+ # Add the point with the maximum distance to the subset
138
+ if next_point_index > -1:
139
+ subset_indices.append(next_point_index)
140
+
141
+ return data_matrix[subset_indices], subset_indices
mdapy/mda.py ADDED
@@ -0,0 +1,85 @@
1
+ """Public API for selecting maximally dissimilar observations."""
2
+
3
+ from functools import cached_property
4
+ from numbers import Integral
5
+
6
+ import numpy as np
7
+ from numpy.typing import ArrayLike, NDArray
8
+ from scipy.stats import circstd
9
+
10
+ from . import legacy
11
+
12
+
13
+ class DataColumn:
14
+ """One scalar variable, optionally interpreted as angles in degrees."""
15
+
16
+ def __init__(self, array: ArrayLike, *, directional: bool = False) -> None:
17
+ if not isinstance(directional, bool):
18
+ raise TypeError("directional must be a bool")
19
+ values = np.asarray(array, dtype=np.float32)
20
+ if values.ndim != 1:
21
+ raise ValueError("Each data column must be one-dimensional")
22
+ if not np.isfinite(values).all():
23
+ raise ValueError("Data columns must contain only finite values")
24
+ self.np_array: NDArray[np.float32] = values
25
+ self.directional = directional
26
+
27
+ def __len__(self) -> int:
28
+ return len(self.np_array)
29
+
30
+ @cached_property
31
+ def standard_deviation(self) -> float:
32
+ if self.directional:
33
+ return float(circstd(self.np_array, high=360, low=0))
34
+ return float(np.std(self.np_array))
35
+
36
+
37
+ _LEGACY_HINT = (
38
+ "The tuple-based API is available from mdapy.legacy: "
39
+ "from mdapy.legacy import max_diss_alg"
40
+ )
41
+
42
+
43
+ def max_diss_alg(
44
+ *columns: DataColumn,
45
+ n_clusters: int | None = None,
46
+ seed_index: int = 0,
47
+ **kwargs: object,
48
+ ) -> tuple[NDArray[np.float32], list[int]]:
49
+ """Select observations using columns and the maximum dissimilarity algorithm.
50
+
51
+ ``n_clusters`` is required. Directional columns use degrees with a period
52
+ of 360. Returns selected rows in selection order and their input indices.
53
+ """
54
+ if "data_tuple" in kwargs or "dir_indices" in kwargs:
55
+ raise TypeError(_LEGACY_HINT)
56
+ if kwargs:
57
+ unexpected = next(iter(kwargs))
58
+ raise TypeError(f"Unexpected keyword argument: {unexpected}")
59
+ if columns and not all(isinstance(col, DataColumn) for col in columns):
60
+ raise TypeError(f"Expected DataColumn arguments. {_LEGACY_HINT}")
61
+ if not columns:
62
+ raise ValueError("At least one DataColumn is required")
63
+ if n_clusters is None:
64
+ raise TypeError("n_clusters is required")
65
+ if isinstance(n_clusters, bool) or not isinstance(n_clusters, Integral):
66
+ raise TypeError("n_clusters must be an integer")
67
+ if isinstance(seed_index, bool) or not isinstance(seed_index, Integral):
68
+ raise TypeError("seed_index must be an integer")
69
+
70
+ n_points = len(columns[0])
71
+ if any(len(col) != n_points for col in columns):
72
+ raise ValueError("All data columns must have the same length")
73
+ if not 1 <= n_clusters <= n_points:
74
+ raise ValueError("n_clusters must be between 1 and the number of rows")
75
+ if not 0 <= seed_index < n_points:
76
+ raise ValueError("seed_index is out of bounds")
77
+ for index, col in enumerate(columns):
78
+ if not np.isfinite(col.standard_deviation) or col.standard_deviation <= 0:
79
+ raise ValueError(f"Column {index} has zero or undefined deviation")
80
+
81
+ arrays = tuple(col.np_array for col in columns)
82
+ dir_indices = [i for i, col in enumerate(columns) if col.directional]
83
+ return legacy.max_diss_alg(
84
+ arrays, int(n_clusters), int(seed_index), dir_indices, show_progress=False
85
+ )
@@ -0,0 +1,122 @@
1
+ Metadata-Version: 2.4
2
+ Name: mdapy-v2
3
+ Version: 2.0.0
4
+ Summary: Maximum Dissimilarity Algorithm in Python
5
+ Author: Pablo Garcia
6
+ Author-email: Pablo Garcia <pablofueros@gmail.com>
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Requires-Dist: numpy>=1.26
10
+ Requires-Dist: scipy>=1.11.2
11
+ Requires-Python: >=3.10
12
+ Project-URL: Repository, https://github.com/pablofueros/mdapy-v2
13
+ Project-URL: Issues, https://github.com/pablofueros/mdapy-v2/issues
14
+ Project-URL: Original project, https://github.com/IHCantabria/mdapy
15
+ Description-Content-Type: text/markdown
16
+
17
+ # mdapy-v2
18
+
19
+ `mdapy-v2` is a maintained fork of
20
+ [`IHCantabria/mdapy`](https://github.com/IHCantabria/mdapy), developed by the
21
+ original author. Version 2.0 introduces the `DataColumn` API while preserving
22
+ the `mdapy` Python import and the original maximum dissimilarity algorithm.
23
+
24
+ The distributions `mdapy-v2`, `ihc-mdapy`, and the unrelated `mdapy` project
25
+ all provide a package named `mdapy`. Treat them as alternatives: do not install
26
+ them together in the same Python environment.
27
+
28
+ ## Installation
29
+
30
+ Install the maintained distribution from PyPI:
31
+
32
+ ```bash
33
+ pip install mdapy-v2
34
+ ```
35
+
36
+ The import remains unchanged:
37
+
38
+ ```python
39
+ from mdapy import DataColumn, max_diss_alg
40
+ ```
41
+
42
+ The original `ihc-mdapy==0.1.0` distribution uses the tuple-based API shown in
43
+ the legacy section below. Version 2.0 also contains the NaN validation from the
44
+ historical `v0.1.1` tag, which was not published to PyPI.
45
+
46
+ ---
47
+
48
+ ## 📋 The Maximum Dissimilarity Algorithm
49
+ The Maximum Dissimilarity Algorithm is a technique used to select a specified number of data points from a larger dataset in such a way that the selected points are maximally dissimilar to each other. The algorithm is particularly useful in scenarios where you need to create diverse subsets of data for analysis or for initializing cluster centroids in clustering algorithms.
50
+
51
+ In this case, directional variables can also be considered, in which case, the distance between two angles is calculated as the shortest distance between the two angles in a circle. For example, the distance between 350° and 10° is 20°, not 340°. This is the approach used in this implementation of the algorithm (see [Guanche et al. (2013)](#1) for more details).
52
+
53
+ ### Algorithm steps
54
+
55
+ 1. Start with the observation at `seed_index` (default `0`).
56
+ 2. For each unselected observation, find its distance to the nearest selected observation.
57
+ 3. Select the observation with the greatest nearest distance. Repeat until `n_clusters` observations have been selected.
58
+
59
+ Distances are Euclidean after dividing each column's difference by its
60
+ standard deviation and `sqrt(2)`. Directional differences use the shortest
61
+ arc in degrees, and their standard deviation is circular. If two candidates
62
+ have exactly the same distance, the one appearing first in the input is
63
+ selected.
64
+
65
+ ---
66
+
67
+ ## Usage
68
+
69
+ Pass one `DataColumn` per variable. Each column must be one-dimensional, have
70
+ the same number of observations, and contain only finite values. Columns with
71
+ zero or undefined standard deviation are rejected. Directional columns are
72
+ angles in degrees. `n_clusters` is required and must be between 1 and the
73
+ number of observations; `seed_index` must be a valid input position.
74
+
75
+ ```python
76
+ from mdapy import DataColumn, max_diss_alg
77
+
78
+ tp = [14.93, 10.42, 7.46, 7.63, 12.20, 12.05, 14.49, 8.62]
79
+ hs = [1.82, 1.45, 0.26, 0.90, 1.30, 5.71, 1.40, 2.18]
80
+ direction = [268, 272, 289, 293, 276, 273, 276, 292]
81
+
82
+ points, indices = max_diss_alg(
83
+ DataColumn(tp),
84
+ DataColumn(hs),
85
+ DataColumn(direction, directional=True),
86
+ n_clusters=3,
87
+ )
88
+ ```
89
+
90
+ Input values are converted to `float32`. `points` is a `float32` NumPy array
91
+ of shape `(n_clusters, n_columns)` in selection order.
92
+ `indices` is a list of positions in the original input. The first position is
93
+ `seed_index` (default `0`).
94
+
95
+ ### Legacy tuple API
96
+
97
+ Existing tuple-based calls can use an explicit import:
98
+
99
+ ```python
100
+ from mdapy.legacy import max_diss_alg
101
+
102
+ points, indices = max_diss_alg((tp, hs, direction), 3, seed_index=0, dir_indices=[2])
103
+ ```
104
+
105
+ Passing a tuple to `mdapy.max_diss_alg` raises an error explaining this import.
106
+
107
+ ---
108
+
109
+ ## ©️ Credits
110
+
111
+ Originally developed by 👨‍💻 [Pablo Garcia](https://github.com/pablofueros)
112
+ at 🏢 [IHCantabria](https://github.com/IHCantabria), and now maintained as
113
+ the personal `mdapy-v2` fork. The historical `v0.1.1` fix was contributed by
114
+ Álvaro Rodríguez. The original IHCantabria copyright notice and MIT terms are
115
+ preserved in [`LICENSE`](LICENSE).
116
+
117
+ ---
118
+
119
+ ## 📚 References
120
+ <a id="1">[1]</a> Guanche, Y., Guanche, R., Camus, P., Mendez, F. J., & Medina, R. (2013). A multivariate approach to estimate design loads for offshore wind turbines. *Wind Energy*, 16(7), 1091-1106.
121
+
122
+ ---
@@ -0,0 +1,7 @@
1
+ mdapy/__init__.py,sha256=L4K8sO64fv2UNzuGn66NgjXZ-oLH-oVT7OInwCmrixQ,123
2
+ mdapy/legacy.py,sha256=N46a_plmigXAgEXO2NPIyl2-q9mAkZiTdf8ae7_4FuE,4605
3
+ mdapy/mda.py,sha256=b-uKJwaZGRFUmPfwkLa9I09i2AoN0lSl-E0p32zQqTU,3334
4
+ mdapy_v2-2.0.0.dist-info/licenses/LICENSE,sha256=H8yzPXcyB1OyvYT09336d2NE5aaSuguDV5-lr2a5DcQ,1067
5
+ mdapy_v2-2.0.0.dist-info/WHEEL,sha256=xTCBFIZIIyRSxyowOv5Wqg_jFU6KsdIffuulBB09Ph8,80
6
+ mdapy_v2-2.0.0.dist-info/METADATA,sha256=9LvNXjN5s9Ap20tEe6tdvBmTaZ4I_rjalC3Ot-rZl38,4727
7
+ mdapy_v2-2.0.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.13.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2023 IHCantabria
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.