ialdev-core 0.1.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.
Files changed (48) hide show
  1. iad/core/__init__.py +9 -0
  2. iad/core/array.py +1961 -0
  3. iad/core/binary.py +377 -0
  4. iad/core/cache.py +903 -0
  5. iad/core/codetools.py +203 -0
  6. iad/core/datatools.py +671 -0
  7. iad/core/docs/locators.ipynb +754 -0
  8. iad/core/dotstyle.py +99 -0
  9. iad/core/env.py +271 -0
  10. iad/core/events.py +650 -0
  11. iad/core/filesproc.py +1046 -0
  12. iad/core/fnctools.py +390 -0
  13. iad/core/label.py +240 -0
  14. iad/core/logs.py +182 -0
  15. iad/core/nptools.py +449 -0
  16. iad/core/one_dark.puml +881 -0
  17. iad/core/param/__init__.py +17 -0
  18. iad/core/param/confargparse.py +55 -0
  19. iad/core/param/paramaze.py +339 -0
  20. iad/core/param/tbox.py +277 -0
  21. iad/core/paths.py +563 -0
  22. iad/core/pdtools.py +2570 -0
  23. iad/core/pydantools/__init__.py +5 -0
  24. iad/core/pydantools/fixed_pydantic_yaml/__init__.py +32 -0
  25. iad/core/pydantools/fixed_pydantic_yaml/compat/__init__.py +0 -0
  26. iad/core/pydantools/fixed_pydantic_yaml/compat/hacks.py +76 -0
  27. iad/core/pydantools/fixed_pydantic_yaml/compat/old_enums.py +37 -0
  28. iad/core/pydantools/fixed_pydantic_yaml/compat/representers.py +92 -0
  29. iad/core/pydantools/fixed_pydantic_yaml/compat/types.py +122 -0
  30. iad/core/pydantools/fixed_pydantic_yaml/compat/yaml_lib.py +104 -0
  31. iad/core/pydantools/fixed_pydantic_yaml/ext/__init__.py +1 -0
  32. iad/core/pydantools/fixed_pydantic_yaml/ext/semver.py +152 -0
  33. iad/core/pydantools/fixed_pydantic_yaml/ext/versioned_model.py +113 -0
  34. iad/core/pydantools/fixed_pydantic_yaml/main.py +30 -0
  35. iad/core/pydantools/fixed_pydantic_yaml/mixin.py +281 -0
  36. iad/core/pydantools/fixed_pydantic_yaml/model.py +20 -0
  37. iad/core/pydantools/fixed_pydantic_yaml/py.typed +1 -0
  38. iad/core/pydantools/fixed_pydantic_yaml/version.py +1 -0
  39. iad/core/pydantools/models.py +560 -0
  40. iad/core/regexp.py +348 -0
  41. iad/core/short.py +308 -0
  42. iad/core/strings.py +635 -0
  43. iad/core/unc_panda.py +270 -0
  44. iad/core/units.py +58 -0
  45. iad/core/wrap.py +420 -0
  46. ialdev_core-0.1.0.dist-info/METADATA +73 -0
  47. ialdev_core-0.1.0.dist-info/RECORD +48 -0
  48. ialdev_core-0.1.0.dist-info/WHEEL +4 -0
