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.
- iad/core/__init__.py +9 -0
- iad/core/array.py +1961 -0
- iad/core/binary.py +377 -0
- iad/core/cache.py +903 -0
- iad/core/codetools.py +203 -0
- iad/core/datatools.py +671 -0
- iad/core/docs/locators.ipynb +754 -0
- iad/core/dotstyle.py +99 -0
- iad/core/env.py +271 -0
- iad/core/events.py +650 -0
- iad/core/filesproc.py +1046 -0
- iad/core/fnctools.py +390 -0
- iad/core/label.py +240 -0
- iad/core/logs.py +182 -0
- iad/core/nptools.py +449 -0
- iad/core/one_dark.puml +881 -0
- iad/core/param/__init__.py +17 -0
- iad/core/param/confargparse.py +55 -0
- iad/core/param/paramaze.py +339 -0
- iad/core/param/tbox.py +277 -0
- iad/core/paths.py +563 -0
- iad/core/pdtools.py +2570 -0
- iad/core/pydantools/__init__.py +5 -0
- iad/core/pydantools/fixed_pydantic_yaml/__init__.py +32 -0
- iad/core/pydantools/fixed_pydantic_yaml/compat/__init__.py +0 -0
- iad/core/pydantools/fixed_pydantic_yaml/compat/hacks.py +76 -0
- iad/core/pydantools/fixed_pydantic_yaml/compat/old_enums.py +37 -0
- iad/core/pydantools/fixed_pydantic_yaml/compat/representers.py +92 -0
- iad/core/pydantools/fixed_pydantic_yaml/compat/types.py +122 -0
- iad/core/pydantools/fixed_pydantic_yaml/compat/yaml_lib.py +104 -0
- iad/core/pydantools/fixed_pydantic_yaml/ext/__init__.py +1 -0
- iad/core/pydantools/fixed_pydantic_yaml/ext/semver.py +152 -0
- iad/core/pydantools/fixed_pydantic_yaml/ext/versioned_model.py +113 -0
- iad/core/pydantools/fixed_pydantic_yaml/main.py +30 -0
- iad/core/pydantools/fixed_pydantic_yaml/mixin.py +281 -0
- iad/core/pydantools/fixed_pydantic_yaml/model.py +20 -0
- iad/core/pydantools/fixed_pydantic_yaml/py.typed +1 -0
- iad/core/pydantools/fixed_pydantic_yaml/version.py +1 -0
- iad/core/pydantools/models.py +560 -0
- iad/core/regexp.py +348 -0
- iad/core/short.py +308 -0
- iad/core/strings.py +635 -0
- iad/core/unc_panda.py +270 -0
- iad/core/units.py +58 -0
- iad/core/wrap.py +420 -0
- ialdev_core-0.1.0.dist-info/METADATA +73 -0
- ialdev_core-0.1.0.dist-info/RECORD +48 -0
- 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"<", 1)
|
|
2140
|
+
val = val.replace(">", r">", 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]
|