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 +5 -0
- mdapy/legacy.py +141 -0
- mdapy/mda.py +85 -0
- mdapy_v2-2.0.0.dist-info/METADATA +122 -0
- mdapy_v2-2.0.0.dist-info/RECORD +7 -0
- mdapy_v2-2.0.0.dist-info/WHEEL +4 -0
- mdapy_v2-2.0.0.dist-info/licenses/LICENSE +21 -0
mdapy/__init__.py
ADDED
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,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.
|