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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ from pypcoords.core import parallel_plot
2
+
3
+ __all__ = ['parallel_plot']
@@ -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
+ 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()