pypcoords 1.0.0__tar.gz
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.
- pypcoords-1.0.0/PKG-INFO +108 -0
- pypcoords-1.0.0/README.md +94 -0
- pypcoords-1.0.0/pyproject.toml +33 -0
- pypcoords-1.0.0/setup.cfg +4 -0
- pypcoords-1.0.0/src/pypcoords/__init__.py +3 -0
- pypcoords-1.0.0/src/pypcoords/core.py +555 -0
- pypcoords-1.0.0/src/pypcoords.egg-info/PKG-INFO +108 -0
- pypcoords-1.0.0/src/pypcoords.egg-info/SOURCES.txt +10 -0
- pypcoords-1.0.0/src/pypcoords.egg-info/dependency_links.txt +1 -0
- pypcoords-1.0.0/src/pypcoords.egg-info/requires.txt +1 -0
- pypcoords-1.0.0/src/pypcoords.egg-info/top_level.txt +1 -0
- pypcoords-1.0.0/tests/test_parallel_plot.py +27 -0
pypcoords-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pypcoords
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Flexible parallel coordinates plot with support for NumPy, pandas, and polars.
|
|
5
|
+
Author: Lucas Detogni
|
|
6
|
+
Project-URL: Homepage, https://github.com/LucasMainUser/pypcoords
|
|
7
|
+
Keywords: visualization,matplotlib,parallel coordinates,plot
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Requires-Python: >=3.11
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
Requires-Dist: matplotlib>=3.10.9
|
|
14
|
+
|
|
15
|
+
# pypcoords
|
|
16
|
+
|
|
17
|
+
Simple and flexible **parallel coordinates plot** implementation built on top of `matplotlib`.
|
|
18
|
+
|
|
19
|
+
Supports multiple data formats out of the box, including **NumPy**, **pandas**, and **polars**.
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## β¨ Features
|
|
24
|
+
|
|
25
|
+
* π Parallel coordinates visualization
|
|
26
|
+
* π Works with:
|
|
27
|
+
|
|
28
|
+
* dict / mappings
|
|
29
|
+
* NumPy arrays
|
|
30
|
+
* pandas DataFrame
|
|
31
|
+
* polars DataFrame
|
|
32
|
+
* π¨ Flexible color system (colormap or custom colors)
|
|
33
|
+
* π§ Automatic handling of:
|
|
34
|
+
|
|
35
|
+
* numeric data
|
|
36
|
+
* categorical data
|
|
37
|
+
* π§΅ Smooth curves using cubic BΓ©zier interpolation
|
|
38
|
+
* π― Row highlighting support
|
|
39
|
+
|
|
40
|
+
---
|
|
41
|
+
|
|
42
|
+
## π¦ Installation
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pip install pypcoords
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
---
|
|
49
|
+
|
|
50
|
+
## π Quick Example
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
from pypcoords import parallel_plot
|
|
54
|
+
|
|
55
|
+
data = {
|
|
56
|
+
"A": [1, 2, 3],
|
|
57
|
+
"B": [4, 5, 6],
|
|
58
|
+
"C": [7, 8, 9],
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
parallel_plot(data)
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
## π¨ With colors
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
parallel_plot(
|
|
70
|
+
data,
|
|
71
|
+
colormap="viridis",
|
|
72
|
+
colormap_column="C"
|
|
73
|
+
)
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
---
|
|
77
|
+
|
|
78
|
+
## π Highlight specific rows
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
parallel_plot(
|
|
82
|
+
data,
|
|
83
|
+
highlight_rows={
|
|
84
|
+
1: {"edgecolor": "red", "linewidth": 2.5}
|
|
85
|
+
}
|
|
86
|
+
)
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
---
|
|
90
|
+
|
|
91
|
+
## π Supported input formats
|
|
92
|
+
|
|
93
|
+
* Mapping of column names to arrays
|
|
94
|
+
* pandas DataFrame
|
|
95
|
+
* polars DataFrame
|
|
96
|
+
* 2D array-like (NumPy, lists, etc.)
|
|
97
|
+
|
|
98
|
+
---
|
|
99
|
+
|
|
100
|
+
## π License
|
|
101
|
+
|
|
102
|
+
MIT
|
|
103
|
+
|
|
104
|
+
---
|
|
105
|
+
|
|
106
|
+
## π€ Contributing
|
|
107
|
+
|
|
108
|
+
Contributions are welcome!
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# pypcoords
|
|
2
|
+
|
|
3
|
+
Simple and flexible **parallel coordinates plot** implementation built on top of `matplotlib`.
|
|
4
|
+
|
|
5
|
+
Supports multiple data formats out of the box, including **NumPy**, **pandas**, and **polars**.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## β¨ Features
|
|
10
|
+
|
|
11
|
+
* π Parallel coordinates visualization
|
|
12
|
+
* π Works with:
|
|
13
|
+
|
|
14
|
+
* dict / mappings
|
|
15
|
+
* NumPy arrays
|
|
16
|
+
* pandas DataFrame
|
|
17
|
+
* polars DataFrame
|
|
18
|
+
* π¨ Flexible color system (colormap or custom colors)
|
|
19
|
+
* π§ Automatic handling of:
|
|
20
|
+
|
|
21
|
+
* numeric data
|
|
22
|
+
* categorical data
|
|
23
|
+
* π§΅ Smooth curves using cubic BΓ©zier interpolation
|
|
24
|
+
* π― Row highlighting support
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
## π¦ Installation
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pip install pypcoords
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
## π Quick Example
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
from pypcoords import parallel_plot
|
|
40
|
+
|
|
41
|
+
data = {
|
|
42
|
+
"A": [1, 2, 3],
|
|
43
|
+
"B": [4, 5, 6],
|
|
44
|
+
"C": [7, 8, 9],
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
parallel_plot(data)
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
## π¨ With colors
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
parallel_plot(
|
|
56
|
+
data,
|
|
57
|
+
colormap="viridis",
|
|
58
|
+
colormap_column="C"
|
|
59
|
+
)
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
---
|
|
63
|
+
|
|
64
|
+
## π Highlight specific rows
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
parallel_plot(
|
|
68
|
+
data,
|
|
69
|
+
highlight_rows={
|
|
70
|
+
1: {"edgecolor": "red", "linewidth": 2.5}
|
|
71
|
+
}
|
|
72
|
+
)
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
---
|
|
76
|
+
|
|
77
|
+
## π Supported input formats
|
|
78
|
+
|
|
79
|
+
* Mapping of column names to arrays
|
|
80
|
+
* pandas DataFrame
|
|
81
|
+
* polars DataFrame
|
|
82
|
+
* 2D array-like (NumPy, lists, etc.)
|
|
83
|
+
|
|
84
|
+
---
|
|
85
|
+
|
|
86
|
+
## π License
|
|
87
|
+
|
|
88
|
+
MIT
|
|
89
|
+
|
|
90
|
+
---
|
|
91
|
+
|
|
92
|
+
## π€ Contributing
|
|
93
|
+
|
|
94
|
+
Contributions are welcome!
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "pypcoords"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Flexible parallel coordinates plot with support for NumPy, pandas, and polars."
|
|
9
|
+
authors = [
|
|
10
|
+
{ name = "Lucas Detogni" }
|
|
11
|
+
]
|
|
12
|
+
readme = "README.md"
|
|
13
|
+
requires-python = ">=3.11"
|
|
14
|
+
dependencies = [
|
|
15
|
+
"matplotlib>=3.10.9"
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
keywords = ["visualization", "matplotlib", "parallel coordinates", "plot"]
|
|
19
|
+
|
|
20
|
+
classifiers = [
|
|
21
|
+
"Programming Language :: Python :: 3",
|
|
22
|
+
"License :: OSI Approved :: MIT License",
|
|
23
|
+
"Operating System :: OS Independent",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[project.urls]
|
|
27
|
+
Homepage = "https://github.com/LucasMainUser/pypcoords"
|
|
28
|
+
|
|
29
|
+
[tool.setuptools]
|
|
30
|
+
package-dir = {"" = "src"}
|
|
31
|
+
|
|
32
|
+
[tool.setuptools.packages.find]
|
|
33
|
+
where = ["src"]
|
|
@@ -0,0 +1,555 @@
|
|
|
1
|
+
from typing import (
|
|
2
|
+
Any,
|
|
3
|
+
Protocol,
|
|
4
|
+
SupportsIndex,
|
|
5
|
+
TypeAlias,
|
|
6
|
+
Mapping,
|
|
7
|
+
Hashable,
|
|
8
|
+
Iterable,
|
|
9
|
+
Self,
|
|
10
|
+
Sized,
|
|
11
|
+
Optional,
|
|
12
|
+
Sequence,
|
|
13
|
+
runtime_checkable
|
|
14
|
+
)
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from operator import index as to_index
|
|
17
|
+
|
|
18
|
+
import numpy as np
|
|
19
|
+
import numpy.typing as npt
|
|
20
|
+
import matplotlib.pyplot as plt
|
|
21
|
+
|
|
22
|
+
from matplotlib.pyplot import get_cmap
|
|
23
|
+
from matplotlib.cm import ScalarMappable
|
|
24
|
+
from matplotlib.colors import (
|
|
25
|
+
Normalize,
|
|
26
|
+
Colormap,
|
|
27
|
+
ListedColormap,
|
|
28
|
+
LinearSegmentedColormap,
|
|
29
|
+
to_rgba,
|
|
30
|
+
is_color_like
|
|
31
|
+
)
|
|
32
|
+
from matplotlib.path import Path
|
|
33
|
+
from matplotlib.patches import PathPatch
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@runtime_checkable
|
|
37
|
+
class DataframeLike(Protocol):
|
|
38
|
+
columns: Iterable[Hashable]
|
|
39
|
+
def __getitem__(self, key: Hashable, /) -> npt.ArrayLike: ...
|
|
40
|
+
|
|
41
|
+
@runtime_checkable
|
|
42
|
+
class Transformer(Protocol):
|
|
43
|
+
def fit(self, values: npt.ArrayLike, /) -> Self: ...
|
|
44
|
+
def transform(self, values: npt.ArrayLike, /) -> npt.NDArray: ...
|
|
45
|
+
def inverse_transform(self, values: npt.ArrayLike, /) -> npt.NDArray: ...
|
|
46
|
+
|
|
47
|
+
@dataclass(slots=True)
|
|
48
|
+
class NumberTransformer:
|
|
49
|
+
dtype: Optional[type] = None
|
|
50
|
+
digits: Optional[int] = None
|
|
51
|
+
|
|
52
|
+
def fit(self, values: npt.ArrayLike, /, digits: Optional[int] = None) -> Self:
|
|
53
|
+
self.dtype = np.asarray(values).dtype
|
|
54
|
+
self.digits = digits
|
|
55
|
+
return self
|
|
56
|
+
|
|
57
|
+
def transform(self, values: npt.ArrayLike, /) -> npt.NDArray:
|
|
58
|
+
array = np.asarray(values, dtype=self.dtype, copy=True)
|
|
59
|
+
if self.digits is not None:
|
|
60
|
+
array = np.round(array, decimals=self.digits)
|
|
61
|
+
return array
|
|
62
|
+
|
|
63
|
+
def inverse_transform(self, values: npt.ArrayLike, /) -> npt.NDArray:
|
|
64
|
+
array = np.asarray(values)
|
|
65
|
+
if self.digits is not None:
|
|
66
|
+
array = np.round(array, decimals=self.digits)
|
|
67
|
+
|
|
68
|
+
if np.issubdtype(self.dtype, np.integer):
|
|
69
|
+
return np.round(array).astype(self.dtype)
|
|
70
|
+
|
|
71
|
+
return array.astype(self.dtype, copy=True)
|
|
72
|
+
|
|
73
|
+
@dataclass(slots=True)
|
|
74
|
+
class CategoricalTransformer:
|
|
75
|
+
categories: Optional[npt.NDArray] = None
|
|
76
|
+
|
|
77
|
+
def fit(self, values: npt.ArrayLike, /) -> Self:
|
|
78
|
+
array = np.asarray(values)
|
|
79
|
+
self.categories = np.unique(array)
|
|
80
|
+
return self
|
|
81
|
+
|
|
82
|
+
def transform(self, values: npt.ArrayLike, /) -> npt.NDArray:
|
|
83
|
+
array = np.asarray(values)
|
|
84
|
+
return np.searchsorted(self.categories, array)
|
|
85
|
+
|
|
86
|
+
def inverse_transform(self, values: npt.ArrayLike, /) -> npt.NDArray:
|
|
87
|
+
array = np.asarray(values, dtype=int)
|
|
88
|
+
return self.categories[array]
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def num_categories(self) -> int:
|
|
92
|
+
if self.categories is None:
|
|
93
|
+
return 0
|
|
94
|
+
return len(self.categories)
|
|
95
|
+
|
|
96
|
+
@dataclass(slots=True)
|
|
97
|
+
class MinMaxTransformer:
|
|
98
|
+
lower: float
|
|
99
|
+
upper: float
|
|
100
|
+
|
|
101
|
+
vmin: Optional[float]=None
|
|
102
|
+
vmax: Optional[float]=None
|
|
103
|
+
|
|
104
|
+
def fit(self, values: npt.ArrayLike, /) -> Self:
|
|
105
|
+
array = np.asarray(values, dtype=float)
|
|
106
|
+
self.vmin = np.min(array)
|
|
107
|
+
self.vmax = np.max(array)
|
|
108
|
+
return self
|
|
109
|
+
|
|
110
|
+
def transform(self, values: npt.ArrayLike, /) -> npt.NDArray:
|
|
111
|
+
array = np.asarray(values, dtype=float)
|
|
112
|
+
|
|
113
|
+
if self.vmax == self.vmin:
|
|
114
|
+
midpoint = (self.lower + self.upper) / 2
|
|
115
|
+
return np.full(array.shape, midpoint, dtype=float)
|
|
116
|
+
|
|
117
|
+
scale = (self.upper - self.lower) / (self.vmax - self.vmin)
|
|
118
|
+
return (array - self.vmin) * scale + self.lower
|
|
119
|
+
|
|
120
|
+
def inverse_transform(self, values: npt.ArrayLike, /) -> npt.NDArray:
|
|
121
|
+
array = np.asarray(values, dtype=float)
|
|
122
|
+
|
|
123
|
+
if self.vmax == self.vmin:
|
|
124
|
+
return np.full(array.shape, self.vmin, dtype=float)
|
|
125
|
+
|
|
126
|
+
scale = (self.vmax - self.vmin) / (self.upper - self.lower)
|
|
127
|
+
return (array - self.lower) * scale + self.vmin
|
|
128
|
+
|
|
129
|
+
Category: TypeAlias = str
|
|
130
|
+
HexColor: TypeAlias = str
|
|
131
|
+
ColumnName: TypeAlias = Hashable
|
|
132
|
+
RGB: TypeAlias = tuple[float, float, float]
|
|
133
|
+
RGBA: TypeAlias = tuple[float, float, float, float]
|
|
134
|
+
ColorLike: TypeAlias = HexColor | RGB | RGBA
|
|
135
|
+
Ticks: TypeAlias = Sequence[float]
|
|
136
|
+
TickLabels: TypeAlias = Sequence[str]
|
|
137
|
+
Vertices: TypeAlias = Sequence[float]
|
|
138
|
+
Codes: TypeAlias = Sequence[float]
|
|
139
|
+
ColumnsMapping: TypeAlias = Mapping[Hashable, npt.NDArray]
|
|
140
|
+
RowStyle: TypeAlias = Mapping[str, Any]
|
|
141
|
+
ColumnValues: TypeAlias = Sequence[Any]
|
|
142
|
+
RowValues: TypeAlias = Sequence[Any]
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def move_to_end(data: list[Any], value: Any, /) -> list[Any]:
|
|
148
|
+
data.remove(value)
|
|
149
|
+
data.append(value)
|
|
150
|
+
return data
|
|
151
|
+
|
|
152
|
+
def iter_rows(columns: Iterable[ColumnValues], /) -> Iterable[RowValues]:
|
|
153
|
+
return zip(*columns, strict=True)
|
|
154
|
+
|
|
155
|
+
def dict_from_lists(keys: Iterable[Hashable], values: Iterable[Any], /) -> dict[Hashable, Any]:
|
|
156
|
+
return dict(zip(keys, values, strict=True))
|
|
157
|
+
|
|
158
|
+
def generate_indices(stop: int, /) -> list[int]:
|
|
159
|
+
return list(range(stop))
|
|
160
|
+
|
|
161
|
+
def assert_same_lengths(*data: Sized) -> None:
|
|
162
|
+
it = iter(data)
|
|
163
|
+
try:
|
|
164
|
+
expected = len(next(it))
|
|
165
|
+
except StopIteration:
|
|
166
|
+
return
|
|
167
|
+
|
|
168
|
+
for obj in it:
|
|
169
|
+
if len(obj) == expected:
|
|
170
|
+
continue
|
|
171
|
+
raise ValueError('All inputs must have the same length')
|
|
172
|
+
|
|
173
|
+
def vector(data: npt.ArrayLike, /, copy: bool=True) -> npt.NDArray:
|
|
174
|
+
array = np.atleast_1d(data).ravel()
|
|
175
|
+
if copy:
|
|
176
|
+
return array.copy()
|
|
177
|
+
return array
|
|
178
|
+
|
|
179
|
+
def infer_transformer(values: npt.ArrayLike, /) -> Transformer:
|
|
180
|
+
array = np.asarray(values)
|
|
181
|
+
|
|
182
|
+
if np.issubdtype(array.dtype, np.number):
|
|
183
|
+
return NumberTransformer()
|
|
184
|
+
|
|
185
|
+
try:
|
|
186
|
+
np.astype(array, dtype=float)
|
|
187
|
+
return NumberTransformer()
|
|
188
|
+
except (TypeError, ValueError):
|
|
189
|
+
return CategoricalTransformer()
|
|
190
|
+
|
|
191
|
+
def sort_keys(m: Mapping[Hashable, Any], /, reverse: bool=False) -> dict[Hashable, Any]:
|
|
192
|
+
return {key: m[key] for key in sorted(m.keys(), reverse=reverse) }
|
|
193
|
+
|
|
194
|
+
def infer_columns(data: Mapping | DataframeLike | npt.ArrayLike, /) -> list[Hashable]:
|
|
195
|
+
if isinstance(data, DataframeLike):
|
|
196
|
+
return list(data.columns)
|
|
197
|
+
if isinstance(data, Mapping):
|
|
198
|
+
return list(data.keys())
|
|
199
|
+
try:
|
|
200
|
+
matrix = np.asarray(data)
|
|
201
|
+
|
|
202
|
+
if matrix.ndim != 2:
|
|
203
|
+
raise ValueError('Expected 2D array-like')
|
|
204
|
+
num_columns = matrix.shape[1]
|
|
205
|
+
return list(range(num_columns))
|
|
206
|
+
except Exception as error:
|
|
207
|
+
raise ValueError(f'Could not infer columns from data. Details: {error}') from error
|
|
208
|
+
|
|
209
|
+
def map_columns(data: npt.ArrayLike | DataframeLike | Mapping[Hashable, npt.ArrayLike], columns: Iterable[Hashable], /) -> ColumnsMapping:
|
|
210
|
+
if isinstance(
|
|
211
|
+
data, (Mapping, DataframeLike)
|
|
212
|
+
):
|
|
213
|
+
return {column: vector(data[column]) for column in columns}
|
|
214
|
+
|
|
215
|
+
matrix = np.asarray(data)
|
|
216
|
+
|
|
217
|
+
if matrix.ndim != 2:
|
|
218
|
+
raise ValueError('Expected 2D array-like')
|
|
219
|
+
return {column: vector(matrix[:, column]) for column in columns}
|
|
220
|
+
|
|
221
|
+
def map_numeric_transformers(mapping: ColumnsMapping, /, decimals: Mapping[Hashable, int] | None=None) -> dict[Hashable, Transformer]:
|
|
222
|
+
if decimals is None:
|
|
223
|
+
decimals = {}
|
|
224
|
+
|
|
225
|
+
def fit_transformer(column: Hashable, values: Sequence, /) -> Transformer:
|
|
226
|
+
transformer = infer_transformer(values)
|
|
227
|
+
if isinstance(transformer, NumberTransformer):
|
|
228
|
+
return transformer.fit(values, decimals.get(column))
|
|
229
|
+
return transformer.fit(values)
|
|
230
|
+
|
|
231
|
+
return {
|
|
232
|
+
column: fit_transformer(column, values)
|
|
233
|
+
for column, values in mapping.items()
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
def map_reescalers(mapping: ColumnsMapping, /, lower: float, upper: float) -> dict[Hashable, Transformer]:
|
|
237
|
+
return {
|
|
238
|
+
column: MinMaxTransformer(lower, upper).fit(values)
|
|
239
|
+
for column, values in mapping.items()
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def map_parallel_axes(
|
|
245
|
+
columns: Iterable[ColumnName],
|
|
246
|
+
/,
|
|
247
|
+
cmap: Optional[str | Colormap]=None,
|
|
248
|
+
norm: Optional[Normalize]=None,
|
|
249
|
+
ax: Optional[plt.Axes]=None,
|
|
250
|
+
colorbar_at_end: bool=False,
|
|
251
|
+
) -> dict[ColumnName, plt.Axes]:
|
|
252
|
+
|
|
253
|
+
columns = list(columns)
|
|
254
|
+
total = len(columns)
|
|
255
|
+
|
|
256
|
+
ax = resolve_axes(ax)
|
|
257
|
+
|
|
258
|
+
if not colorbar_at_end:
|
|
259
|
+
parallel_axes = [ax] + [ ax.twinx() for _ in range(total - 1) ]
|
|
260
|
+
return dict_from_lists(columns, parallel_axes)
|
|
261
|
+
|
|
262
|
+
scalar_mappable = ScalarMappable(norm=norm, cmap=cmap)
|
|
263
|
+
scalar_mappable.set_array([])
|
|
264
|
+
colorbar = plt.colorbar(scalar_mappable, ax=ax, pad=0.0)
|
|
265
|
+
|
|
266
|
+
parallel_axes = [ax] + [ ax.twinx() for _ in range(total - 2) ] + [colorbar.ax]
|
|
267
|
+
return dict_from_lists(columns, parallel_axes)
|
|
268
|
+
|
|
269
|
+
def cubic_bezier_path(x: npt.ArrayLike, y: npt.ArrayLike, /) -> tuple[Vertices, Codes]:
|
|
270
|
+
x_array = vector(x)
|
|
271
|
+
y_array = vector(y)
|
|
272
|
+
|
|
273
|
+
assert_same_lengths(x_array, y_array)
|
|
274
|
+
|
|
275
|
+
codes: Codes = []
|
|
276
|
+
vertices: Vertices = []
|
|
277
|
+
|
|
278
|
+
is_first_point = True
|
|
279
|
+
num_segments = len(x) - 1
|
|
280
|
+
|
|
281
|
+
for index in range(num_segments):
|
|
282
|
+
x_start = x_array[index]
|
|
283
|
+
y_start = y_array[index]
|
|
284
|
+
|
|
285
|
+
x_final = x_array[index + 1]
|
|
286
|
+
y_final = y_array[index + 1]
|
|
287
|
+
|
|
288
|
+
delta_x = x_final - x_start
|
|
289
|
+
|
|
290
|
+
start_point = (x_start, y_start)
|
|
291
|
+
control_point_1 = (x_start + 1/3 * delta_x, y_start)
|
|
292
|
+
control_point_2 = (x_start + 2/3 * delta_x, y_final)
|
|
293
|
+
final_point = (x_final, y_final)
|
|
294
|
+
|
|
295
|
+
if is_first_point:
|
|
296
|
+
is_first_point = False
|
|
297
|
+
vertices.append(start_point)
|
|
298
|
+
codes.append(Path.MOVETO)
|
|
299
|
+
|
|
300
|
+
vertices.extend([control_point_1, control_point_2, final_point])
|
|
301
|
+
codes.extend([Path.CURVE4, Path.CURVE4, Path.CURVE4])
|
|
302
|
+
|
|
303
|
+
return vertices, codes
|
|
304
|
+
|
|
305
|
+
def resolve_axes(ax: plt.Axes | None, /) -> plt.Axes:
|
|
306
|
+
if ax is None:
|
|
307
|
+
return plt.gca()
|
|
308
|
+
return ax
|
|
309
|
+
|
|
310
|
+
def transform_rgba(data: str | RGB | RGBA, /, alpha: Optional[float]=None) -> RGBA:
|
|
311
|
+
if (
|
|
312
|
+
isinstance(data, tuple)
|
|
313
|
+
and len(data) in (3,4)
|
|
314
|
+
and max(data) > 1
|
|
315
|
+
):
|
|
316
|
+
data = tuple(value / 255 for value in data)
|
|
317
|
+
return to_rgba(data, alpha=alpha)
|
|
318
|
+
|
|
319
|
+
def resolve_color_system(
|
|
320
|
+
to_color_array: npt.ArrayLike,
|
|
321
|
+
/,
|
|
322
|
+
palette: Optional[ColorLike | Mapping[float, ColorLike] | Colormap] = None,
|
|
323
|
+
colormap_gradient: Optional[int]=None,
|
|
324
|
+
vmin: Optional[float]=None,
|
|
325
|
+
vmax: Optional[float]=None
|
|
326
|
+
) -> tuple[Colormap, Normalize, np.ndarray[RGBA, None] ]:
|
|
327
|
+
|
|
328
|
+
if colormap_gradient is None:
|
|
329
|
+
colormap_gradient = 256
|
|
330
|
+
|
|
331
|
+
values = np.asarray(to_color_array)
|
|
332
|
+
|
|
333
|
+
if vmin is None:
|
|
334
|
+
vmin = np.min(values)
|
|
335
|
+
|
|
336
|
+
if vmax is None:
|
|
337
|
+
vmax = np.max(values)
|
|
338
|
+
|
|
339
|
+
if isinstance(palette, Mapping):
|
|
340
|
+
palette = dict(palette)
|
|
341
|
+
segments = [
|
|
342
|
+
(value, color) for value, color in sort_keys(palette, reverse=False).items()
|
|
343
|
+
]
|
|
344
|
+
|
|
345
|
+
cmap = LinearSegmentedColormap.from_list('linear_segment_colormap', segments, N=colormap_gradient)
|
|
346
|
+
norm = Normalize(vmin=vmin, vmax=vmax)
|
|
347
|
+
colors_array = np.array([
|
|
348
|
+
transform_rgba(palette[value]) for value in values
|
|
349
|
+
])
|
|
350
|
+
return cmap, norm, colors_array
|
|
351
|
+
|
|
352
|
+
cmap = None
|
|
353
|
+
|
|
354
|
+
if is_color_like(palette):
|
|
355
|
+
colors = [transform_rgba(palette)]
|
|
356
|
+
cmap = ListedColormap(colors)
|
|
357
|
+
|
|
358
|
+
if cmap is None:
|
|
359
|
+
cmap = get_cmap(palette)
|
|
360
|
+
|
|
361
|
+
norm = Normalize(vmin=vmin, vmax=vmax)
|
|
362
|
+
colors_array = cmap(norm(values))
|
|
363
|
+
|
|
364
|
+
return cmap, norm, colors_array
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def parallel_plot(
|
|
369
|
+
data: Mapping[ColumnName, npt.ArrayLike] | DataframeLike | npt.ArrayLike,
|
|
370
|
+
columns: Iterable[ColumnName] | None=None,
|
|
371
|
+
column_labels: Iterable[str] | None=None,
|
|
372
|
+
color_column: Optional[ColumnName]=None,
|
|
373
|
+
palette: Optional[ColorLike | Mapping[Category, ColorLike] | Colormap]=None,
|
|
374
|
+
colormap_gradient: Optional[int]=None,
|
|
375
|
+
linewidth: Optional[float]=None,
|
|
376
|
+
alpha: Optional[float]=None,
|
|
377
|
+
highlight_rows: Mapping[SupportsIndex, RowStyle] | None=None,
|
|
378
|
+
decimals: Mapping[ColumnName, int] | None=None,
|
|
379
|
+
ax: Optional[plt.Axes]=None,
|
|
380
|
+
show_colorbar: bool=True
|
|
381
|
+
) -> plt.Axes:
|
|
382
|
+
|
|
383
|
+
if alpha is None:
|
|
384
|
+
alpha = 0.7
|
|
385
|
+
|
|
386
|
+
if linewidth is None:
|
|
387
|
+
linewidth = 1.0
|
|
388
|
+
|
|
389
|
+
if highlight_rows is None:
|
|
390
|
+
highlight_rows = {}
|
|
391
|
+
|
|
392
|
+
# NOTE: These variables defines:
|
|
393
|
+
# 1) y-axis limits (bottom, top)
|
|
394
|
+
# 2) colorbar.norm limits
|
|
395
|
+
ymin, ymax = -0.05, 1.05
|
|
396
|
+
|
|
397
|
+
# NOTE: Resolve column subsets
|
|
398
|
+
if columns is None:
|
|
399
|
+
columns = infer_columns(data)
|
|
400
|
+
columns = list(columns)
|
|
401
|
+
|
|
402
|
+
num_columns = len(columns)
|
|
403
|
+
|
|
404
|
+
if num_columns < 2:
|
|
405
|
+
raise ValueError('parallel-plot requires at least two columns')
|
|
406
|
+
|
|
407
|
+
# NOTE: Resolve column to color
|
|
408
|
+
if color_column is None:
|
|
409
|
+
color_column = columns[-1]
|
|
410
|
+
|
|
411
|
+
if color_column not in columns:
|
|
412
|
+
raise ValueError(f'color_column ({color_column!r}) not in {columns!r}')
|
|
413
|
+
|
|
414
|
+
# NOTE: Resolve x-labels
|
|
415
|
+
if column_labels is None:
|
|
416
|
+
column_labels = columns
|
|
417
|
+
column_labels = list(column_labels)
|
|
418
|
+
assert_same_lengths(columns, column_labels)
|
|
419
|
+
|
|
420
|
+
labels = dict_from_lists(columns, column_labels)
|
|
421
|
+
|
|
422
|
+
# NOTE: Transform data into a columnar mapping
|
|
423
|
+
# Assert all arrays have same length
|
|
424
|
+
series = map_columns(data, columns)
|
|
425
|
+
assert_same_lengths(*series.values())
|
|
426
|
+
|
|
427
|
+
# NOTE: Fit a numeric-transformer for each column
|
|
428
|
+
# Transform non-numeric to number
|
|
429
|
+
transformers = map_numeric_transformers(series, decimals)
|
|
430
|
+
series_transformed = {
|
|
431
|
+
column: transformers[column].transform(values)
|
|
432
|
+
for column, values in series.items()
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
# NOTE: Fit a scaler for each numeric column
|
|
436
|
+
# Transform numbers to 0-1 fractions
|
|
437
|
+
scalers = map_reescalers(series_transformed, lower=0, upper=1)
|
|
438
|
+
series_reescaled = {
|
|
439
|
+
column: scalers[column].transform(values)
|
|
440
|
+
for column, values in series_transformed.items()
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
# NOTE: Resolve colors
|
|
444
|
+
# Normalize pallete if is mapping
|
|
445
|
+
if isinstance(palette, Mapping):
|
|
446
|
+
color_column_transformer = transformers[color_column]
|
|
447
|
+
color_column_scaler = scalers[color_column]
|
|
448
|
+
color_column_values = series[color_column]
|
|
449
|
+
|
|
450
|
+
palette = dict(palette)
|
|
451
|
+
|
|
452
|
+
missing_categories = set(color_column_values).difference(palette)
|
|
453
|
+
if missing_categories:
|
|
454
|
+
missing_categories = sorted(missing_categories)
|
|
455
|
+
raise ValueError(
|
|
456
|
+
f'Invalid palette for color_column={color_column!r}. '
|
|
457
|
+
f'The following categories are missing from the palette: {missing_categories}. '
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
palette = {
|
|
461
|
+
color_column_transformer.transform(category): color
|
|
462
|
+
for category, color in palette.items()
|
|
463
|
+
}
|
|
464
|
+
palette = {
|
|
465
|
+
color_column_scaler.transform(index): color
|
|
466
|
+
for index, color in palette.items()
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
cmap, norm, colors_array = resolve_color_system(
|
|
470
|
+
series_reescaled[color_column],
|
|
471
|
+
palette=palette,
|
|
472
|
+
colormap_gradient=colormap_gradient,
|
|
473
|
+
vmin=ymin,
|
|
474
|
+
vmax=ymax)
|
|
475
|
+
|
|
476
|
+
# NOTE: Create parallel axes
|
|
477
|
+
# If show colorbar, then color-column should be moved to the end
|
|
478
|
+
ax = resolve_axes(ax)
|
|
479
|
+
|
|
480
|
+
if show_colorbar:
|
|
481
|
+
columns = move_to_end(columns, color_column)
|
|
482
|
+
parallel_axes = map_parallel_axes(columns, cmap=cmap, norm=norm, ax=ax, colorbar_at_end=show_colorbar)
|
|
483
|
+
|
|
484
|
+
# NOTE: Configure vertical-axes (position, ticks, ticklabels)
|
|
485
|
+
for index, column_name in enumerate(parallel_axes):
|
|
486
|
+
axes = parallel_axes[column_name]
|
|
487
|
+
scaler = scalers[column_name]
|
|
488
|
+
transformer = transformers[column_name]
|
|
489
|
+
|
|
490
|
+
axes.set_ylim(ymin, ymax)
|
|
491
|
+
|
|
492
|
+
axes.spines['top'].set_visible(False)
|
|
493
|
+
axes.spines['bottom'].set_visible(False)
|
|
494
|
+
|
|
495
|
+
if axes is not ax:
|
|
496
|
+
axes.spines['left'].set_visible(False)
|
|
497
|
+
axes.yaxis.set_ticks_position('right')
|
|
498
|
+
|
|
499
|
+
axes_position = index / (num_columns - 1)
|
|
500
|
+
axes.spines['right'].set_position(('axes', axes_position))
|
|
501
|
+
|
|
502
|
+
if isinstance(transformer, CategoricalTransformer):
|
|
503
|
+
indices = np.arange(transformer.num_categories)
|
|
504
|
+
yticks = scaler.transform(indices)
|
|
505
|
+
yticklabels = transformer.inverse_transform(indices)
|
|
506
|
+
else:
|
|
507
|
+
yticks = np.linspace(0, 1, 5, endpoint=True)
|
|
508
|
+
yticklabels = scaler.inverse_transform(yticks)
|
|
509
|
+
yticklabels = transformer.inverse_transform(yticklabels)
|
|
510
|
+
|
|
511
|
+
unique_yticklabels = np.unique(yticklabels)
|
|
512
|
+
|
|
513
|
+
if len(unique_yticklabels) == 1:
|
|
514
|
+
yticks = [0.5]
|
|
515
|
+
yticklabels = unique_yticklabels
|
|
516
|
+
|
|
517
|
+
axes.set_yticks(yticks, yticklabels)
|
|
518
|
+
|
|
519
|
+
# NOTE: Configure horizontal axes
|
|
520
|
+
x_positions = generate_indices(num_columns)
|
|
521
|
+
|
|
522
|
+
ax.set_xticks(ticks=x_positions, labels=[labels[column_name] for column_name in columns])
|
|
523
|
+
ax.tick_params(axis='x', which='major', pad=7, zorder=10)
|
|
524
|
+
|
|
525
|
+
ax.spines['right'].set_visible(False)
|
|
526
|
+
ax.xaxis.tick_top()
|
|
527
|
+
|
|
528
|
+
# NOTE: Resolve highlight rows
|
|
529
|
+
highlight_rows = {
|
|
530
|
+
to_index(row): style for row, style in highlight_rows.items()
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
# NOTE: Draw Bezier curvers
|
|
534
|
+
rows = iter_rows(series_reescaled[column_name] for column_name in columns)
|
|
535
|
+
|
|
536
|
+
for row_index, row_values in enumerate(rows):
|
|
537
|
+
row_style = highlight_rows.get(row_index, None)
|
|
538
|
+
|
|
539
|
+
if row_style is None:
|
|
540
|
+
row_style = {
|
|
541
|
+
'edgecolor': colors_array[row_index],
|
|
542
|
+
'linewidth': linewidth,
|
|
543
|
+
'alpha': alpha
|
|
544
|
+
}
|
|
545
|
+
row_style = dict(row_style)
|
|
546
|
+
row_style.setdefault('facecolor', 'none')
|
|
547
|
+
|
|
548
|
+
vertices, codes = cubic_bezier_path(x_positions, row_values)
|
|
549
|
+
path = Path(vertices, codes)
|
|
550
|
+
|
|
551
|
+
patch = PathPatch(path, **row_style)
|
|
552
|
+
ax.add_patch(patch)
|
|
553
|
+
|
|
554
|
+
return ax
|
|
555
|
+
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pypcoords
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Flexible parallel coordinates plot with support for NumPy, pandas, and polars.
|
|
5
|
+
Author: Lucas Detogni
|
|
6
|
+
Project-URL: Homepage, https://github.com/LucasMainUser/pypcoords
|
|
7
|
+
Keywords: visualization,matplotlib,parallel coordinates,plot
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Requires-Python: >=3.11
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
Requires-Dist: matplotlib>=3.10.9
|
|
14
|
+
|
|
15
|
+
# pypcoords
|
|
16
|
+
|
|
17
|
+
Simple and flexible **parallel coordinates plot** implementation built on top of `matplotlib`.
|
|
18
|
+
|
|
19
|
+
Supports multiple data formats out of the box, including **NumPy**, **pandas**, and **polars**.
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## β¨ Features
|
|
24
|
+
|
|
25
|
+
* π Parallel coordinates visualization
|
|
26
|
+
* π Works with:
|
|
27
|
+
|
|
28
|
+
* dict / mappings
|
|
29
|
+
* NumPy arrays
|
|
30
|
+
* pandas DataFrame
|
|
31
|
+
* polars DataFrame
|
|
32
|
+
* π¨ Flexible color system (colormap or custom colors)
|
|
33
|
+
* π§ Automatic handling of:
|
|
34
|
+
|
|
35
|
+
* numeric data
|
|
36
|
+
* categorical data
|
|
37
|
+
* π§΅ Smooth curves using cubic BΓ©zier interpolation
|
|
38
|
+
* π― Row highlighting support
|
|
39
|
+
|
|
40
|
+
---
|
|
41
|
+
|
|
42
|
+
## π¦ Installation
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pip install pypcoords
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
---
|
|
49
|
+
|
|
50
|
+
## π Quick Example
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
from pypcoords import parallel_plot
|
|
54
|
+
|
|
55
|
+
data = {
|
|
56
|
+
"A": [1, 2, 3],
|
|
57
|
+
"B": [4, 5, 6],
|
|
58
|
+
"C": [7, 8, 9],
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
parallel_plot(data)
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
## π¨ With colors
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
parallel_plot(
|
|
70
|
+
data,
|
|
71
|
+
colormap="viridis",
|
|
72
|
+
colormap_column="C"
|
|
73
|
+
)
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
---
|
|
77
|
+
|
|
78
|
+
## π Highlight specific rows
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
parallel_plot(
|
|
82
|
+
data,
|
|
83
|
+
highlight_rows={
|
|
84
|
+
1: {"edgecolor": "red", "linewidth": 2.5}
|
|
85
|
+
}
|
|
86
|
+
)
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
---
|
|
90
|
+
|
|
91
|
+
## π Supported input formats
|
|
92
|
+
|
|
93
|
+
* Mapping of column names to arrays
|
|
94
|
+
* pandas DataFrame
|
|
95
|
+
* polars DataFrame
|
|
96
|
+
* 2D array-like (NumPy, lists, etc.)
|
|
97
|
+
|
|
98
|
+
---
|
|
99
|
+
|
|
100
|
+
## π License
|
|
101
|
+
|
|
102
|
+
MIT
|
|
103
|
+
|
|
104
|
+
---
|
|
105
|
+
|
|
106
|
+
## π€ Contributing
|
|
107
|
+
|
|
108
|
+
Contributions are welcome!
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/pypcoords/__init__.py
|
|
4
|
+
src/pypcoords/core.py
|
|
5
|
+
src/pypcoords.egg-info/PKG-INFO
|
|
6
|
+
src/pypcoords.egg-info/SOURCES.txt
|
|
7
|
+
src/pypcoords.egg-info/dependency_links.txt
|
|
8
|
+
src/pypcoords.egg-info/requires.txt
|
|
9
|
+
src/pypcoords.egg-info/top_level.txt
|
|
10
|
+
tests/test_parallel_plot.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
matplotlib>=3.10.9
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pypcoords
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from pypcoords import parallel_plot
|
|
2
|
+
import matplotlib.pyplot as plt
|
|
3
|
+
|
|
4
|
+
def main() -> None:
|
|
5
|
+
data = {
|
|
6
|
+
'city': ['SP', 'RJ', 'SP', 'MG', 'RJ', 'BA', 'RS', 'SP', 'BA', 'MG'],
|
|
7
|
+
'age': [25, 32, 47, 51, 62, 29, 41, 38, 55, 60],
|
|
8
|
+
'income': [3000, 5200, 8000, 6200, 7000, 4500, 5800, 9100, 4300, 6400],
|
|
9
|
+
'segment': ['A', 'B', 'A', 'B', 'A', 'C', 'B', 'A', 'C', 'B'],
|
|
10
|
+
'score': [0.2, 0.5, 0.9, 0.7, 0.85, 0.3, 0.6, 0.95, 0.4, 0.75],
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
parallel_plot(
|
|
14
|
+
data,
|
|
15
|
+
color_column='score',
|
|
16
|
+
palette='magma_r',
|
|
17
|
+
linewidth=1.0,
|
|
18
|
+
alpha=0.5,
|
|
19
|
+
decimals={'score': 2},
|
|
20
|
+
show_colorbar=True,
|
|
21
|
+
colormap_gradient=None,
|
|
22
|
+
highlight_rows=None
|
|
23
|
+
)
|
|
24
|
+
plt.show()
|
|
25
|
+
|
|
26
|
+
if __name__ == '__main__':
|
|
27
|
+
main()
|