iad/core/pdtools.py ADDED
@@ -0,0 +1,2570 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import re
5
+ import warnings
6
+ from collections import namedtuple
7
+ from dataclasses import dataclass
8
+ from functools import cmp_to_key
9
+ from io import StringIO
10
+ from numbers import Number
11
+ from pathlib import PurePath
12
+ from typing import Union, Collection, Iterable, Sequence, Any, Callable, Literal, Generator, TYPE_CHECKING
13
+
14
+ if TYPE_CHECKING:
15
+ from .label import Labels
16
+
17
+ import numpy as np
18
+ import pandas as pd
19
+ from IPython import display as ipy_disp
20
+ from pandas.core.dtypes.common import is_list_like
21
+
22
+ from . import strings as stt, codetools as cdt, nptools as npt
23
+ from . import wrap
24
+ from .datatools import select_from, issubset_report
25
+ from .short import as_list, unless_subset
26
+
27
+ # https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy
28
+ # Copy-on-Write will become the new default in pandas 3.0. This means than chained indexing will never work.
29
+ # As a consequence, the SettingWithCopyWarning won’t be necessary anymore. See this section for more context.
30
+ # We recommend turning Copy-on-Write on to leverage the improvements with:
31
+ # pd.options.mode.copy_on_write = True
32
+ pd.options.display.precision = 3
33
+ pd.options.display.float_format = '{:.3}'.format
34
+ pd.options.display.max_colwidth = 40
35
+ pd.options.display.width = 120
36
+
37
+ TCol = Union[str, tuple[str]]
38
+ PTable = Union[pd.DataFrame, pd.Series] # pandas table-like types
39
+ DTable = Union['DataSeries', 'DataTable'] # derived in this module Data* Table like types
40
+ StringS = Union[Sequence[str], str]
41
+
42
+ AxisID = Literal[0, 1, 'index', 'columns']
43
+ AxisT = Union[AxisID, Collection[AxisID]]
44
+
45
+ _ALL = slice(None)
46
+ NA = cdt.NamedObj('NA')
47
+
48
+
49
+ def toy_table(rows: int = 4, cols: int | Collection[str] = 2, index: int | Collection[str] = 2):
50
+ """
51
+ Create a sample DataTable for experiments
52
+ :param rows: number of rows
53
+ :param cols: number of columns or their names
54
+ :param index: number of index levels or their names
55
+ :return: DataTable
56
+ """
57
+ if isinstance(index, int):
58
+ index = [f"i{_}" for _ in range(index)]
59
+
60
+ if isinstance(cols, int):
61
+ cols = [chr(ord('a') + i) for i in range(cols)]
62
+
63
+ data = [{c: 10 * i + j for i, c in enumerate(as_list(index) + as_list(cols))}
64
+ for j in range(rows)]
65
+
66
+ data = DataTable(data)
67
+ return data.set_index(index) if index else data
68
+
69
+
70
+ @dataclass
71
+ class Parallel:
72
+ """
73
+ Parallelization for DataFrame processing (apply) supporting different
74
+ methods (packages): swifter, joblib, no parallel (pandas)
75
+
76
+ Objects are initialized by the parallelization parameters:
77
+ """
78
+ swift: bool = False
79
+ jobs: int = False
80
+ pbar: bool = True
81
+ strict: bool = False
82
+
83
+ methods = dict(swifter='swift', joblib='jobs')
84
+
85
+ def __post_init__(self):
86
+ from importlib import import_module
87
+
88
+ def check(method):
89
+ par = getattr(self, name := self.methods[method])
90
+ if par:
91
+ if not isinstance(par, (int, bool)):
92
+ raise ValueError(f'Field {name} must be bool or int')
93
+ try:
94
+ import_module(method)
95
+ except ModuleNotFoundError as e:
96
+ if self.strict:
97
+ raise e
98
+ setattr(self, name, False)
99
+ logging.getLogger().warning(
100
+ f'Package `{e.name}` is missing, trying another option')
101
+ return 1
102
+ return 0
103
+
104
+ if sum(map(check, self.methods)) > 1 and self.strict:
105
+ raise RuntimeError('Only one parallelization method must be selected')
106
+
107
+ @classmethod
108
+ def from_flag(cls, parallel: Parallel | str | bool):
109
+ """Create Parallel object from flag if it's
110
+ either method's name or True or threads number.
111
+ Default method is swifter.
112
+
113
+ If ``not bool(parallel)`` return ``None``
114
+
115
+ If ``isinstance(parallel, Parallel)`` return ``parallel``
116
+
117
+ Otherwise, raise ValueError.
118
+ """
119
+ if not parallel:
120
+ return None
121
+ if isinstance(parallel, Parallel):
122
+ return parallel
123
+ if isinstance(parallel, (bool, int)):
124
+ return Parallel(swift=parallel, jobs=True, strict=False)
125
+ if isinstance(parallel, str):
126
+ if parallel not in cls.methods.values():
127
+ raise ValueError(f"Unsupported parallel method {parallel}")
128
+ return cls(**{parallel: True})
129
+ raise ValueError("Invalid parallel request")
130
+
131
+ def __bool__(self):
132
+ return any(self.methods.values())
133
+
134
+ @staticmethod
135
+ def applicable(row_fnc):
136
+ def fnc_to_apply(row):
137
+ """prepare wrapped function to be used in apply"""
138
+ if isinstance(row, pd.DataFrame): # refuse attempt to process multiple rows
139
+ raise TypeError('Signal of no vectorization support (to swifter)')
140
+ res = row_fnc(row)
141
+ if isinstance(res, (dict, Sequence)):
142
+ return pd.Series(res)
143
+ return res
144
+
145
+ return fnc_to_apply
146
+
147
+ def swift_apply(self, df: pd.DataFrame, func: Callable[[pd.Series, ...], pd.Series],
148
+ args: tuple = (), **kws):
149
+ """
150
+ Parallel apply provided function on the rows of the given DataFrame using swifter package.
151
+
152
+ Function must:
153
+ 1. receive Series as first argument
154
+ 2. return Series, even if a single value calculated per row
155
+ 3. rise in attempt to provide a DataFrame (deny vectorization support).
156
+
157
+ Such function can be constructed from a function returning dict or sequence
158
+ using ``applicable`` decorator of the ``Parallel`` class.
159
+
160
+ :param df:
161
+ :param func:
162
+ :param args: tuple with additional func arguments
163
+ :param kws: DataFrame.apply AND func keyword arguments
164
+ :return: table with results
165
+ """
166
+ return (df
167
+ .reset_index(drop=True) # Dask does not support MultiIndex Dataframes.
168
+ .swifter.allow_dask_on_strings() # activate swifter
169
+ .set_npartitions(self.swift is True and None or self.swift) # default is CPUS * 2
170
+ .set_dask_threshold(0).set_dask_scheduler('threads')
171
+ .apply(func, args=args, **kws) # apply the function
172
+ .set_index(df.index))
173
+
174
+ def jobs_apply(self, df: pd.DataFrame,
175
+ func: Callable[[pd.Series, ...], dict | Sequence], args=(), **kws):
176
+ """
177
+ Implements parallel apply of per-row function on a ``DataFrame`` using ``joblib`` package.
178
+ Supplied ``row_func`` must be able to receive a row (pd.Series) as a first argument,
179
+ and return either a dict (to produce multiple columns) or a Sequence with one element.
180
+
181
+ :param df: DataFrame object to apply on
182
+ :param func: function to be applied on a single row
183
+ :param args: optional additional arguments to the row_func
184
+ :param kws: optional keyword arguments to the row_func
185
+ :return: resulted table (same class as df)
186
+ """
187
+ import joblib as jb
188
+ from .events import tqdm_joblib
189
+
190
+ par = self.jobs if isinstance(self.jobs, dict) else dict(
191
+ backend='loky',
192
+ batch_size='auto',
193
+ n_jobs=self.jobs is True and 16 or self.jobs,
194
+ verbose=0
195
+ )
196
+ with tqdm_joblib(desc="Processing rows in parallel", total=len(df), disable=not self.pbar):
197
+ res = jb.Parallel(**par)(jb.delayed(func)(it[1], *args, **kws) for it in df.iterrows())
198
+ return df.__class__.from_records(res, index=df.index)
199
+
200
+ def apply(self, df: pd.DataFrame, func: Callable[[pd.Series, ...], dict | Sequence],
201
+ axis=1, result_type='expand', args=(), **kws):
202
+ """
203
+ Parallel apply of per-row function on a ``DataFrame`` using active method.
204
+ Supplied ``row_func`` must be able to receive a row (pd.Series) as a first argument,
205
+ and return either a dict (to produce multiple columns) or a Sequence with one element.
206
+
207
+ :param df: DataFrame object to apply on
208
+ :param func: function to be applied on a single row
209
+ :param args: tuple with optional additional arguments to the row_func
210
+ :param kws: optional keyword arguments to the row_func
211
+ :param axis: must be 1!
212
+ :param result_type: must be 'expand'!
213
+ :return: resulted table (same class as df)
214
+ """
215
+ if not axis == 1 and result_type == 'expand':
216
+ raise NotImplementedError("Currently only support: axis=1, result_type='expand'")
217
+
218
+ if self.jobs:
219
+ return self.jobs_apply(df, func, *args, *kws)
220
+
221
+ opt = dict(func=self.applicable(func), axis=1, result_type='expand', args=args, **kws)
222
+ if self.swift: # jobs and swift has been verified above to be mutually exclusive
223
+ return self.swift_apply(df, **opt)
224
+ return df.apply(**opt)
225
+
226
+
227
+ def col_args_row_func(fnc, *, pos: StringS = None, kwcol: StringS = None,
228
+ alias: dict[str, str] = None, out: StringS = True,
229
+ cols: StringS = None):
230
+ """
231
+ Wraps given function ``fnc`` accepting regular positional and keyword arguments
232
+ into ``row_fnc`` accepting dict-like object (Series) and redicts its fields
233
+ into ``fnc`` arguments according to the mappings directives defined by
234
+ ``pos``, ``kwcol`` and ``alias``.
235
+
236
+ **Warning**: if no columns -> arguments correspondence are provided ALL the columns
237
+ are passed as positional arguments!
238
+
239
+ Also outputs of the ``fnc`` may be named (or renamed if its dict) using strings in ``out``,
240
+ (must match amount of the outputs).
241
+
242
+ Resulted function may be applied to an arbitrary DataFrames with given all the
243
+ columns referred in the arguments are available.
244
+ (That can be verified if optional ``cols`` argument provides list of columns names)
245
+
246
+ **Note**! Output of the ``DataFrame.apply`` with ``result_type='expand'`` depends
247
+ on the type of the ``row_fnc`` output:
248
+ - ``DataFrame`` if it's a ``dict`` with columns named as its keys
249
+ - ``DataFrame`` with enumerated columns if it's a ``list``
250
+ - ``Series`` if it's a scalar (other objects including numpy array)
251
+ So, to make ``apply`` produce a DataFrame even if ``fnc`` returns a scalar,
252
+ provide ``out=name`` and ``row_fnc`` will return ``{name: scalar}`` instead.
253
+
254
+ Default ``out=True`` will automatically generate ``{fnc.__name__: scalar}``
255
+ IFF ``fnc`` produces scalars.
256
+
257
+ To avoid output manipulation set ``out`` to ``True`` or ``None``.
258
+
259
+ :param fnc: function to apply, may return either a scalar or dict or tuple
260
+ :param pos: list of columns names to use as positional arguments
261
+ :param kwcol: fnc keyword arguments which are same as columns names
262
+ :param alias: mapping from columns corresponding fnc kw args {col: kw}
263
+ :param out: if list (type) is passed ensures "vectorized" output even if
264
+ scalar (anything but dict, list, tuple)
265
+ results of fnc "name(s) of the resulted columns. If function returns a scalar and
266
+ out is not defined, apply will return a Series!
267
+ :param cols: Optional list of columns names expected in the DataFrame to apply to
268
+ for consistency validation.
269
+
270
+ :return: row_fnc(row: dict, *args, **kws) -> dict | ...
271
+ """
272
+ pos, kwcol, out = map(as_list, [pos, kwcol, out])
273
+ alias = alias or {}
274
+
275
+ groups = [pos, kwcol, alias]
276
+ assert not any(set(groups[i]).intersection(groups[i - 1]) for i in range(len(groups))), \
277
+ "Same column appears in different argument categories!"
278
+
279
+ assert all(map(lambda s: isinstance(s, str), [*pos, *kwcol, *alias, *alias.values()]))
280
+ mapping_defined = any(map(len, groups))
281
+
282
+ if cols is None:
283
+ mapping_defined or warnings.warn(
284
+ "Neither columns->arguments mappings nor expected columns been defined."
285
+ "Will use ALL the available columns as arguments when applied")
286
+ else:
287
+ assert all(map(lambda x: set(cols).issuperset(x), groups)), "Not a column name!"
288
+
289
+ if out is True:
290
+ out_name = fnc.__name__
291
+
292
+ def row_fnc(row: pd.Series, *args, **kws):
293
+ """Wraps function input and output"""
294
+ if mapping_defined:
295
+ pos_args = row[pos]
296
+ kwd_args = {k: row[c] for c, k in alias.items()}
297
+ kwd_args.update(**row[kwcol], **kws)
298
+ else:
299
+ pos_args = row.values
300
+ kwd_args = {}
301
+
302
+ res = fnc(*pos_args, *args, **kwd_args)
303
+
304
+ if not out:
305
+ return res
306
+ if out is True:
307
+ return isinstance(res, (tuple, list, dict)) and res or {out_name: res}
308
+ # here out is always a not empty list
309
+ if isinstance(res, dict):
310
+ return dict(zip(out, res.values(), strict=True))
311
+ if len(out) == 1 and not (hasattr(res, '__len__') and len(res) == 1):
312
+ res = [res]
313
+ return dict(zip(out, res))
314
+
315
+ return row_fnc
316
+
317
+
318
+ def apply_col_args(df: pd.DataFrame, fnc: Callable[[...], dict | tuple | Any],
319
+ *, pos: StringS = None, kwcol: StringS = None,
320
+ alias: dict[str, str] = None, out: StringS = None,
321
+ parallel: Parallel = False, args=(), **kws) -> pd.DataFrame:
322
+ """
323
+ Apply function on DataFrame by rows using specific columns as arguments.
324
+
325
+ Place results into specified columns or return as a new data-frame.
326
+
327
+ Columns can be used as positinal or keyword arguments,
328
+ with supported translation from columns to keyword names.
329
+
330
+ Parallel application of the function is supported using either
331
+ `swifter` or `joblib` packages. (falls back to reqular apply if not installed).
332
+
333
+ >>> def f(aleph, beth): return aleph + beth
334
+ >>> df = pd.DataFrame({'x': [1, 2], 'y': [3, 4], 'z': [5,6]})
335
+ >>> res = apply_col_args(df, f, pos=['x','z'], out='sum'); res # selects x, z as positional
336
+ sum
337
+ 0 6
338
+ 1 8
339
+ >>> def g(aleph, beth): return {'sum': aleph + beth}
340
+ >>> apply_col_args(df, g, alias={'x': 'aleph','z': 'beth'}).equals(res) # map columns toi args names
341
+ True
342
+ >>> apply_col_args(df[['x', 'z']], g, swift=True).equals(res) # use all provided columns as positional arguments
343
+ True
344
+ >>> apply_col_args(df['x'], lambda x: x+10, out='inc10', swift=True)
345
+ inc10
346
+ 0 11
347
+ 1 12
348
+ >>> def min_max(*a): return {'min': min(a), 'max': max(a)}
349
+ >>> apply_col_args(df, min_max, jobs=2)
350
+ min max
351
+ 0 1 5
352
+ 1 2 6
353
+
354
+ :param df: The source data frame (or Series)
355
+ :param fnc: function to apply, may return either a scalar or dict or tuple
356
+ :param pos: list of columns names to use as positional arguments
357
+ :param kwcol: fnc keyword arguments which are same as columns names
358
+ :param alias: mapping from columns corresponding fnc kw args {col: kw}
359
+ :param out: name(s) of the resulted columns. If function returns a scalar and
360
+ out is not defined, apply will return a Series!
361
+ :param parallel: Parallel object to define parallel execution
362
+ :param args: additional positional arguments for `fnc` appended after pos
363
+ :param kws: additional keyword arguments for `fnc`
364
+
365
+ :return: source `df` if `inplace` or new data-frame with results
366
+ """
367
+ if isinstance(df, pd.Series):
368
+ df = df.to_frame()
369
+
370
+ row_fnc = col_args_row_func(fnc, pos=pos, kwcol=kwcol, alias=alias, out=out, cols=df.columns)
371
+
372
+ parallel = Parallel.from_flag(parallel) or df.__class__
373
+ return parallel.apply(df, row_fnc, axis=1, result_type='expand', *args, **kws)
374
+
375
+
376
+ def index_like(index: pd.Index, *, as_tuple=True, **kws) -> pd.MultiIndex:
377
+ """
378
+ Create multi-index item based on the input one, with specific levels
379
+ replaced by the values in the arguments.
380
+
381
+ :param index: index item from multi-index (first is used)
382
+ :param as_tuple: return as tuple
383
+ :param kws: {level_name: new_level_value} - to replace
384
+ :return: Multi-index of same structure as `index`
385
+ """
386
+ dix = index.to_frame(False).T[0].to_dict()
387
+ idx = tuple(kws.get(k, v) for k, v in dix.items())
388
+ return idx if as_tuple else \
389
+ pd.MultiIndex.from_tuples([idx], names=index.names)
390
+
391
+
392
+ def set_index_levels(index: pd.MultiIndex, *, pass_named=True, **kws) -> pd.MultiIndex:
393
+ """In the given index set specific levels to provided values.
394
+
395
+ The values could be scalar, vector of index length or callable
396
+ receiving a tuple of index values of the corresponding index row.
397
+
398
+ May set existing or add new levels to the index.
399
+
400
+ :param index: index to alter
401
+ :param pass_named: if True, pass index tuple as named using index names
402
+ :param kws: {level_name: val|fnc}
403
+ :return: New MultiIndex
404
+ """
405
+ callables = [*map(lambda v: isinstance(v, Callable), kws.values())]
406
+ if fnc_num := sum(callables):
407
+ index = (
408
+ map(namedtuple("Index", [*index.names]), index) if pass_named
409
+ else [*index] if fnc_num > 1
410
+ else index
411
+ ) # avoid redundant namedtuple creations
412
+
413
+ f = index.to_frame(False) # operate on columns is more efficient
414
+ for call, (name, val) in zip(callables, kws.items()):
415
+ f[name] = [*map(val, index)] if call else val
416
+
417
+ return pd.MultiIndex.from_frame(f)
418
+
419
+
420
+ def redundant_levels(index: pd.MultiIndex):
421
+ """
422
+ Return list of levels with one unique value
423
+ :param index:
424
+ :return:
425
+ """
426
+ return [i for i in range(index.nlevels) if index.unique(i).size == 1]
427
+
428
+
429
+ def squeeze_levels(index: pd.MultiIndex):
430
+ """
431
+ Return multiindex with removed levels with a single unique value
432
+ :param index:
433
+ :return:
434
+ """
435
+ redundant = redundant_levels(index)
436
+ return index.droplevel(redundant) if redundant else index
437
+
438
+
439
+ class IndexModifier:
440
+ """
441
+ Utility class to replace values of selected levels in multi index
442
+ """
443
+ IndexForm = Literal['list', 'tuple']
444
+ LevelValue = Union[Callable, str, int, float]
445
+ Change = tuple[int, LevelValue]
446
+
447
+ def __init__(self, names: Sequence[str] | pd.MultiIndex):
448
+ if isinstance(names, pd.MultiIndex):
449
+ names = names.names
450
+ self.names = names
451
+ self._pos = dict(zip(names, range(len(names))))
452
+ self.name_index_tuple = namedtuple('IndexTuple', names)._make
453
+
454
+ def __repr__(self):
455
+ return f"{self.__class__.__name__}[{', '.join(self.names)}]"
456
+
457
+ @staticmethod
458
+ def separate_calls(mixed: dict[str, Any | Callable]) -> tuple[dict[str, Any], dict[str, Callable]]:
459
+ """Split the dict by separating items with Callable values"""
460
+ changes = mixed.copy()
461
+ calls = {}
462
+ for k, v in mixed.items():
463
+ if hasattr(v, '__call__'):
464
+ calls[k] = changes.pop(k)
465
+ return changes, calls
466
+
467
+ @staticmethod
468
+ def unbound_modifiers(group_name: str = '', call_named=True, named=True,
469
+ **named_changes: dict[str, LevelValue]
470
+ ) -> Callable[[IndexModifier, tuple], tuple[tuple, ...]]:
471
+ """
472
+ Given multiple (group) named dicts of changes ``{change_name: {level: value}}``
473
+ to be applied to the specified levels of a multi-index item,
474
+ create a function ``indexer(index: tuple)`` applying every one of them on its argument (index tuple)
475
+ and returning list or named tuple of resulting indices named accordingly.
476
+
477
+ :param group_name: [positional argument!] Name of this group of indexes
478
+ :param call_named: callable require named index tuples
479
+ :param named: create indexers producing named tuples
480
+ :param named_changes: dict of groups of changes {ch_name: {lv_name: lv_val}}
481
+ :return: function: amend(index) -> namedtuple(changed_index, ...)
482
+ """
483
+ changes_calls_list = [*map(IndexModifier.separate_calls, named_changes.values())]
484
+ changes_list, calls_list = zip(*changes_calls_list)
485
+ has_calls = any(calls_list)
486
+ if not (has_calls or any(changes_list)):
487
+ raise ValueError("Required changes may not be empty")
488
+
489
+ def create_index_calls(imod, index, changes, calls):
490
+ changes = changes | {k: call(index) for k, call in calls.items()}
491
+ make_tuple = imod.name_index_tuple if named else tuple
492
+ return make_tuple(map(changes.get, imod.names, index))
493
+
494
+ def create_index(imod, index, changes):
495
+ make_tuple = imod.name_index_tuple if named else tuple
496
+ return make_tuple(map(changes.get, imod.names, index))
497
+
498
+ if len(changes_calls_list) == 1: # no_groups
499
+ _changes, _calls = changes_calls_list[0]
500
+ if has_calls: # create different functions to optimize for each case (~30%)
501
+ def replace(idx_mod: IndexModifier, index: tuple):
502
+ if call_named and not hasattr(index, '_fields'): # convert to named if needed
503
+ index = idx_mod.name_index_tuple(index) # as call requires
504
+ return create_index_calls(idx_mod, index, _changes, _calls)
505
+ elif named: # no calls no groups
506
+ def replace(idx_mod: IndexModifier, index): # no callables here
507
+ return create_index(idx_mod, index, _changes)
508
+ else: # not named tuples, no calls, no groups - the fastest implementation
509
+ def replace(idx_mod: IndexModifier, index): # no callables here
510
+ return tuple(map(_changes.get, idx_mod.names, index))
511
+ else:
512
+ make_group_tuple = wrap.namedtuple(group_name, named_changes)._make if group_name else tuple
513
+ if has_calls:
514
+ def replace(idx_mod: IndexModifier, index: tuple):
515
+ """Version supporting callables"""
516
+ if call_named and not hasattr(index, '_fields'): # convert to named if needed
517
+ index = idx_mod.name_index_tuple(index) # as call requires
518
+ return make_group_tuple(create_index_calls(idx_mod, index, changes, calls)
519
+ if calls else create_index(idx_mod, index, changes)
520
+ for changes, calls in changes_calls_list)
521
+ elif named:
522
+ def replace(idx_mod: IndexModifier, index): # no callables here
523
+ return make_group_tuple(create_index(idx_mod, index, changes)
524
+ for changes in changes_list)
525
+ else:
526
+ def replace(idx_mod: IndexModifier, index): # no callables here
527
+ return make_group_tuple(tuple(map(changes.get, idx_mod.names, index))
528
+ for changes in changes_list)
529
+
530
+ return replace
531
+
532
+ def replace_at(self, index: tuple, items: Iterable[Change]) -> tuple:
533
+ """
534
+ Return tuple with given positions set to new values
535
+ :param index: tuple with index values
536
+ :param items: tuple of (position in the index tuple value to set)
537
+ :return: updated index tuple
538
+
539
+ Example:
540
+ >>> self.replace_at()
541
+
542
+ """
543
+ r = list(index)
544
+
545
+ for i, v in items:
546
+ if hasattr(v, '__call__'):
547
+ if not hasattr(index, '_fields'): # skip transform if already namedtuple
548
+ index = self.name_index_tuple(index)
549
+ r[i] = v(index)
550
+ else:
551
+ r[i] = v
552
+ return type(index)(r)
553
+
554
+ def validate_modifiers(self, mods: dict, fail=False):
555
+ """
556
+ Check validity of the ``changes``: it must be a dict with keys from known names.
557
+ Return boolean or rise exception depending on ``fail`` argument.
558
+ :param mods: dict with changes
559
+ :param fail: raise on invalid if True
560
+ :return: True if valid
561
+ """
562
+ try:
563
+ if not isinstance(mods, dict):
564
+ raise TypeError(f'Changes ({type(mods)}) must be dict in {self}!')
565
+ if inv := set(mods).difference(self.names):
566
+ raise KeyError(f'Unknown levels {inv} in {self}')
567
+ for k, v in mods.items():
568
+ if not isinstance(v, (Callable, Number, str)):
569
+ raise TypeError(f"Invalid label '{k}' modifier type {type(v)}")
570
+ except Exception as ex:
571
+ if fail: raise ex
572
+ return False
573
+ return True
574
+
575
+ def iter_named(self, indices: Iterable[tuple]) -> Iterable[tuple]:
576
+ """Convert an iterable over index items in form of tuples (like a MultiIndex)
577
+ into an iterator over named tuples, with names as provided to ``__init__``.
578
+ """
579
+ return (self.name_index_tuple(ii) for ii in indices)
580
+
581
+ def replace_levels(self, index: pd.MultiIndex | tuple, *changes: Iterable[Change],
582
+ **kws: LevelValue) -> pd.MultiIndex | tuple:
583
+ """
584
+ Replace values of specific levels in MultiIndex or a single Multiindex item.
585
+ Return correspondingly a copy of the index or of the items
586
+ with selected levels set to new values.
587
+
588
+ Values may be either a scalar, or vector with length of the index, or function,
589
+ in which case values be calculated for every index row by passing it to
590
+ the function as a namedtuple.
591
+
592
+ :param index: MultiIndex where levels will be replaced
593
+ :param changes: positions in the tuples of the index to set
594
+ :param kws: alternatively {name: value} provide levels values by their names
595
+ :return: MultiIndex with updated levels values
596
+ """
597
+
598
+ changes = [*changes, *self.iter_changes(**kws)]
599
+ if isinstance(index, tuple): # a single tuple to turn
600
+ return self.replace_at(index, *changes)
601
+
602
+ kws = {self.names[i]: val for i, val in changes} | kws
603
+ return set_index_levels(index, **kws)
604
+
605
+ def iter_changes(self, **alterations: LevelValue):
606
+ """From key-value dict create iterator over items for an amendment."""
607
+ return zip(map(self._pos.get, alterations), alterations.values())
608
+
609
+ def changes(self, **alteration: LevelValue) -> tuple[Change, ...]:
610
+ """Pack description of changes provided in form of
611
+ keywords dictionary {level_name: level_val} into form of
612
+ tuple of items (level_id, level_val) expected by ``update_positions`` method.
613
+ """
614
+ return tuple(self.iter_changes(**alteration))
615
+
616
+ def set_fields(self, index, changes):
617
+ # make_tuple = self.name_index_tuple._make if named else tuple
618
+ return tuple(map(changes.get, self.names, index))
619
+
620
+ def modify(self, index: tuple, changes: dict[str, LevelValue], *,
621
+ named=False, call: Literal['named', True, False] = True):
622
+ """Replace specified fields in the provided index tuple according to the
623
+ ``changes`` keyword arguments, with keys indicating to which levels to
624
+ assign the new values.
625
+
626
+ If a value is ``Callable``, result of its evaluation is assigned instead,
627
+ (unless that is disabled by setting argument ``call=False``)
628
+
629
+ Those calls are initiated with ``index`` as argument:
630
+ - if ``call == True`` it is passed to the callable as is,
631
+ - if ``call == "named"`` it is converted into ``IndexTuple``
632
+ (a named tuple with MultiIndex names), unless it IS *a* (!) named tuple.
633
+
634
+ :param index: (named?)tuple with values of all the index levels
635
+ :param named: control over type of the output: True for named tuple
636
+ :param call: method to process callable values:
637
+ - named - ensure index_item is passed as named tuple into the callables
638
+ - True - index_item is passed to callable as is
639
+ - False - assign callables as other values, not call them
640
+ :param changes: {level_name: new_value}
641
+ """
642
+
643
+ if call:
644
+ if call == 'named' and not getattr(index, '_fields'):
645
+ index = self.name_index_tuple(index)
646
+
647
+ itr = map(lambda k, old: (
648
+ new(index) if hasattr(new := changes.get(k, old), '__call__') else new
649
+ ), self.names, index)
650
+ else:
651
+ itr = map(changes.get, self.names, index)
652
+
653
+ return self.name_index_tuple(itr) if named else tuple(itr)
654
+
655
+ def group_indexers(self, group_name: str = '', *, named=False, call_named=False,
656
+ **named_changes: dict[str, LevelValue]
657
+ ) -> Callable[[tuple], tuple[tuple, ...]]:
658
+ """
659
+ Given multiple named sets of changes ``{change_name: {level: value}}``
660
+ to be applied to the specified levels of a multi-index item,
661
+ create a function ``indexer(index: tuple)`` applying every one of them on its argument (index tuple)
662
+ and returning list or named tuple of resulting indices named accordingly.
663
+
664
+ :param group_name: Name for the namedtuple type for the groups, or return regular tuple
665
+ :param named: create indices as named tuples (slower)
666
+ :param call_named: changes contain functions requiring named index tuple (slower)
667
+ :param named_changes: dict of the group of changes
668
+ {change_name: {level_name: level_value | Callable}}
669
+ :return: function: amend(index) -> namedtuple(changed_index, ...)
670
+ """
671
+ unbound = self.unbound_modifiers(group_name, named=named, **named_changes, call_named=call_named)
672
+ return lambda index: unbound(self, index)
673
+
674
+ def indexer(self, *, named=False, call_named=False, **changes: LevelValue):
675
+ """
676
+ Return function applying given modification to a single index tuple.
677
+ Performance of the function depends on the selected arguments as indicated below.
678
+
679
+ :param named: index will be created as a named tuple (slower)
680
+ :param call_named: changes contain functions requiring named index tuple (slower)
681
+ :param changes: dict of changes {level_name: level_value | Callable}
682
+ :return: Function to modify index: func(index)
683
+ """
684
+ unbound = self.unbound_modifiers(changes=changes, named=named, call_named=call_named)
685
+ return lambda index: unbound(self, index)
686
+
687
+ def to_index(self, tuples: Iterable[tuple]):
688
+ """Convert iterable of index tuples into MultiIndex"""
689
+ return type(self.index).from_tuples(tuples, names=self.index.names)
690
+
691
+
692
+ def iter_rows_dicts(df: pd.DataFrame):
693
+ """Iterator over rows of a dataframe as dict items with columns as keys"""
694
+ return map(lambda x: x._asdict(), df.itertuples(False))
695
+
696
+
697
+ class TablePainter:
698
+ def __init__(self, rev=False, disp=True, axis=1, cmap='RdYlGn', name=None, cap=None, prec=None, **kws):
699
+ """ Create Style Painter for DataFrame or Series.
700
+
701
+ :param rev: reverse colormap sequence to match meaning of high/low
702
+ :param disp: return display object when painting or styling output
703
+ :param axis: gradient color along this axis
704
+ :param cmap: colormap to use
705
+ :param name: (IGNORED if not painting Series) name of the series
706
+ :param cap: Caption text
707
+ :param prec: precision
708
+ :param kws: other styler keyword arguments
709
+ """
710
+ self.name = name
711
+ self.prec = prec
712
+ self.cap = cap
713
+ self.rev = rev
714
+ self.disp = disp
715
+ self.kws = dict(axis=axis, cmap=cmap, **kws)
716
+ self.kws.update(kws)
717
+
718
+ def __call__(self, rev=None, disp=None, name=None, cap=None, prec=None, **kws):
719
+ """Return painter updated by provided arguments
720
+
721
+ :param rev: reverse colormap sequence to match meaning of high/low
722
+ :param disp: return display object when painting or styling output
723
+ :param name: (ONLY if painting Series) name of the series
724
+ :param cap: Caption text
725
+ :param prec: precision
726
+ :param kws: other styler keyword arguments
727
+ :return:
728
+ """
729
+ return TablePainter(
730
+ disp=self.disp if disp is None else disp,
731
+ prec=self.prec if prec is None else prec,
732
+ cap=cap or self.cap,
733
+ name=name or self.name,
734
+ rev=rev or self.rev,
735
+ **{**self.kws, **kws}
736
+ )
737
+
738
+ def __rrshift__(self, df: Union[pd.DataFrame, pd.Series]):
739
+ """Apply style on the given table according to self configuration.
740
+
741
+ :param df: Table to paint (DataFrame or Series)
742
+ :return: display or style
743
+ """
744
+
745
+ if self.rev:
746
+ kws = self.kws.copy()
747
+ cmap = kws['cmap']
748
+ kws['cmap'] = cmap[:-2] if cmap.endswith('_r') else cmap + '_r'
749
+ else:
750
+ kws = self.kws
751
+
752
+ if isinstance(df, pd.Series):
753
+ df = df.to_frame(name=self.name or df.name).T
754
+
755
+ styled = getattr(df, 'nominal_value', df).style.background_gradient(**kws)
756
+
757
+ if self.cap:
758
+ styled.set_caption(self.cap)
759
+
760
+ if self.prec is not None:
761
+ styled.set_precision(self.prec)
762
+
763
+ if not self.disp:
764
+ return styled
765
+ ipy_disp.display(styled)
766
+
767
+ def __repr__(self):
768
+ return str(f"{self.__class__.__qualname__}{'[rev]' if self.rev else ''}{self.kws}")
769
+
770
+
771
+ def side_tables(*tables, caps=None, hide_index=None, painter=None, **kws):
772
+ """ In Notebook display side by side multiple tables
773
+ Example:
774
+
775
+ >>> side_tables(df, ['A', 'B']) == side_tables(df.A, df.B, ['A', 'B'])
776
+
777
+ :param tables: DataFrames or styled tables
778
+ :param caps: optional captions for each
779
+ if SINGLE DataFrame is given and MULTIPLE captions produce
780
+ tables as caption attributes from the one.
781
+ :param hide_index: if True hide indices from the second table on
782
+ :param painter: optional TablePainter object to paint tables with
783
+ :param kws: keywords to TablePainter IF tables are DataFrames
784
+ """
785
+ from functools import reduce
786
+ if caps is None:
787
+ caps = [None] * len(tables)
788
+ if len(tables) == 1 and len(caps):
789
+ tb = tables[0]
790
+ tables = (getattr(tb, cap) for cap in caps)
791
+ if hide_index is None:
792
+ hide_index = True
793
+
794
+ if kws or painter:
795
+ painter = (painter or TablePainter())(disp=False, **kws)
796
+ tables = (tb >> painter for tb in tables)
797
+
798
+ def iter_styled(tables_it, captions):
799
+ for i, (table, cap) in enumerate(zip(tables_it, captions)):
800
+ if isinstance(table, pd.DataFrame):
801
+ table = table.style
802
+ elif not isinstance(table, pd.io.formats.style.Styler):
803
+ raise TypeError(f"Argument {i + 1} is neither Table nor Styler but {type(table)}!")
804
+
805
+ if hide_index:
806
+ if i == 0:
807
+ index = table.index.copy()
808
+ index.names = [None] * len(index.names)
809
+ columns = table.columns[[0]]
810
+ t = pd.DataFrame(data=np.arange(len(table.index)), index=index, columns=columns).style
811
+ yield t.hide_columns([*columns]), None
812
+ yield table.hide_index(), cap
813
+ else:
814
+ yield table, cap
815
+
816
+ tables = (tb.set_table_attributes("style='display:inline'")
817
+ .set_caption(cap)._repr_html_()
818
+ for tb, cap in iter_styled(tables, caps))
819
+
820
+ ipy_disp.display_html(reduce(str.__add__, tables), raw=True)
821
+
822
+
823
+ def sample(dt: DTable, selection: slice | int | list[int] | float,
824
+ *, shuffle=False, groups=False):
825
+ """
826
+ Return subsample of table rows.
827
+
828
+ Selection, in its full form is described by a ``slice`` or list of indices.
829
+ Supported shortcuts:
830
+ - ``int`` > 0 : selection → slice(0, selection)
831
+ - ``float`` < 1: selection → slice(0, size * selection)
832
+
833
+ **Notice** that ``float(1.)`` == `size` samples, when ``int(1)`` == `1` sample!
834
+
835
+ :param dt: Data Frame or Series
836
+ :param selection: int or slice or list of integer indices, or float for proportion from 0 to 1
837
+ For selection == 1, as sampling mostly comes in groups, and sample one row
838
+ is a less occurring UC, we create float from it to make it 100% prop.
839
+ For 1 sample, you can create a slice - slice(1)
840
+ :param shuffle: if True random shuffle before sampling
841
+ :param groups: if True sample groups of rows rather than rows themselves.
842
+ In this case indexing is with respect to groups, not rows
843
+ :return: new table
844
+ """
845
+ if shuffle and not np.issubdtype(type(selection), np.number):
846
+ raise TypeError("Shuffling requires number of items as slc argument,"
847
+ "or a proportion float number from 0 to 1.")
848
+
849
+ if selection is None:
850
+ return dt
851
+
852
+ def as_slice(s, size):
853
+ """creating slice from input s - either float, int, slice or a list"""
854
+ if np.issubdtype(type(s), np.floating):
855
+ if s == 1.0:
856
+ warnings.warn("Creating sampling of 1. (100%) part of the table! Use 1 for 1 row")
857
+ s = round(s * size)
858
+ if isinstance(s, int):
859
+ s = slice(0, s) # works with -, fails on > size!
860
+ elif isinstance(s, tuple):
861
+ s = slice(*s)
862
+ if isinstance(s, slice) and not (s.stop is None or s.stop <= size):
863
+ raise ValueError(f"Out of range for {size=} and slice {s}")
864
+ else: # input is either slice or list of nums to indicate rows
865
+ return s
866
+
867
+ groups = groups and as_list(groups) or False
868
+ if groups:
869
+ gid = '__gid__'
870
+ idf = dt.reset_index() # groupby can't handle NA in Multiindex!
871
+ idf[gid] = idf.groupby(groups, dropna=False).ngroup() # column with group ids
872
+ groups_num = idf[gid].max() + 1
873
+ slc = as_slice(selection, groups_num)
874
+ gids = np.random.permutation(groups_num) if shuffle else np.arange(groups_num)
875
+ selected = set(gids[slc]) # set of integer indices of selected groups
876
+ slc = idf.index[idf[gid].apply(selected.__contains__)]
877
+ else:
878
+ slc = as_slice(selection, len(dt))
879
+ if shuffle:
880
+ slc = np.random.permutation(len(dt))[slc]
881
+
882
+ return dt.iloc[slc]
883
+
884
+
885
+ def index_fillna(db: DataTable, fill=None, **kws):
886
+ """
887
+ Fill NaN values inside index or multiindex (not data), inplace.
888
+ If fill is provided - using it for every NaN.
889
+ If fill == None, using **kws as a dict, where each key is the name of
890
+ the level to change NaN, and each value is the fill_value to change to
891
+ """
892
+ if isinstance(db.index, pd.MultiIndex):
893
+ db.index = pd.MultiIndex.from_frame(
894
+ db.index.to_frame().fillna(**kws) if not fill else
895
+ db.index.to_frame().fillna(fill)
896
+ )
897
+ else:
898
+ db.index = db.index.fillna(**kws) if not fill else db.index.fillna(fill)
899
+
900
+
901
+ # ToDo: leave only one version of add_row
902
+ # def add_row(s, data, index, name='data'):
903
+ # """
904
+ # Add row to series
905
+ # :param s:
906
+ # :param data: data to add
907
+ # :param index: pd.Multiindex type
908
+ # :param name: name of the column
909
+ # :return:
910
+ # """
911
+ # if isinstance(index, pd.Index):
912
+ # return pd.concat([s, pd.Series([data], index=index, name=name)])
913
+ # else:
914
+ # return pd.concat([s, pd.Series([data], index=[index], name=name)])
915
+
916
+
917
+ def add_row(fs: DTable | pd.DataFrame | pd.Series, data: Number | str | dict | Sequence,
918
+ index: Number | tuple | str | dict) -> pd.DataFrame | pd.Series:
919
+ """
920
+ Add a new row to the data frame or series.
921
+ Expands index levels and columns if new data requires.
922
+
923
+ If ``fs`` is Series and new data does not bring columns of its own, series is returned.
924
+
925
+ :param fs: data frame or series
926
+ :param data: data to add, a value or some collection if there are multiple columns
927
+ :param index: index value, tuple of multi-index values (matching existing levels) or dict for new levels
928
+ :return: DataFrame or Series with new row added
929
+ """
930
+ if isinstance(index, dict):
931
+ index = pd.MultiIndex.from_frame(pd.DataFrame([index]))
932
+ elif isinstance(index, tuple):
933
+ index = pd.MultiIndex.from_tuples([index], names=fs.index.names)
934
+ elif not isinstance(index, pd.Index):
935
+ index = [index]
936
+ new_names = fs.index.names + index.names.difference(fs.index.names)
937
+
938
+ is_series = isinstance(fs, pd.Series)
939
+ if is_series:
940
+ fs = fs.to_frame()
941
+ columns = fs.columns
942
+
943
+ if isinstance(data, dict): # dict named columns
944
+ data = [data]
945
+ columns = None
946
+ # from here - unnamed columns
947
+ elif isinstance(data, Sequence) and not isinstance(data, str) and len(columns) > 1: # multiple columns
948
+ data = [tuple(data)]
949
+ elif (isinstance(data, np.ndarray) and data.ndim == 2) \
950
+ or (isinstance(data, Sequence) and len(columns) == 1): # sequence data with only one column
951
+ data = [[data]]
952
+ else: # scalar
953
+ data = [data]
954
+
955
+ df = pd.DataFrame(data, columns=columns, index=index).reset_index()
956
+ df = pd.concat([fs.reset_index(), df]).set_index(new_names)
957
+
958
+ return df.iloc[:, 0] if is_series and len(df.columns) == 1 else df
959
+
960
+
961
+ def sort_index(df: pd.Series | pd.DataFrame, *, reorder_levels=True, drop_levels=False,
962
+ fail_missing=True, **levels_order):
963
+ """
964
+ Sort multi-index of the given series or frame according to the
965
+ order of values provided for every level.
966
+
967
+ All the values not mentioned in the levels order lists are pushed to the end
968
+
969
+ Example:
970
+
971
+ >>> sort_index(df, color=['red', 'green'], shape=['triangle', 'square', 'circle'])
972
+
973
+
974
+ :param df: series of data-frame to sort
975
+ :param reorder_levels: if True, reorder index levels according to provided levels order
976
+ :param drop_levels: if True, drop index levels not mentioned in the levels order
977
+ :param fail_missing: raise KeyError if requested level is not if the index
978
+ :param levels_order: {level_name: list of values in required order}
979
+ :return:
980
+ """
981
+ if inv := set(levels_order).difference(df.index.names):
982
+ if fail_missing:
983
+ raise KeyError(f"Index names {df.index.names} does not include {inv}")
984
+ else:
985
+ for k in inv: del levels_order[k]
986
+
987
+ sort_levels = list(levels_order)
988
+ rest_levels = df.index.names.difference(sort_levels)
989
+ if drop_levels:
990
+ df._drop_labels_or_levels(rest_levels)
991
+
992
+ if reorder_levels:
993
+ df.reorder_levels(sort_levels + rest_levels)
994
+
995
+ # From levels_order: {'a': [2, 1], 'b': [20, 10, 30], 'c': [200, 300]} ->
996
+ # to order_map: [{2: 0, 1: 1}, {20: 0, 10: 1, 30: 2}, {200: 0, 300: 1}]
997
+ order_maps = [{k: p for p, k in enumerate(order)} for order in
998
+ (levels_order.get(lvl, []) for lvl in df.index.names)]
999
+
1000
+ def _cmp_idx(i1: tuple, i2: tuple):
1001
+ for lvl, (v1, v2) in enumerate(zip(i1, i2)):
1002
+ if v1 == v2: continue
1003
+ om = order_maps[lvl]
1004
+ if not om: continue
1005
+ mx = len(om)
1006
+ w1 = om.get(v1, mx)
1007
+ w2 = om.get(v2, mx)
1008
+ if w1 == w2:
1009
+ continue
1010
+ else:
1011
+ return -1 if w1 < w2 else 1
1012
+ return 0
1013
+
1014
+ return df.loc[sorted(df.index, key=cmp_to_key(_cmp_idx))]
1015
+
1016
+
1017
+ Scalar = int | float | str
1018
+ Vector = Sequence[Scalar]
1019
+
1020
+
1021
+ def _expand_vec_values(pairs: list[tuple[Scalar, Scalar | Vector]], prev_expanded=()
1022
+ ) -> Generator[list[tuple[Scalar, Scalar]]]:
1023
+ """
1024
+ Perfroms external multiplication on the vector values
1025
+
1026
+ >>> [*_expand_vec_values([(1, [10, 20]), (3, 0), (2, ['ok', 'no'])])]
1027
+ [[(1, 10), (3, 0), (2, 'ok')],
1028
+ [(1, 10), (3, 0), (2, 'no')],
1029
+ [(1, 20), (3, 0), (2, 'ok')],
1030
+ [(1, 20), (3, 0), (2, 'no')]]
1031
+
1032
+ :param pairs: list of pairs: (key, scalar | list[scalar])
1033
+ :return: generator of scalar pairs: (key, scalar)
1034
+ """
1035
+ if pairs:
1036
+ (key, values), remained_pairs = pairs[0], pairs[1:]
1037
+ for val in as_list(values):
1038
+ yield from _expand_vec_values(remained_pairs, [*prev_expanded, (key, val)])
1039
+ else: # empy pairs list - last most inner recursion iteration
1040
+ yield prev_expanded # return as received - start aggregation
1041
+
1042
+
1043
+ def _row_queries_gen(labels, levels, levels_map) -> Generator[list[tuple[int, Scalar]]]:
1044
+ """
1045
+ Generator of mult-index row queries in form of:
1046
+ ::
1047
+ list[tuple[level_num, level_value]]
1048
+ """
1049
+ for comp_query in labels:
1050
+ name_vec_pairs: Iterable[tuple[str, Scalar | Vector]]
1051
+ if isinstance(comp_query, dict):
1052
+ name_vec_pairs = comp_query.items()
1053
+ elif int.__eq__(
1054
+ l1 := len(comp_query := as_list(comp_query)),
1055
+ l2 := len(levels)
1056
+ ):
1057
+ name_vec_pairs = zip(levels, comp_query)
1058
+ else:
1059
+ raise ValueError(f"Mismatch in lengths ({l1} != {l2}) of query "
1060
+ f"levels={list(levels)} and values={comp_query}!")
1061
+
1062
+ i_vec_pairs: list[tuple[int, Scalar | Vector]] = [
1063
+ (levels_map[k], vec) for k, vec in name_vec_pairs
1064
+ ]
1065
+ # expansion of a compressed query which contains multiple actual single row queries
1066
+ # and yield them one by one
1067
+ for i_val_pair in _expand_vec_values(i_vec_pairs):
1068
+ yield i_val_pair
1069
+
1070
+
1071
+ def select(d: DTable, labels: Sequence[dict[str, Any] | tuple[Any, ...]],
1072
+ levels=None, *, first_found=False,
1073
+ keep_levels: bool | str | list[str] = True):
1074
+ """
1075
+ From table with multi-index select rows matching given sequence of ``labels``.
1076
+
1077
+ Form of the Result
1078
+ ------------------
1079
+ Every query item the ``labels`` sequence must match:
1080
+ - *exactly one* row in the table, if ``first_found is Flase``,
1081
+ - *at least one* row, if ``first_found is True``, (only first is returned!)
1082
+ Otherwis, exception is raised.
1083
+
1084
+ **Resulting rows mirrors order as the query labels.**
1085
+
1086
+ Forms of the Inputs
1087
+ -------------------
1088
+
1089
+ Every label can be in form of a dict: `{level_name: value}` or only a sequence of values.
1090
+ Single dict can be passed to get a single row:
1091
+
1092
+ >>> select(ds, {'a': 10, 'c': 'ok'})
1093
+
1094
+ Later case matches levels names by position from ``levels`` argument, or from ``index.names``.
1095
+
1096
+ For example, there are two options to define same labels for index with levels names `'a', 'b', 'c', 'd'`
1097
+
1098
+ >>> labels = [{'a': 10, 'c': 'ok'},
1099
+ ... {'b': 2, 'd':1, 'c': 'no'}]
1100
+ or
1101
+ >>> labels = [(10, NA, 'ok'),
1102
+ ... (NA, 2, 'no', 1)]
1103
+
1104
+ Here ``NA`` is ``pdtools.NA``
1105
+
1106
+ Compressed Queries
1107
+ ------------------
1108
+
1109
+ Repetitive levels requests may be compressed, in both dict-based and tuples queries:
1110
+
1111
+ >>> ds = DataSeries(range(6), index=pd.MultiIndex.from_product(
1112
+ ... [['image', 'disp', 'conf'], ['R', 'L']], names=['kind', 'view']))
1113
+
1114
+ >>> res = select(ds, [
1115
+ ... ('disp', 'L'),
1116
+ ... ('disp', 'R'),
1117
+ ... ('image', 'L'),
1118
+ ... ('image', 'R')
1119
+ ... ])
1120
+ >>> res #doctest: +NORMALIZE_WHITESPACE
1121
+ None (Series)
1122
+ kind view
1123
+ disp L 3
1124
+ R 2
1125
+ image L 1
1126
+ R 0
1127
+
1128
+ May be written as:
1129
+
1130
+ >>> res2 = select(ds, [(['disp', 'image'], ['L', 'R'])])
1131
+ >>> assert (res == res).all()
1132
+
1133
+ Notice, that nested lists are expanded from the last to first.
1134
+
1135
+ When labels are provided as values (without keys), then `levels`
1136
+
1137
+ :param d: ``DataFrame`` or ``Series``
1138
+ :param labels: sequence of dict: {level1: value1, level2: value2}
1139
+ :param first_found: if ``True`` returns first found match for every label
1140
+ :param levels: Sequence of levels names used for implicit labels values.
1141
+ :param keep_levels: ``True`` - to keep the original index levels,
1142
+ ``False`` - to use ``levels`` argument (if provided or fall back to True)
1143
+ ``list|str`` - explicit levels to keep.
1144
+ :return: table with sub-set of selected rows
1145
+ :raises: ``KeyError`` if not found, ``LokupError`` if number of matches > 1
1146
+ """
1147
+ levels_names = d.index.names
1148
+ is_multi = len(levels_names) > 1
1149
+
1150
+ if isinstance(labels, (dict, tuple)):
1151
+ labels = [labels]
1152
+
1153
+ if not (levels is None or (levels := as_list(levels))):
1154
+ raise ValueError(f"Invalid {levels=}")
1155
+
1156
+ match keep_levels:
1157
+ case True:
1158
+ levels = levels or levels_names
1159
+ case False:
1160
+ if levels:
1161
+ keep_levels = levels
1162
+ else:
1163
+ keep_levels = True
1164
+ levels = levels_names
1165
+ case [*_levels]: # levels are provided through keep_levels
1166
+ levels = levels or _levels
1167
+ case str(x) | int(x): # levels are provided through keep_levels
1168
+ levels = levels or [x]
1169
+ case _:
1170
+ raise ValueError(f"Invalid {keep_levels=}")
1171
+
1172
+ # ---------------------------------------------
1173
+ def row_queries():
1174
+ """Generates row queries with accompanying info string"""
1175
+ levels_map = {n: i for i, n in enumerate(levels_names)}
1176
+ for qi, _query in enumerate(_row_queries_gen(labels, levels, levels_map)):
1177
+ yield _query, f"selection {_query} ({levels=})"
1178
+
1179
+ indices = []
1180
+ for query, info in row_queries(): # each query run over all the indices
1181
+ match_row = -1 # initialize match row number "not existing"
1182
+ for row_i, levels_values in enumerate(d.index): # match the query with every index row
1183
+ for lvl_i, label_val in query: # ALL query labels values must match!
1184
+ # if not MultiIndex lvl_i represents level's value
1185
+ lvl_val = levels_values[lvl_i] if is_multi else levels_values
1186
+ if not (label_val is NA or lvl_val == label_val):
1187
+ break # level value DOES NOT match - skip row
1188
+ else: # no break - all levels of this row have matched
1189
+ if match_row == -1: # this is the first time this query matched
1190
+ match_row = row_i
1191
+ if first_found: break # skip verifying that no other matches for the query
1192
+ else: # here first_found is False and second match found!
1193
+ raise LookupError(f'Multiple matches ({match_row, levels_values}, ...) for {info}')
1194
+
1195
+ if match_row == -1: # finished matching all the index - without success!
1196
+ raise KeyError(f'{info} not found in index:\n {d.index}')
1197
+ indices.append(match_row)
1198
+
1199
+ sel = d.iloc[indices]
1200
+
1201
+ if is_multi and keep_levels is not True: # here if keep_levels is True or [...]
1202
+ sel = sel.keep_levels(keep_levels)
1203
+ return sel
1204
+
1205
+
1206
+ class TableFormats:
1207
+ _reg_name = {}
1208
+ _reg_type = {}
1209
+ _reg_cond = []
1210
+ _reg_obj = None
1211
+
1212
+ _html_as_text = False
1213
+
1214
+ @classmethod
1215
+ def html_as_text(cls, as_text=True):
1216
+ cls._html_as_text = as_text
1217
+
1218
+ @classmethod
1219
+ def register(cls, *conditions: str | type | np.dtype | Callable[[pd.Series], bool]):
1220
+ """
1221
+ Decorator to register DataFrame cells formatting function, by associating it
1222
+ with column name, column type or a particular condition on its Series:
1223
+
1224
+ - string with specific name of a column - first condition to check
1225
+ - name of a ``numpy`` supported type OR ``numpy.dtype`` - next condition is type association
1226
+ - 'string'
1227
+ - Literal ``object`` - a fallback formatter for any ``dtype[object]`` column
1228
+
1229
+
1230
+ :param conditions:
1231
+ :return:
1232
+ """
1233
+ dtypes = {'int', 'int8', 'int16', 'int32', 'int64',
1234
+ 'uint', 'uint8', 'uint32', 'uint64',
1235
+ 'bool', 'complex', 'complex64', 'complex128',
1236
+ 'float', 'float32', 'float64'}
1237
+ string = pd.StringDtype()
1238
+ if not conditions: raise ValueError("Missing format condition")
1239
+
1240
+ def decorator(fnc):
1241
+ for cond in conditions:
1242
+ if isinstance(cond, str):
1243
+ if cond in dtypes:
1244
+ cls._reg_type[np.dtype(cond)] = fnc
1245
+ elif cond in {'str', 'string'}:
1246
+ cls._reg_type[string] = fnc
1247
+ else:
1248
+ cls._reg_name[cond] = fnc
1249
+ elif cond == string:
1250
+ cls._reg_type[string] = fnc
1251
+ elif issubclass(cond, str):
1252
+ cls._reg_type[string] = fnc
1253
+ elif isinstance(cond, np.dtype):
1254
+ cls._reg_type[cond] = fnc
1255
+ elif cond is object:
1256
+ cls._reg_obj = fnc
1257
+ elif isinstance(cond, type):
1258
+ cls._reg_type[np.dtype(cond)] = fnc
1259
+ elif isinstance(cond, Callable):
1260
+ cls._reg_cond.append((cond, fnc))
1261
+ return fnc
1262
+
1263
+ return decorator
1264
+
1265
+ @classmethod
1266
+ def formatters(cls, df: pd.DataFrame):
1267
+ def match_cond(name):
1268
+ for cond, fnc in cls._reg_cond:
1269
+ if cond(df.get(name)): return fnc
1270
+
1271
+ return tuple(cls._reg_name.get(name, None) or
1272
+ cls._reg_type.get(dtype, None) or
1273
+ match_cond(name) or cls._reg_obj
1274
+ for name, dtype in df.dtypes.items())
1275
+
1276
+
1277
+ @TableFormats.register(object)
1278
+ def obj(x, max_col_width=20):
1279
+ if isinstance(x, dict):
1280
+ if x.__str__ is dict.__str__:
1281
+ return f"{{{stt.short_form(str(x)[1:-1], max_col_width - 2)}}}"
1282
+ if isinstance(x, np.ndarray):
1283
+ return npt.array_info_str(x, stats=0)
1284
+ if isinstance(x, PurePath):
1285
+ return path(x, max_col_width=max_col_width)
1286
+
1287
+ clean_line = re.compile(r"\s{2,}|(\n\r?)").sub(' ', str(x))
1288
+ return stt.short_form(clean_line, max_col_width)
1289
+
1290
+
1291
+ @TableFormats.register('path', 'folder', 'file', 'filename')
1292
+ def path(x, max_col_width=40):
1293
+ return f"🖿{stt.short_form(str(x), tail=max_col_width - 2)}"
1294
+
1295
+
1296
+ @TableFormats.register('transforms', 'transformed', 'align_trans')
1297
+ def trans(s, max_col_width=20):
1298
+ if not isinstance(s, dict): # safety if called for non-dict cell
1299
+ return str(s)
1300
+
1301
+ def args_str(a, names=True):
1302
+ if not hasattr(a, 'items'):
1303
+ return str(a)
1304
+ ars = ','.join(f"{k}={v}" if names else f"{v}"
1305
+ for k, v in a.items())
1306
+ return f"({ars})" if ars else ""
1307
+
1308
+ ss = ', '.join(f"{name}{args_str(args)}" for name, args in s.items())
1309
+ if len(ss) > max_col_width:
1310
+ ss = ', '.join(f"{name}{args_str(args, False)}" for name, args in s.items())
1311
+ return ss
1312
+
1313
+
1314
+ def _invert_levels(levels, names):
1315
+ if isinstance(levels, (str, int)):
1316
+ levels = [levels]
1317
+ if isinstance(levels[0], int):
1318
+ return [x for x in range(len(names)) if x not in levels]
1319
+ else:
1320
+ return names.difference(levels)
1321
+
1322
+
1323
+ class _TableMixIn:
1324
+
1325
+ def as_labels(self, index=True, data=False, squeeze=False) -> Labels | list[Labels]:
1326
+ """
1327
+ Convert into list of datacast.labels.Labels objects.
1328
+ :param index: include all (`True`) or specified index levels into the Labels objects
1329
+ :param data: include all (`True`) or specified columns into the Labels objects
1330
+ :param squeeze: from 1 row return its Labels object instead of list with it
1331
+ :return: list of or single Labels representation of the row(s)
1332
+ """
1333
+ from .label import Labels
1334
+ return Labels.from_frame(self, index=index, data=data, squeeze=squeeze)
1335
+
1336
+ def squeeze_levels(self: DTable, levels=None, *, keep=None, axis=0):
1337
+ """
1338
+ Leave only name level and optionally other levels required for
1339
+ unique identification of a multi-label category defined by name.
1340
+
1341
+ For example in some benchmark algorithm version and or config are
1342
+ redundant, and doesn't include different info for different rows
1343
+
1344
+ :param levels: list of levels to try to squeeze, default - all the levels
1345
+ :param keep: keys to keep even if they are squeezable
1346
+ :param axis: axis to work with
1347
+ """
1348
+ index = self.axes[axis]
1349
+ keep = set(as_list(keep))
1350
+ if unknown := keep.difference(index.names):
1351
+ raise NameError(f"{unknown = } levels in {axis = }")
1352
+
1353
+ levels = as_list(levels) or index.names
1354
+ levels = set(levels) - keep # levels to try to squeeze
1355
+ drop = [level for level in levels if index.unique(level).size == 1]
1356
+ return self.droplevel(drop, axis=axis)
1357
+
1358
+ def find_level(self: DTable, lv_name, axis=None):
1359
+ """Return True if name found in specific axis
1360
+ :param axis: if None - search in all the axes
1361
+ """
1362
+ if axis is None:
1363
+ axis = [0, 1] if self.ndim == 2 else [1]
1364
+ else:
1365
+ axis = as_list(axis)
1366
+
1367
+ for ax in axis:
1368
+ if lv_name in self.axes[ax].names:
1369
+ return True
1370
+ return False
1371
+
1372
+ def levels_in(self, levels):
1373
+ """
1374
+ Return list of levels from the given list actually found in self
1375
+ :param levels:
1376
+ :return:
1377
+ """
1378
+ return [*filter(self.find_level, levels)]
1379
+
1380
+ def all_levels_names(self) -> set:
1381
+ """Return set of all the levels in both index and columns"""
1382
+ all_names = set(self.index.names)
1383
+ if self.ndim > 1:
1384
+ all_names.update(self.columns.names)
1385
+ return all_names
1386
+
1387
+ def named_levels(self: DTable, levels: Union[int, str, Collection[Union[int, str]]],
1388
+ axis=0, *, exclude=False) -> list[str]:
1389
+ """Return levels names given different forms: one or more int or str
1390
+ Also as exclusion from all the levels names in the index.
1391
+
1392
+ * levels may contain names not in the ``index.names`` without raising error!
1393
+
1394
+ :param levels: one or more levels as int or str
1395
+ :param axis: axis to examine
1396
+ :param exclude: invert and return list of levels NOT in the names
1397
+ :return: list of names
1398
+ """
1399
+ all_names = self.axes[axis].names
1400
+ if isinstance(levels, int):
1401
+ levels = all_names[levels]
1402
+ elif isinstance(levels, str):
1403
+ levels = [levels]
1404
+ else:
1405
+ levels = [all_names[lvl] if isinstance(lvl, int) else lvl for lvl in levels]
1406
+ return [*set(all_names).difference(levels)] if exclude else levels
1407
+
1408
+ def unstack_but(self: DTable, level, strict=False, *, dropna=True, **kws):
1409
+ """Unstack all the levels except the given ones.
1410
+ Remaining levels reorder as defined in level argument.
1411
+
1412
+ :param level: one or more levels in any form (int, str)
1413
+ :param strict: if True ensures ALL the requested levels are found in
1414
+ index, stack them from columns if needed or raise
1415
+ :param dropna: drop NA columns which could appear as unstack artefact
1416
+ :param kws: unstack kwargs
1417
+ """
1418
+ if not level:
1419
+ return self
1420
+ if strict and (inv_levels := set(as_list(level)).difference(self.all_levels_names())):
1421
+ raise KeyError(f"Not valid levels: {inv_levels}")
1422
+
1423
+ levels = self.named_levels(level)
1424
+ index_names = set(self.index.names)
1425
+
1426
+ tbl = self
1427
+ if strict:
1428
+ missing = set(levels).difference(index_names)
1429
+ if missing:
1430
+ tbl = tbl.stack([*missing]) # will raise her if not found in columns
1431
+ index_names.update(missing)
1432
+
1433
+ tbl = tbl.unstack([*index_names.difference(levels)], **kws)
1434
+ if dropna and tbl.ndim > 1:
1435
+ tbl = tbl.dropna(axis=1, how='all')
1436
+
1437
+ if tbl.index.nlevels > 1:
1438
+ levels = [lvl for lvl in levels if lvl in index_names] # leave existing keep the order
1439
+ return tbl.reorder_levels(levels)
1440
+ return tbl
1441
+
1442
+ def stack_but(self, level, strict=False, **kws):
1443
+ """Stack all the levels except the given ones.
1444
+
1445
+ :param level: one or more levels names (or ids) to leave in columns
1446
+ :param strict: if True ensures ALL the requested levels are in place
1447
+ move them from index if needed or rise if not found
1448
+ :param kws: stack kw args
1449
+ """
1450
+ levels = self.named_levels(level, 1) if self.ndim > 1 else as_list(level)
1451
+ col_names = set([] if self.ndim == 1 else self.columns.names)
1452
+ tbl = self # type: pd.DataFrame
1453
+ if strict:
1454
+ missing = set(levels).difference(col_names)
1455
+ if missing:
1456
+ try:
1457
+ tbl = tbl.unstack([*missing]).dropna(axis=1,
1458
+ how='all') # remove possible artefacts of unstacking
1459
+ except KeyError:
1460
+ raise KeyError(f"Levels {missing} not found in index {tbl.index.names}")
1461
+ col_names.update(missing)
1462
+
1463
+ tbl = tbl.stack([*col_names.difference(levels)], **kws) # Need remove NULL rows?
1464
+
1465
+ if tbl.ndim > 1 and tbl.columns.nlevels > 1: # if there are col levels
1466
+ levels = [lvl for lvl in levels if lvl in col_names]
1467
+ return tbl.reorder_levels(levels, 1)
1468
+ return tbl
1469
+
1470
+ def rmi(self: PTable, key=None, level=None, *, axis=0, split=False, **levels_keys) -> PTable:
1471
+ """Remove rows matching given conditions, which could be
1472
+ - keys in given levels `DataFrame.xs` style
1473
+ - index, then equivalent to df.loc[df.index.difference(key)]
1474
+
1475
+ Examples:
1476
+ ::
1477
+ df.rmi('L', 'view')
1478
+
1479
+ Attempts to remove not existed index lead to return of the
1480
+ input data intact.
1481
+
1482
+ :param self: DataFrame or Series to filter rows from
1483
+ :param key: key(s) or index
1484
+ :param level: levels of the given keys (omit if key is index)
1485
+ :param axis: 0 or 1 for columns
1486
+ :param split: if True return also the removed part
1487
+ :param levels_keys: (level=key) form instead of (key, level)
1488
+ :return: DataFrame/Series with removed rows
1489
+ """
1490
+ assert axis in {0, 1}
1491
+ inv = lambda x: x.T if axis == 1 else x
1492
+
1493
+ if levels_keys:
1494
+ if key or level:
1495
+ raise ValueError("Can't provide both positional args and keyword level keys")
1496
+
1497
+ self = inv(self)
1498
+ try:
1499
+ if levels_keys:
1500
+ if self.index.nlevels == 1:
1501
+ raise ValueError("Keyword level keys only supported for MultiIndex")
1502
+ # Start with a mask of all True, then reduce it
1503
+ mask = pd.Series(True, index=self.index)
1504
+ for lvl, val in levels_keys.items():
1505
+ mask &= self.index.get_level_values(lvl).isin(as_list(val))
1506
+ rm_idx = self.index[mask]
1507
+ elif isinstance(key, pd.Index):
1508
+ assert level is None
1509
+ rm_idx = key
1510
+ elif self.index.nlevels == 1: # Not a MultiIndex
1511
+ assert level in {None, 0, self.index.name}
1512
+ rm_idx = as_list(key)
1513
+ elif not is_list_like(level) and is_list_like(key):
1514
+ rm_idx = self.index[self.index.isin(key, level)]
1515
+ else:
1516
+ rm_idx = self.xs(key, 0, level=level, drop_level=False).index
1517
+ rm = self.index.isin(rm_idx)
1518
+ return (inv(self[~rm]), inv(self[rm])) if split else inv(self[~rm])
1519
+
1520
+ except KeyError:
1521
+ return inv(self)
1522
+
1523
+ def add_labels(self, axis=0, pass_named=True, **kws):
1524
+ """
1525
+ Add labels to a specific axis
1526
+ :param pass_named: if True, pass index tuple as named using index names
1527
+ :param axis: axis to add labels to
1528
+ :param kws: {level_name: val|fnc}
1529
+ :return:
1530
+ """
1531
+ if axis == 0:
1532
+ self.index = set_index_levels(self.index, pass_named=pass_named, **kws)
1533
+ elif axis == 1:
1534
+ self.columns = set_index_levels(self.columns, pass_named=pass_named, **kws)
1535
+
1536
+ Mismatch = Literal['from_items'] | Exception | Collection[str] | dict[str, Any] | Any
1537
+
1538
+ def add_items(self, items: dict | Iterable[dict],
1539
+ join: Literal['inner', 'outer', 'items', 'original'] = 'outer'):
1540
+ """
1541
+ Add item(s) provided as iterable of dicts or a single one.
1542
+
1543
+ Keys found in the `items` are compared with all the keys in `table`
1544
+ (index levels + columns).
1545
+
1546
+ When both sets of keys are the same, the new items just appended to the bottom of the table.
1547
+
1548
+ :param items: dict-like item or Iterable of items
1549
+ :param join: controls behaviour when items keys are different from the table keys
1550
+ :return: table with items added as new rows
1551
+ """
1552
+ # -----------------------
1553
+ if isinstance(items, pd.Series):
1554
+ items_df = items.to_frame()
1555
+ if isinstance(items.columns, pd.MultiIndex):
1556
+ items_df = items.T
1557
+ elif isinstance(items, pd.DataFrame):
1558
+ items_df = items
1559
+ else:
1560
+ if isinstance(items, dict):
1561
+ items = [items]
1562
+ elif isinstance(items, Iterable):
1563
+ def check_type(x):
1564
+ if isinstance(x, dict):
1565
+ return x
1566
+ raise TypeError(f'Expected dict-like type, received {type(x)}!')
1567
+
1568
+ items = map(check_type, items)
1569
+ items_df = DataTable(items)
1570
+
1571
+ def invalid_index_names(t):
1572
+ not_valid = not all(names := t.index.names)
1573
+ if not_valid and len(names) > 1:
1574
+ raise IndexError("Not supported index with mixed named and None levels")
1575
+ return not_valid
1576
+
1577
+ items_df = items_df.reset_index(drop=invalid_index_names(items_df))
1578
+ table = self.reset_index(drop=invalid_index_names(self))
1579
+
1580
+ if (_join := join) in ('items', 'table'):
1581
+ _join = 'outer'
1582
+ df = pd.concat([table, items_df], join=_join)
1583
+
1584
+ missing_keys = table.columns.difference(items_df.columns)
1585
+ extra_keys = items_df.columns.difference(table.columns)
1586
+
1587
+ if join == 'items' and not missing_keys.empty:
1588
+ drop_columns = missing_keys
1589
+ elif join == 'table' and not extra_keys.empty:
1590
+ drop_columns = extra_keys
1591
+ else:
1592
+ drop_columns = None
1593
+
1594
+ index_levels = self.index.names
1595
+ if drop_columns is not None:
1596
+ df.drop(columns=drop_columns, inplace=True)
1597
+ index_levels = index_levels.difference(drop_columns)
1598
+
1599
+ if all(index_levels):
1600
+ df.set_index(index_levels, inplace=True)
1601
+ return df
1602
+
1603
+ def keep_levels(self: PTable, level, *, strict=True):
1604
+ """
1605
+ Leave only given level(s) in the *given order* in the axis 0 index levels.
1606
+
1607
+ :param level: name or sequence of names of levels
1608
+ :param strict: if True raise if unknown level is provided.
1609
+ otherwise just ignore it.
1610
+ :return: Table with changed index.
1611
+ """
1612
+ current_levels = set(self.index.names)
1613
+ levels = as_list(level)
1614
+ if strict: # raise if unknown levels found
1615
+ unless_subset(current_levels, levels)
1616
+ else:
1617
+ levels = [l for l in levels if l in current_levels]
1618
+ # now we have only valid levels to be kept
1619
+ self = self.droplevel([*current_levels.difference(levels)])
1620
+ return self.reorder_levels(levels) if len(levels) > 1 else self
1621
+
1622
+ @wrap.doc_from(select)
1623
+ def select(self: DTable, labels: Sequence[dict[str, Any] | tuple[Any, ...]] | dict[str, Any],
1624
+ levels=None,
1625
+ *, first_found=False, keep_levels: bool | str | list[str] = True):
1626
+ """From table with multi-index select rows matching given list of labels.
1627
+
1628
+ Every label can be in form of a dict: `{level_name: value}` or only a sequence of values.
1629
+
1630
+ Later case matches levels names by position from ``levels`` argument, or from ``index.names``.
1631
+
1632
+ For example, if ``d.index.names == ['a', 'b', 'c', 'd']`` there are two options to define same labels:
1633
+ ::
1634
+ labels = [
1635
+ {'a': 10, 'c': 'ok'},
1636
+ {'b': 2, 'd':1, 'c': 'no'},
1637
+ ], or as
1638
+ labels = [
1639
+ (10, NA, 'ok'),
1640
+ (NA, 2, 'no', 1)
1641
+ ]
1642
+
1643
+ Here ``NA`` is ``pdtools.NA``
1644
+
1645
+ Rows in the resulting table follow order of the list of provided query labels.
1646
+
1647
+ Every query of labels must match exactly one row in the table or exception is raised.
1648
+ Single dict can be passed to get a single row:
1649
+
1650
+ >>> ds.select({'a': 10, 'c': 'ok'})
1651
+
1652
+ **Compressed Queries**
1653
+
1654
+ Repetitive levels requests may be compressed, in both dict-based and tuples queries:
1655
+
1656
+ >>> ds = DataSeries(range(6), index=pd.MultiIndex.from_product(
1657
+ ... [['image', 'disp', 'conf'], ['R', 'L']], names=['kind', 'view']))
1658
+
1659
+ >>> res = ds.select([
1660
+ ... ('disp', 'L'),
1661
+ ... ('disp', 'R'),
1662
+ ... ('image', 'L'),
1663
+ ... ('image', 'R')
1664
+ ... ])
1665
+ >>> res #doctest: +NORMALIZE_WHITESPACE
1666
+ None (Series)
1667
+ kind view
1668
+ disp L 3
1669
+ R 2
1670
+ image L 1
1671
+ R 0
1672
+
1673
+ May be written as:
1674
+
1675
+ >>> res2 = ds.select([(['disp', 'image'], ['L', 'R'])])
1676
+ >>> assert (res == res).all()
1677
+
1678
+ Notice, that nested lists are expanded from the last to first.
1679
+
1680
+ ``IndexError`` is raise if number of matches found for any label is not 1.
1681
+
1682
+ :param d:
1683
+ :param labels: sequence of dict: {level1: value1, level2: value2}
1684
+ :param first_found: if ``True`` returns first found match for every label
1685
+ :param levels: Sequence of levels names used for implicit labels values.
1686
+ :param keep_levels: ``True`` - to keep the original index levels,
1687
+ ``False`` - to use ``levels`` argument (if provided or fall back to True)
1688
+ ``list|str`` - explicit levels to keep.
1689
+ :return: table with sub-set of selected rows
1690
+ """
1691
+ return select(self, labels, levels=levels,
1692
+ first_found=first_found, keep_levels=keep_levels)
1693
+
1694
+ def qix(self: PTable, *anonymous,
1695
+ drop_level: bool | Collection = False, keep: Collection = None,
1696
+ axis: AxisT = None, key_err=True, **named) -> PTable:
1697
+ """
1698
+ Filter a dataframe or series using fuzzy query of its multi-index.
1699
+ Arguments in different forms describe the index values to be searched for.
1700
+
1701
+ Some index levels in the output can be dropped by specifying either
1702
+ ``drop_level`` or ``keep`` (mutually exclusive) arguments.
1703
+
1704
+ Use ``drop_level=True`` to drop all the redundant levels used in the query, or
1705
+ just provide one (or list) of specific levels to drop.
1706
+ Would not drop if the level is important for indexing the data (there is no redundancy),
1707
+ even if specified in the drop_level list
1708
+
1709
+ :param self: The table to filter
1710
+ :param anonymous: list of values from one of the index levels
1711
+ - will try all until first is found or raise IndexError
1712
+ :param drop_level: if True - drop found levels from the result if there is redundancy
1713
+ or a collection of specific levels to drop
1714
+ :param keep: a collection of levels to keep - excludes using drop_level
1715
+ :param axis: if specified query only this axis
1716
+ :param key_err: raise KeyError if index not found or return empty
1717
+ :param named: {level: value} - specific levels to find,
1718
+ eliminates exhaustive search in all levels
1719
+ :return: Filtered table
1720
+
1721
+ Tutorial:
1722
+ Using this DataTable:
1723
+
1724
+ >>> from iad.core.tests.test_pdtools import sdt_general
1725
+ >>> dt= sdt_general()
1726
+ >>> dt # doctest: +NORMALIZE_WHITESPACE
1727
+ z data1 data2
1728
+ w a b c d a b c d
1729
+ x y
1730
+ 1 3 1 2 3 4 5 6 7 8
1731
+ 4 1 2 3 4 5 6 7 8
1732
+ 5 1 2 3 4 5 6 7 8
1733
+ 6 1 2 3 4 5 6 7 8
1734
+ 2 3 1 2 3 4 5 6 7 8
1735
+ 4 1 2 3 4 5 6 7 8
1736
+ 5 1 2 3 4 5 6 7 8
1737
+ 6 1 2 3 4 5 6 7 8
1738
+
1739
+ Example 1: specific levels to find
1740
+
1741
+ >>> dt.qix(w=['a','b'],x='1') # doctest: +NORMALIZE_WHITESPACE
1742
+ z data1 data2
1743
+ w a b a b
1744
+ x y
1745
+ 1 3 1 2 5 6
1746
+ 4 1 2 5 6
1747
+ 5 1 2 5 6
1748
+ 6 1 2 5 6
1749
+
1750
+ We can see the redundant level X. We can drop it with drop_level
1751
+ Example 2 - same, with drop level:
1752
+
1753
+ >>> dt.qix(w=['a','b'],x='1',drop_level=True) # doctest: +NORMALIZE_WHITESPACE
1754
+ z data1 data2
1755
+ w a b a b
1756
+ y
1757
+ 3 1 2 5 6
1758
+ 4 1 2 5 6
1759
+ 5 1 2 5 6
1760
+ 6 1 2 5 6
1761
+
1762
+ We can also search for levels without knowing their name.
1763
+ Example 3 - Anonymous search:
1764
+
1765
+ >>> dt.qix('a','3',drop_level=True) # doctest: +NORMALIZE_WHITESPACE
1766
+ z data1 data2
1767
+ x
1768
+ 1 1 5
1769
+ 2 1 5
1770
+ """
1771
+ # ToDo: add option to search not only in the index
1772
+ # FixMe: self.empty check twice!
1773
+ # if self.empty: return self.iloc[0:0]
1774
+ if self.empty:
1775
+ return self
1776
+ if not (anonymous or named):
1777
+ return self.iloc[0:0]
1778
+
1779
+ def merge_repeating_levels_into_list(level_value: list[tuple], seen):
1780
+ for lvl, vals in level_value:
1781
+ if prev := seen.get(lvl, None): # lvl is already in named
1782
+ if not isinstance(prev, list):
1783
+ seen[lvl] = [prev, vals]
1784
+ elif vals not in prev: # vals is not in the list
1785
+ prev.append(vals)
1786
+ else:
1787
+ seen[lvl] = vals
1788
+ return seen
1789
+
1790
+ axes = self.axes_as_list(axis)
1791
+ assert not keep or drop_level is True, "keep levels makes sense only with drop is True"
1792
+
1793
+ if anonymous: # associate anonymous values with levels and add them to kws
1794
+ assoc = self.associate_levels(anonymous, axes)
1795
+ named = merge_repeating_levels_into_list(assoc, named)
1796
+ if not named:
1797
+ raise IndexError(f"Values {anonymous = } not found")
1798
+
1799
+ # build lists of sets of levels for the required axes (could be only 1)
1800
+ axs_kws = [select_from(named, idx.names, strict=False) for idx in self.axes]
1801
+ if len(axes) > 1 and (both := select_from(axs_kws[0], axs_kws[1], strict=False)):
1802
+ raise IndexError(f"Requested levels {list(both)} appear in both axes")
1803
+
1804
+ try:
1805
+ if len(named) > sum(map(len, axs_kws)):
1806
+ raise IndexError(f"Requested unknown levels: {set(named) - set(axs_kws[0] | axs_kws[1])}")
1807
+
1808
+ slices = tuple(slicer(index, kws) if kws else _ALL for index, kws in zip(self.axes, axs_kws))
1809
+ res: DTable = self.loc[slices]
1810
+ except KeyError as err:
1811
+ if key_err:
1812
+ raise err
1813
+ logging.debug(f'qix:\n{err}')
1814
+ return self.iloc[0:0]
1815
+
1816
+ if drop_level:
1817
+ to_set = lambda x: {x} if isinstance(x, (str, int)) else set(x)
1818
+ for ax, (kws, axis) in enumerate(zip(axs_kws, res.axes)):
1819
+ if drop_level is not True: # if specific drop
1820
+ kws = select_from(kws, drop_level, strict=False)
1821
+ kws_left = kws.keys() - keep if keep else kws.keys() # if specific keep
1822
+ # check if there are only one value in a level name in res, if True - Drop
1823
+ drop = [n for n in to_set(axis.names) & kws_left if len(axis.unique(n)) < 2]
1824
+ if drop:
1825
+ res = res.droplevel(list(drop), ax)
1826
+ return res
1827
+
1828
+ def axes_as_list(self, axis: AxisT = None):
1829
+ named_axes = dict(index=0, columns=1)
1830
+ if not axis and self.ndim == 1:
1831
+ axis = 0
1832
+ if isinstance(axis, str): # 'index' or 'columns'
1833
+ axes = [named_axes[axis]]
1834
+ elif axis is None:
1835
+ axes = named_axes.values()
1836
+ elif axis in named_axes.values(): # 0 or 1
1837
+ axes = [axis]
1838
+ elif all(named_axes.__contains__, axis):
1839
+ axes = [named_axes[k] for k in axis] # [0, 1]
1840
+ elif all(named_axes.values().__contains__, axis):
1841
+ axes = [*axis]
1842
+ else:
1843
+ raise ValueError(f"Invalid axis: {axis}")
1844
+ return axes
1845
+
1846
+ def associate_levels(self, anonymous: Collection, axes: list[int]):
1847
+ """ Associate anonymous level values with level names
1848
+ :param self: DataTable
1849
+ :param anonymous: Collection of anonymous values to get their level name
1850
+ :param axes: Specific axis to check, if requested by the user
1851
+
1852
+ :return: list of tuples [(name,value),...] found and paired
1853
+ """
1854
+ assoc = []
1855
+ for ax in axes:
1856
+ if not anonymous: break # spare unneeded calculations
1857
+ index = self.axes[ax]
1858
+ levels_vals = [*zip(index.names, index.levels)] if index.nlevels > 1 else [(index.name, index)]
1859
+ not_found = []
1860
+ for item in anonymous:
1861
+ found_level = None
1862
+ for lvl, vals in levels_vals:
1863
+ if item not in vals: continue
1864
+ if found_level:
1865
+ raise IndexError(f"{item} found in more then one levels: {found_level, lvl}")
1866
+ found_level = lvl
1867
+
1868
+ if found_level:
1869
+ assoc.append((found_level, item))
1870
+ else:
1871
+ not_found.append(item)
1872
+ anonymous = not_found # to look for them in
1873
+ return assoc
1874
+
1875
+ def mean(self: DTable, axis=0, skipna=True, level=None, numeric_only=None):
1876
+ """ Return the mean of the values over the requested axis / level.
1877
+
1878
+ *Uncertainties are properly calculated if available!
1879
+
1880
+ :param axis : {index (0), columns (1)}
1881
+ Axis for the function to be applied on.
1882
+ :param skipna : default True, Exclude NA/null when computing the result
1883
+ :param level : int or level name, default None
1884
+ If the axis is a MultiIndex (hierarchical), count along a
1885
+ particular level, collapsing into a Series.
1886
+ :param numeric_only : bool, default None
1887
+ Include only float, int, boolean columns. If None, will attempt to use
1888
+ everything, then use only numeric data. Not implemented for Series.
1889
+ """
1890
+ series = isinstance(self, pd.Series)
1891
+ kws = dict(axis=axis, level=level, numeric_only=numeric_only)
1892
+ mean = (pd.Series if series else pd.DataFrame).mean(self.nominal_value, **kws)
1893
+ if not (hasattr(mean, 'index') and hasattr(self, 'nominal_value')) or self.std_dev.sum().sum() == 0:
1894
+ return mean
1895
+
1896
+ kws.update(level=level)
1897
+ num = self.std_dev.count(**(dict(level=level) if series else kws))
1898
+ sd = ((self.std_dev ** 2).sum(**kws) / num) ** 0.5
1899
+ from uncertainties.unumpy import uarray
1900
+ return mean.__class__(uarray(mean, sd), index=mean.index,
1901
+ **({} if mean.ndim == 1 else {'columns': mean.columns}))
1902
+
1903
+ def weighted_mean(self: DTable, ws: Union[pd.Series, pd.DataFrame, Sequence],
1904
+ level=None, unc=True):
1905
+ """Calculate mean of DF columns weighted by ws vector of same length.
1906
+ :param self: table with columns to average
1907
+ :param ws: weights vector in any supported form
1908
+ :param level: separate averaging over those levels
1909
+ :param unc: uncertainty calculation control:
1910
+ False - do not calculate
1911
+ True - calculate and fill resulted table with objects
1912
+ from uncertainty package
1913
+ <str> - each column to produces two: 'avr', <str>
1914
+ second with standard deviation values
1915
+ """
1916
+
1917
+ if isinstance(ws, Sequence):
1918
+ ws = DataSeries(ws, index=self.index if level is None else
1919
+ self.unique_level_values(level=level))
1920
+ elif isinstance(ws, (pd.DataFrame, pd.Series)):
1921
+ self = self.unstack_but(ws.index.names) # align indices
1922
+ assert ws.shape[0] == self.shape[0], f"{ws.shape=} vs {self.shape=}"
1923
+
1924
+ ws = ws / ws.sum(level=level)
1925
+ avr = (self.multiply(ws, 0)).sum(level=level).unstack_but(level)
1926
+ if unc:
1927
+ from uncertainties.unumpy import uarray
1928
+ sd = ((self.sub(avr) ** 2).multiply(ws, 0).sum(level=level)) ** 0.5
1929
+ if unc is True:
1930
+ data = uarray(avr.values, sd.values)
1931
+ return DataSeries(data, index=avr.index) if avr.ndim == 1 else \
1932
+ DataTable(data, index=avr.index, columns=avr.columns)
1933
+ else:
1934
+ return pd.concat([avr, sd], keys=['avr', unc], names=['ws']).reorder_levels(
1935
+ [*avr.index.names, 'ws'])
1936
+ return avr
1937
+
1938
+ def __add__(self, other):
1939
+ index_names = self.index.names
1940
+ return pd.concat([self.reset_index(), other.reset_index()]).set_index(list(index_names))
1941
+
1942
+ def __or__(self: DTable, other: dict):
1943
+ (out := self.copy()).__ior__(other)
1944
+ return out
1945
+
1946
+ def __ior__(self: DTable, other: dict):
1947
+ new_levels = list(other)
1948
+ self[new_levels] = [*other.values()]
1949
+ self.set_index(new_levels, append=True, inplace=True)
1950
+ return self
1951
+
1952
+ def freeze(self, state: bool = True):
1953
+ old_state = getattr(self, '_hash', None)
1954
+ self._hash = id(self) if state else None
1955
+ return old_state
1956
+
1957
+ def __hash__(self):
1958
+ if getattr(self, '_hash', None) is None:
1959
+ super().__hash__()
1960
+ else:
1961
+ return self._hash
1962
+
1963
+ def hash_str(self, n=0, *, index=True) -> str:
1964
+ """Create stable hash string of required length using sha256
1965
+
1966
+ :param n: length of hash str (0 - use all 64 hex chars sha256 returns)
1967
+ :param index: include index to hash calculation
1968
+ """
1969
+ from hashlib import sha256
1970
+ hash_value = sha256(pd.util.hash_pandas_object(self, index=index).values)
1971
+ return hash_value.hexdigest()[-n:]
1972
+
1973
+
1974
+ def slicer(index: pd.Index | pd.MultiIndex, kws):
1975
+ """ Slicing the index with kws requested
1976
+ Args:
1977
+ index: Multi (or standard) index to be sliced
1978
+ kws: the level values requested which define the slice
1979
+
1980
+ Returns: tuple of slices to be used to filter the datatable
1981
+ """
1982
+ if index.nlevels > 1: # MultiIndex
1983
+ return tuple(q if (q := kws.get(lvl, _ALL)) is _ALL
1984
+ else index.isin(as_list(q), lvl) # map of found level values
1985
+ for lvl in index.names)
1986
+
1987
+ (level, val), *not_existing_levels = kws.items()
1988
+ assert level == index.name
1989
+ assert not not_existing_levels
1990
+ return as_list(val)
1991
+
1992
+
1993
+ def path_fixer(root, fixer_name, cols='path') -> Callable[[pd.DataFrame], pd.DataFrame]:
1994
+ """Create function to transform path column in a given DataFrame
1995
+ by either add to or crop from it the root path.
1996
+
1997
+ :param root: root path to add or crop
1998
+ :param fixer_name: name of the transformation to perform: 'add' | 'crop'
1999
+
2000
+ :return function object receiving and producing a dataframe
2001
+ """
2002
+ if not root:
2003
+ return None
2004
+
2005
+ cols = set(as_list(cols))
2006
+
2007
+ from . import filesproc as proc
2008
+ fixer = {
2009
+ 'add': proc.root_adder,
2010
+ 'crop': proc.root_cropper
2011
+ }[fixer_name]
2012
+
2013
+ def fix_path(df):
2014
+ if columns := [c for c in df.columns if c in cols]:
2015
+ df[columns] = df[columns].applymap(fixer(root))
2016
+ return df
2017
+
2018
+ return fix_path
2019
+
2020
+
2021
+ def _index_from(s, idx):
2022
+ return idx if idx is not None else \
2023
+ s.index if isinstance(s, pd.Series) else \
2024
+ pd.RangeIndex(len(s))
2025
+
2026
+
2027
+ def kron(s1: Union[pd.Series, Collection], s2: Union[pd.Series, Collection],
2028
+ index1: pd.Index = None, index2: pd.Index = None):
2029
+ """Create Series or DataSeries (depending on the input types)
2030
+ from kron multiplication of two Series
2031
+
2032
+ :param s1: Input Series or Collection (if later, index1 must be supplied)
2033
+ :param s2: Input Series or Collection (if later, index2 must be supplied)
2034
+ :param index1: Optional replacing index in s1 or providing if missing
2035
+ :param index2: Optional replacing index in s1 or providing if missing
2036
+ """
2037
+ Series = DataSeries if isinstance(s1, DataSeries) or isinstance(s1, DataSeries) else pd.Series
2038
+ return Series(np.kron(s1, s2), index=pd.MultiIndex.from_product(
2039
+ [_index_from(s1, index1), _index_from(s2, index2)]))
2040
+
2041
+
2042
+ def outer(s1: Union[pd.Series, Collection], s2: Union[pd.Series, Collection],
2043
+ index1: pd.Index = None, index2: pd.Index = None):
2044
+ """Create 2D Table (DataFrame or DataTable, depending on input types)
2045
+ from outer multiplication of two Series
2046
+
2047
+ :param s1: Input Series or Collection (if later, index1 must be supplied)
2048
+ :param s2: Input Series or Collection (if later, index2 must be supplied)
2049
+ :param index1: Optional replacing index in s1 or providing if missing
2050
+ :param index2: Optional replacing index in s1 or providing if missing
2051
+ """
2052
+ Table = DataTable if isinstance(s1, DataSeries) or isinstance(s1, DataSeries) else pd.DataFrame
2053
+ return Table(np.outer(s1, s2), index=_index_from(s1, index1), columns=_index_from(s2, index2))
2054
+
2055
+
2056
+ class DataSeries(_TableMixIn, pd.Series):
2057
+ #
2058
+ # def kron(self, pd.Series):
2059
+ # weights = pdt.DataSeries(np.kron(acr_ws, [*rgn_ws.values()]),
2060
+ # index=pd.MultiIndex.from_product(
2061
+ # [acr_ws.index, pd.Index(rgn_ws.keys(), name='region')]
2062
+ # ))
2063
+
2064
+ @property
2065
+ def _constructor(self):
2066
+ return DataSeries
2067
+
2068
+ @property
2069
+ def _constructor_expanddim(self):
2070
+ return DataTable
2071
+
2072
+ def __getattr__(self, item):
2073
+ if isinstance(self.index, pd.MultiIndex):
2074
+ for lvl in self.index.levels:
2075
+ if item in lvl:
2076
+ return self.xs(item, level=lvl.name)
2077
+ return super().__getattr__(item)
2078
+
2079
+ def _prep_repr(self):
2080
+ return self.to_frame(name=f'{self.name} (Series)')
2081
+
2082
+ def _repr_html_(self):
2083
+ return self._prep_repr()._repr_html_()
2084
+
2085
+ def __repr__(self):
2086
+ return self._prep_repr().__repr__()
2087
+
2088
+ def __str__(self):
2089
+ return self._prep_repr().__str__()
2090
+
2091
+
2092
+ class DataTable(_TableMixIn, pd.DataFrame):
2093
+ @property
2094
+ def _constructor(self):
2095
+ return DataTable
2096
+
2097
+ @property
2098
+ def _constructor_sliced(self):
2099
+ return DataSeries
2100
+
2101
+ def unique_level_values(self, level: Union[int, str], axis=0):
2102
+ """
2103
+ Return unique levels values for given level in given MultiIndex axis
2104
+ :param level: level's name or id
2105
+ :param axis: 0 - index, 1 - columns
2106
+ :return: list of values
2107
+ """
2108
+ idx = self.axes[axis]
2109
+ if idx.nlevels == 1:
2110
+ assert level == 0 or idx.name == level
2111
+ return idx
2112
+ lid = idx._names.index(level) if isinstance(level, str) else level
2113
+ return idx._levels[lid]
2114
+
2115
+ def __getattr__(self, item):
2116
+ get = pd.DataFrame.__getattribute__
2117
+ columns = get(self, 'columns')
2118
+ index = get(self, 'index')
2119
+ xs = get(self, 'xs')
2120
+
2121
+ for ax, idx in [(1, columns), (0, index)]:
2122
+ if isinstance(idx, pd.MultiIndex):
2123
+ for lvl in idx.levels:
2124
+ if item in lvl:
2125
+ return xs(item, axis=ax, level=lvl.name)
2126
+ elif item in idx:
2127
+ return xs(item, ax)
2128
+ return super().__getattr__(item)
2129
+
2130
+ def _repr_html_(self):
2131
+ if TableFormats._html_as_text:
2132
+ text = self.__repr__()
2133
+ return f"""<pre style="font-size: 12px">{text}</pre>"""
2134
+
2135
+ if self._info_repr():
2136
+ buf = StringIO("")
2137
+ self.info(buf=buf)
2138
+ # need to escape the <class>, should be the first line.
2139
+ val = buf.getvalue().replace("<", r"&lt;", 1)
2140
+ val = val.replace(">", r"&gt;", 1)
2141
+ return "<pre>" + val + "</pre>"
2142
+
2143
+ get_option = pd._config.get_option
2144
+ if get_option("display.notebook_repr_html"):
2145
+ dfm = pd.io.formats.format
2146
+ formatter = dfm.DataFrameFormatter(
2147
+ self,
2148
+ columns=None,
2149
+ col_space=None,
2150
+ na_rep="NaN",
2151
+ formatters=TableFormats.formatters(self),
2152
+ float_format=None,
2153
+ sparsify=None,
2154
+ justify=None,
2155
+ index_names=True,
2156
+ header=True,
2157
+ index=True,
2158
+ bold_rows=True,
2159
+ escape=True,
2160
+ max_rows=get_option("display.max_rows"),
2161
+ min_rows=get_option("display.min_rows"),
2162
+ max_cols=get_option("display.max_columns"),
2163
+ show_dimensions=get_option("display.show_dimensions"),
2164
+ decimal=".",
2165
+ )
2166
+ return dfm.DataFrameRenderer(formatter).to_html(notebook=True)
2167
+ else:
2168
+ return None
2169
+
2170
+ def __repr__(self):
2171
+ get_option = pd._config.get_option
2172
+ # print(f'=====repr({id(self)})')
2173
+ buf = StringIO("")
2174
+ if self._info_repr():
2175
+ self.info(buf=buf)
2176
+ return buf.getvalue()
2177
+ width = get_option("display.expand_frame_repr") and \
2178
+ get_option("display.width") or None
2179
+
2180
+ self.to_string(
2181
+ buf=buf,
2182
+ float_format=':.5'.format,
2183
+ formatters=TableFormats.formatters(self),
2184
+ max_rows=get_option("display.max_rows"),
2185
+ min_rows=get_option("display.min_rows"),
2186
+ max_cols=get_option("display.max_columns"),
2187
+ show_dimensions=get_option("display.show_dimensions"),
2188
+ line_width=width,
2189
+ max_colwidth=get_option("display.max_colwidth"),
2190
+ )
2191
+ return buf.getvalue()
2192
+
2193
+ def item_labels(self, idx: int, index=True, data=False):
2194
+ """"""
2195
+ from .label import Labels
2196
+ return Labels.from_frame(self.iloc[[idx]], index=index, data=data)[0]
2197
+
2198
+
2199
+ def as_table(x: PTable, series: bool | None = None) -> DTable:
2200
+ """
2201
+ Ensure result is of a Table type (DataTable or DataSeries),
2202
+ according to the input form, if ``series`` is `None`.
2203
+
2204
+ Otherwise, try to cast into requested form or raise ``TypeError``.
2205
+
2206
+ :param x: any table type (pandas or derived)
2207
+ :param series: if True | False - return specifically `DataSeries` or `DataTable`.
2208
+ :return: table or series
2209
+ """
2210
+ is_series = isinstance(x, pd.Series)
2211
+ is_frame = isinstance(x, pd.DataFrame)
2212
+
2213
+ # neither series nor frame, no conversion needed, ty to create directly whats required
2214
+ if not (is_series or is_frame):
2215
+ return DataSeries(x) if series else DataTable(x)
2216
+
2217
+ # first bring into proper series / frame form, not necessary a table
2218
+ if series is True: # requested series
2219
+ if is_frame: # frame -> series
2220
+ if len(x.columns) > 1:
2221
+ raise TypeError('Can''t convert frame with columns: {x.columns} into series')
2222
+ x = x[x.columns[0]]
2223
+ elif series is False and is_series: # requested frame
2224
+ x = x.to_frame() # but it's a series
2225
+ # otherwise no conversion needed
2226
+
2227
+ if isinstance(x, _TableMixIn):
2228
+ return x
2229
+ if isinstance(x, pd.DataFrame):
2230
+ return DataTable(x)
2231
+ if isinstance(x, pd.Series):
2232
+ return DataSeries(x)
2233
+ raise TypeError(f"Can't cast {type(x)} to a Table class")
2234
+
2235
+
2236
+ def append_col(df: pd.DataFrame,
2237
+ col: Union[TCol, list[TCol]],
2238
+ values: Union[pd.DataFrame, Collection],
2239
+ levels: Collection[str] = None) -> pd.DataFrame:
2240
+ """
2241
+ Append column(s) to the DataFrame (may also overwrite)
2242
+ and match columns levels if needed.
2243
+ :param df:
2244
+ :param col: str | tuple | list
2245
+ str - a regular name, then place it at the top level
2246
+ tuple - of str for multiple levels - then start
2247
+ filling the existing columns levels from the top
2248
+ and extend them if needed
2249
+ list - list of str or tuple - to add multiple columns
2250
+ :param values: values of new columns(s)
2251
+ if multiple columns - same size collection of 1D arrays
2252
+ if value is a DataFrame, its columns' levels are prepended
2253
+ by cols and the added into `df`
2254
+ :param levels: optionally provide names for the levels,
2255
+ must be same number of them as the final levels
2256
+ :return: return the input df (the operations are inplace)
2257
+ """
2258
+
2259
+ def add_levels(n):
2260
+ df.columns = pd.MultiIndex.from_product([df.columns, *[*[['']] * n]])
2261
+
2262
+ if isinstance(values, pd.DataFrame):
2263
+ assert isinstance(col, (str, tuple)), "With values as DataFrame, col must be str or tuple"
2264
+ col = as_list(col)
2265
+ for vc in values.columns:
2266
+ df = append_col(df, (*col, *as_list(vc)), values[vc], levels)
2267
+ return df
2268
+
2269
+ if isinstance(col, list):
2270
+ if len(values) != len(col) and (
2271
+ hasattr(values, 'shape')
2272
+ and values.ndim == 2
2273
+ and values.shape[1] == len(col)):
2274
+ values = values.T
2275
+
2276
+ for c, vals in zip(col, values):
2277
+ df = append_col(df, c, vals, levels)
2278
+ return df
2279
+
2280
+ levels_num = len(df.columns.names)
2281
+ if isinstance(col, str):
2282
+ if levels_num > 1:
2283
+ col = (col, *[''] * (levels_num - 1))
2284
+ elif isinstance(col, tuple):
2285
+ if levels_num > len(col):
2286
+ col = (*col, *[''] * (levels_num - len(col)))
2287
+ elif levels_num < len(col):
2288
+ add_levels(len(col) - levels_num)
2289
+ if levels:
2290
+ df.columns.names = levels
2291
+ df[col] = values
2292
+ return df
2293
+
2294
+
2295
+ def apply_index_level(df, level, func):
2296
+ """Alter index by applying a function on specific level of index"""
2297
+ idx_df = df.index.to_frame()
2298
+ idx_df[level] = idx_df[level].apply(func)
2299
+ df.index = idx_df.set_index(df.index.names).index
2300
+ return df
2301
+
2302
+
2303
+ def key_based_mapper(func_map: dict[Any, Callable]):
2304
+ """Creates functions to use to map apply to DataFrames based on its
2305
+ columns index keys - different function per key.
2306
+
2307
+ :param func_map: dictionary of functions
2308
+ :return: function to use in DataFrame.apply
2309
+ """
2310
+
2311
+ def mapper(x):
2312
+ return pd.Series({k: func_map[k](v) for k, v in x.items()})
2313
+
2314
+ return mapper
2315
+
2316
+
2317
+ def unc_split(df, axis=1, names=('val', 'sd')):
2318
+ """ Split uncertainty objects in the table into two columns
2319
+ :param df:
2320
+ :param axis: along with axes to concatenate `val` and `std` arrays
2321
+ :param names: names of the value and std column
2322
+ :return: data frame with separate rows (or columns) for val and std
2323
+ """
2324
+ return pd.concat([df.nominal_value, df.std_dev], axis=axis, keys=names)
2325
+
2326
+
2327
+ def add_levels(df: Union[pd.Series, pd.DataFrame], pos=0, **levels) -> Union[pd.Series, pd.DataFrame]:
2328
+ """
2329
+ Add level to multi-index of the DataFrame or Series.
2330
+
2331
+ :param df: DataFrame or Series to add the level to its index
2332
+ :param pos: position among the original levels, first by default: 0
2333
+ None does no rearrangements
2334
+ :param levels: key: value pairs as in `DataFrame.assign`
2335
+ :return: DataFrame or Series with updated index
2336
+ """
2337
+
2338
+ if isinstance(df, pd.Series):
2339
+ name = df.name
2340
+ return add_levels(df.to_frame(), pos=pos, **levels).iloc[:, 0]
2341
+
2342
+ df = df.assign(**levels).set_index([*levels], append=True)
2343
+
2344
+ if pos not in (None, -1):
2345
+ pos = pos + 1 if pos < 0 else pos
2346
+ original = df.index.names[:-len(levels)]
2347
+ df = df.reorder_levels([*original[:pos], *levels, *original[pos:]])
2348
+ return df
2349
+
2350
+
2351
+ def split_col(df, col: Union[str, Sequence[str]], val=False) -> tuple:
2352
+ """Split dataframe into two by columns.
2353
+
2354
+ :param df: the DataFrame to split
2355
+ :param col: name(s) of columns to split into a new dataframe
2356
+ :param val: instead of data frames return their values (as arrays)
2357
+ :return: tuple of two dataframes or arrays of their values
2358
+ """
2359
+ x, y = df.drop(columns=col), df[as_list(col)]
2360
+ return (x.values, y.values) if val else (x, y)
2361
+
2362
+
2363
+ def plot_hists(df, m):
2364
+ import matplotlib.pyplot as plt
2365
+
2366
+ fig, axes = plt.subplots(int(np.ceil(len(df.columns) / m)), m, figsize=(18, 4))
2367
+ plt.tight_layout()
2368
+ axes = [*axes.flat]
2369
+ for col, axis in zip(df.columns, axes):
2370
+ df.hist(column=col, bins=100, ax=axis)
2371
+ return axes
2372
+
2373
+
2374
+ def group_iter(df: pd.DataFrame, group: Union[str, list], index: Union[str, list] = None,
2375
+ data: Union[str, Iterable] = None, out: str = None, progress: Union[bool, dict] = None,
2376
+ islice: int | tuple | None = None, shuffle=False
2377
+ ) -> Iterable: # Iterator[tuple[Union[str, tuple[str]], Union[DataTable, DataSeries]]]
2378
+ """
2379
+ Create iterator over groups of paths with flexible organization,
2380
+ producing tuples::
2381
+ (group_index, group_data)
2382
+
2383
+ - `group_index` - tuple of (or single) values of categories used for grouping
2384
+ - `group_data` - grouped data elements in form of DataFrame
2385
+
2386
+ Specific structure of the group is controlled by arguments:
2387
+
2388
+ * by which categories the items are grouped as iteration entities:
2389
+ * the content and structure of the `group_data` collection (DataFrame)
2390
+
2391
+ Multi-level indexing is supported for one axis of the frame, by default for the rows.
2392
+ That allows to combine hierarchical access to the labels values and iteration.
2393
+
2394
+ Example:
2395
+
2396
+ >>> ds.categories == ['scene', 'alg', 'kind', 'side', 'time', 'path', 'transforms']
2397
+ >>> (scene, alg), group = next(
2398
+ ... ds.iter(['scene', 'alg'], index=['kind', 'side'], data='path'))
2399
+ ... # Notice drop of all the categories not explicitly requested as index or data,
2400
+ ... # like 'ext', 'transforms', but 'time' remains to ensure unique indexing
2401
+ ... scene, alg == 'Piano', 'NU4'
2402
+ ... group.index == ['kind', 'side', 'time']
2403
+ ... group.columns == ['path']
2404
+
2405
+ :param df: DataFrame with items to group
2406
+ :param group: one or list of categories to group by.
2407
+ :param index: list of categories to be used as multi-index levels
2408
+ of the resulting groups (also defines their order)
2409
+ :param data: keep those categories in the data (as colums)
2410
+ if `None`: produce `DataFrame` with all original data categories as columns
2411
+ :param out: form of the output (None|"frame"|"series"|"pivot")
2412
+ if "frame": return DataFrame with columns as data categories
2413
+ if "series": return `Series` - only if single data column is defined!
2414
+ if "pivot": return DatFrame in pivoted form - to have data-columns as rows,
2415
+ and the `index` argument as columns
2416
+ - column-based multi-level indexing allows hierarchical access:
2417
+ `data.level1.level2 == value`
2418
+ - row-based may be more convinient to `data.itertuples()`:
2419
+ `for (level1, level2), values in data.itertuples():`
2420
+ if None (default): operates as "frame" or "series" if single data column
2421
+ :param islice: arguments for ``itertool.islice`` (start, [stop, [step]])
2422
+ :param shuffle: reorder groups randomly
2423
+ :param progress: show progress bar if True or dict with args for `tqdm`
2424
+ :return: iterator over tuples: ((grouped by categories), group)
2425
+ """
2426
+ assert out in {None, "frame", "series", "pivot"}
2427
+ # get input dataframe indexs and data names
2428
+ db_index = set(df.index.names)
2429
+ db_data = set(df.columns)
2430
+ if not data:
2431
+ data = db_data
2432
+ df = df.reset_index()
2433
+ cats = set(df.columns) # all the categories in the df
2434
+ group = as_list(group) # as list of labels!
2435
+
2436
+ index = as_list(index) or as_list(db_index.difference({*group, *data})) # requested index or default
2437
+ data = as_list(data) or as_list(db_data.difference(set(index))) # and data categories
2438
+
2439
+ unless_subset(cats, index, "`index` requires not existing categories: {inv}")
2440
+ unless_subset(cats, data, "`data` requires not existing categories: {inv}")
2441
+
2442
+ if len(set(index).intersection(set(data))) > 0:
2443
+ raise "requested index and data can't share values"
2444
+ if out is None:
2445
+ out = "series" if len(data) == 1 else "frame"
2446
+ if out == "series":
2447
+ if len(data) == 1:
2448
+ data = data.pop()
2449
+ else:
2450
+ raise ValueError(f'Multiple requested data columns: {data}\n'
2451
+ f'incompatible with argument out="series"!')
2452
+
2453
+ def prep_group(item):
2454
+ idx, grp = item
2455
+ grp = grp.set_index(index, append=True).droplevel(0)
2456
+ return idx, grp.sort_index()[data]
2457
+
2458
+ groups = df.groupby(group if len(group) > 1 else group[0], dropna=False)
2459
+ total = groups.ngroups
2460
+
2461
+ # ------------ requests for reshaping the data -----------------------
2462
+ if islice:
2463
+ from itertools import islice as slice_iter
2464
+
2465
+ start, stop, step = _norm_slice(islice)
2466
+ total = int(np.ceil((min(stop, total) - start) / step))
2467
+ groups = slice_iter(groups, start, stop, step)
2468
+
2469
+ if shuffle:
2470
+ import random
2471
+ groups = list(groups)
2472
+ random.shuffle(groups)
2473
+
2474
+ iterator = map(prep_group, groups)
2475
+
2476
+ # -------------------- show progress bar -----------------------------
2477
+ if progress is None and total >= 20 or progress is True:
2478
+ progress = {}
2479
+
2480
+ if isinstance(progress, dict):
2481
+ from tqdm.auto import tqdm
2482
+ progress = {'leave': False, 'total': total} | progress
2483
+ iterator = tqdm(iterator, **progress)
2484
+
2485
+ return iterator
2486
+
2487
+
2488
+ def _norm_slice(slc):
2489
+ match slc:
2490
+ case int(stop) | (stop, ):
2491
+ return 0, stop, 1
2492
+ case (start, stop):
2493
+ return start, stop, 1
2494
+ case (_, _, _):
2495
+ return slc
2496
+ case _:
2497
+ raise ValueError(f"Invalid slice: {slc}")
2498
+
2499
+
2500
+ def filter_full_groups(df, group) -> (pd.DataFrame, int):
2501
+ """
2502
+ Filter df to leave only items comprising full groups
2503
+ :param df: the DataFrame to filter
2504
+ :param group: column(s) to group by
2505
+ :return: tuple(filtered df, number of full groups)
2506
+ """
2507
+ group = as_list(group)
2508
+ mv_idx = df.index.names.difference(group)
2509
+ df = df.reset_index(mv_idx) # leave only 'scene' in the index
2510
+ full_groups = df.groupby(group).apply(lambda x: issubset_report(x.kind, ))
2511
+ df = df.loc[full_groups[full_groups].index]
2512
+ bad_scenes = (full_groups == False).sum()
2513
+ groups_num = len(full_groups) - bad_scenes
2514
+ if bad_scenes:
2515
+ logging.getLogger().warning(f'Filtered {bad_scenes} (of {len(full_groups)}) '
2516
+ f'incomplete scenes from collection')
2517
+ return df, groups_num
2518
+
2519
+
2520
+ def sorted_index_levels(idx) -> dict[str, pd.Index]:
2521
+ """Dictionary { name: unique level's index} sorted by index length
2522
+
2523
+ :param idx: Multi or nor Index
2524
+ :return: dict sorted by index len
2525
+ """
2526
+ if isinstance(idx, pd.MultiIndex):
2527
+ names = idx._names
2528
+ values = idx._levels
2529
+ by_len = np.argsort([*map(len, values)])
2530
+ return {names[i]: values[i] for i in by_len}
2531
+ else:
2532
+ return {idx.name: idx}
2533
+
2534
+
2535
+ def all_bool(s: pd.Series):
2536
+ bool_or_null = lambda x: isinstance(x, bool) or x is None or x is np.nan
2537
+ return s.dtype.hasobject and all(map(bool_or_null, s.values))
2538
+
2539
+
2540
+ def all_str(s: pd.Series):
2541
+ str_or_null = lambda x: isinstance(x, str) or x is None or x is np.nan
2542
+ return s.dtype.hasobject and all(map(str_or_null, s.values))
2543
+
2544
+
2545
+ def series_has_types(s: pd.Series, *types):
2546
+ """Check if series contains elements of give `object` types, ignoring Nans"""
2547
+ of_types_or_null = lambda x: isinstance(x, types) or x is None or x is np.nan
2548
+ return s.dtype.hasobject and all(map(of_types_or_null, s.values))
2549
+
2550
+
2551
+ def missing_associates(db: pd.DataFrame, select: dict, assoc: dict) -> tuple[pd.MultiIndex, pd.MultiIndex]:
2552
+ """
2553
+ From selected subset of labels db find items with no associates in the db.
2554
+
2555
+ Labels are represented by the multiindex.
2556
+ Labels of associates are determined by replacing values of the items labels
2557
+ according to the `assoc` dict.
2558
+
2559
+ :param db: DataFrame with MultiIndex encoding the data labels
2560
+ :param select: {label: value} - dict used to select items to find associates with
2561
+ :param assoc: {label: new_value} replacement rules to build for a given label an associated one
2562
+
2563
+ :return: missing labels, their associates
2564
+ """
2565
+ sel_idx = db.qix(**select).index
2566
+ rep_index = set_index_levels(sel_idx, **assoc)
2567
+ # missing_mask = ~rep_index.isin(db.index) # slow for large db
2568
+ idx_set = set(db.index)
2569
+ missing_mask = ~np.array([*map(idx_set.__contains__, rep_index)], dtype=bool)
2570
+ return rep_index[missing_mask], sel_idx[missing_mask]