ialdev-dataman 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/dataman/__init__.py +22 -0
- iad/dataman/datacast/__init__.py +8 -0
- iad/dataman/datacast/caster.py +523 -0
- iad/dataman/datacast/collect.py +1466 -0
- iad/dataman/datacast/labeled.py +80 -0
- iad/dataman/datacast/scan.py +178 -0
- iad/dataman/datacast/transforms.py +40 -0
- iad/dataman/datacast/transtools.py +608 -0
- iad/dataman/env.py +10 -0
- iad/dataman/factories.py +143 -0
- iad/dataman/models.py +425 -0
- iad/dataman/resman.py +1229 -0
- ialdev_dataman-0.1.0.dist-info/METADATA +33 -0
- ialdev_dataman-0.1.0.dist-info/RECORD +15 -0
- ialdev_dataman-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,1466 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from iad.core.events import Timer
|
|
4
|
+
|
|
5
|
+
with Timer(f" ← {__file__} imports", "timing", min=0.1, pre=f' → importing in {__file__} ...'):
|
|
6
|
+
from collections import namedtuple
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Iterable, Sequence, Tuple, Callable, Union, Literal, Any, TYPE_CHECKING, Collection
|
|
9
|
+
import pandas as pd
|
|
10
|
+
|
|
11
|
+
# utils import
|
|
12
|
+
from iad.core import as_list, logger, drop_undef, as_iter
|
|
13
|
+
import iad.core.pdtools as pdt
|
|
14
|
+
from iad.core.cache import CacheMode, Cacher
|
|
15
|
+
|
|
16
|
+
# internal imports
|
|
17
|
+
from . import transtools as tr, CollectTable
|
|
18
|
+
from .transtools import Col, DataItem
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
import numpy as np
|
|
22
|
+
from .caster import DataCaster
|
|
23
|
+
|
|
24
|
+
PathT = Union[Path, str]
|
|
25
|
+
|
|
26
|
+
StringS = pdt.StringS
|
|
27
|
+
|
|
28
|
+
_log = logger('datacast.collect')
|
|
29
|
+
|
|
30
|
+
__all__ = ['DataCollection', 'SinkRepo']
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class DataCollection:
|
|
34
|
+
"""
|
|
35
|
+
Container for labeled data collected potentially from multiple related datasets.
|
|
36
|
+
|
|
37
|
+
Description
|
|
38
|
+
===========
|
|
39
|
+
Merging Datasets
|
|
40
|
+
----------------
|
|
41
|
+
Practically only datasets sharing same `categories` should be merged
|
|
42
|
+
into collections. Common categories are usually used as index in the
|
|
43
|
+
collection's multi-level indexing structure.
|
|
44
|
+
|
|
45
|
+
Separation of categories into index and data is arbitrary, and
|
|
46
|
+
is completely controlled by user, but to reduce configuration
|
|
47
|
+
complexity some default assumptions are made:
|
|
48
|
+
- default data categories: `path`, `data`, `transformed`, `transforms`
|
|
49
|
+
- all other categories used as index
|
|
50
|
+
- categories without variations of values and `ext` are dropped
|
|
51
|
+
|
|
52
|
+
Accessing Collection Data Structure
|
|
53
|
+
-----------------------------------
|
|
54
|
+
`DataCollection` provides two levels of access to its data:
|
|
55
|
+
- low level access to `.db` <DatFrame> attribute
|
|
56
|
+
- several high level functions supporting specific use cases:
|
|
57
|
+
|
|
58
|
+
- smart iteration with `iter`
|
|
59
|
+
- query based functions: `filter`, `select`
|
|
60
|
+
- information attributes: `.categories`, `dataset`
|
|
61
|
+
- operations: `drop_non_unique`, `apply_transforms`, ...
|
|
62
|
+
|
|
63
|
+
Iterations
|
|
64
|
+
~~~~~~~~~~
|
|
65
|
+
Method `iter` is aimed to simplify coding of the most common use case
|
|
66
|
+
when processing on every iteration requires access to certain
|
|
67
|
+
semantic context selected from the whole dataset.
|
|
68
|
+
|
|
69
|
+
It provides great flexibility by allowing to:
|
|
70
|
+
- iterate over groups of elements united by custom criteria
|
|
71
|
+
- a group structure can be specified
|
|
72
|
+
- default transformation applied on the fly
|
|
73
|
+
|
|
74
|
+
Data Transformation
|
|
75
|
+
-------------------
|
|
76
|
+
Some data sets include data transformation instructions
|
|
77
|
+
as part of their schemes in form of `transforms` and `transformed`
|
|
78
|
+
labels assigned to specific data items.
|
|
79
|
+
`transforms` (T) define set of operations to be applied on the item.
|
|
80
|
+
`transformed` (Td) describes operations which has been applied.
|
|
81
|
+
|
|
82
|
+
This is especially relevant to make sure data in different
|
|
83
|
+
items is aligned for its size, scale, range, etc.
|
|
84
|
+
|
|
85
|
+
The assumption is that `transforms` would project data into the
|
|
86
|
+
common _reference_ state, and `transformed` defines how the data
|
|
87
|
+
been transformed from this state.
|
|
88
|
+
|
|
89
|
+
The alignment procedure then includes two steps: \n
|
|
90
|
+
1.. Apply all the `transforms` T on the items where they are defined \n
|
|
91
|
+
2.a. Apply inverse transforms Td^(-1) where Td are defines OR \n
|
|
92
|
+
2.b. Apply forward transform Td on the rest of the items
|
|
93
|
+
|
|
94
|
+
Problem of Inverse Transformations
|
|
95
|
+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
96
|
+
Option 2.a is considered to be preferable, if inverse is defined,
|
|
97
|
+
otherwise 2.b. is applied.
|
|
98
|
+
|
|
99
|
+
In the case when both direct and inverse transform can be defined
|
|
100
|
+
the choice of marking `transformed` items with Td or the rest of
|
|
101
|
+
the items in the dataset with Td^(-1) is not quite symmetric,
|
|
102
|
+
as aforementioned reference state may have some natural meaning,
|
|
103
|
+
shared between different datasets.
|
|
104
|
+
|
|
105
|
+
Since every dataset description must be self-contained, in order
|
|
106
|
+
to allow merging between datasets certain semantic convention on
|
|
107
|
+
the data must be maintained, making option 2.a even more desirable.
|
|
108
|
+
|
|
109
|
+
If forced towards 2.b the proper approach would be to apply Td on
|
|
110
|
+
all the data elements in DataCollection _after_ datasets are merged.
|
|
111
|
+
Its not always clear which specific elements require this correction.
|
|
112
|
+
For example cropping can be be universally applied to all the images,
|
|
113
|
+
but values transformation only to the data of same *kind*.
|
|
114
|
+
|
|
115
|
+
Approach to Inverse Transformation
|
|
116
|
+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
117
|
+
To avoid all the complications for step 2 it can be delegated to
|
|
118
|
+
the algorithms sensitive to those transformation which would access
|
|
119
|
+
information in the `transformed` label and act accordingly.
|
|
120
|
+
"""
|
|
121
|
+
|
|
122
|
+
_TRANS_COL = {Col.transforms, Col.transformed}
|
|
123
|
+
_NO_CAT_COL = {Col.read_trans} | _TRANS_COL # non category columns
|
|
124
|
+
|
|
125
|
+
DATA_COL = (Col.path, Col.read_trans)
|
|
126
|
+
|
|
127
|
+
class _HistoryItem(namedtuple('HistoryItem',
|
|
128
|
+
['oper', 'desc', 'args', 'kws', 'before', 'after'],
|
|
129
|
+
defaults=['', '', (), None, None, None]
|
|
130
|
+
)):
|
|
131
|
+
def __repr__(self):
|
|
132
|
+
from iad.core.strings import dict_str
|
|
133
|
+
change = "" if (self.before is None or self.after is None) \
|
|
134
|
+
else f" {self.before}⮕{self.after}"
|
|
135
|
+
desc = self.desc or ','.join(filter(None, [
|
|
136
|
+
','.join(as_list(self.args)),
|
|
137
|
+
dict_str(self.kws)
|
|
138
|
+
]))
|
|
139
|
+
return f"{self.oper}({desc}){change}"
|
|
140
|
+
|
|
141
|
+
class Group(namedtuple('Group', ['gid', 'grp'])):
|
|
142
|
+
""" Namedtuple Group(gid, grp), also can be initialized by a
|
|
143
|
+
tuple-like Group((gid, grp))
|
|
144
|
+
"""
|
|
145
|
+
|
|
146
|
+
def __new__(cls, *args):
|
|
147
|
+
if len(args) == 2:
|
|
148
|
+
gid, grp = args
|
|
149
|
+
else:
|
|
150
|
+
group, gid, grp = args
|
|
151
|
+
if len(group) > 1:
|
|
152
|
+
gid = DataItem(zip(group, gid))
|
|
153
|
+
if not isinstance(grp, CollectTable):
|
|
154
|
+
grp = CollectTable(grp)
|
|
155
|
+
return super().__new__(cls, gid, grp)
|
|
156
|
+
|
|
157
|
+
def __repr__(self):
|
|
158
|
+
return f"<Group> gid: {self.gid}, grp:\n{self.grp}"
|
|
159
|
+
|
|
160
|
+
def fetch(self, **kws):
|
|
161
|
+
self.grp = self.grp.fetch(**kws)
|
|
162
|
+
return self
|
|
163
|
+
|
|
164
|
+
@property
|
|
165
|
+
def db(self):
|
|
166
|
+
return self._db
|
|
167
|
+
|
|
168
|
+
# attributes which could be inherited when modifying a collection
|
|
169
|
+
_inherit_attrs = ['name', 'casters', 'bundle', 'cacher']
|
|
170
|
+
|
|
171
|
+
@classmethod
|
|
172
|
+
def from_db(cls, db, *, like: DataCollection = None,
|
|
173
|
+
history: list[str | tuple] | [str | tuple] = None,
|
|
174
|
+
name='', casters=None, bundle=None, cacher=None):
|
|
175
|
+
"""
|
|
176
|
+
Create a DataCollection object from given db and optionally other
|
|
177
|
+
attributes.
|
|
178
|
+
|
|
179
|
+
:param db: DataTable(Frame) object to build the collection from
|
|
180
|
+
:param like: DataCollection object to inherit some of its attributes
|
|
181
|
+
:param history: list of history records or single history record,
|
|
182
|
+
in which case append to the history in the `like` collection.
|
|
183
|
+
:param name: override that in like. Required if like is None!
|
|
184
|
+
:param casters: if provided override that in like
|
|
185
|
+
:param bundle: if provided override that in like
|
|
186
|
+
:param cacher: if provided override that in like
|
|
187
|
+
:return: a new DataCollection
|
|
188
|
+
"""
|
|
189
|
+
hist_item = lambda h: isinstance(h, str) and cls._HistoryItem('stage', h) or cls._HistoryItem(*h)
|
|
190
|
+
|
|
191
|
+
if like is None and not isinstance(name, str):
|
|
192
|
+
raise ValueError("Provide either name or like")
|
|
193
|
+
casters = casters or []
|
|
194
|
+
bundle = as_list(bundle) or []
|
|
195
|
+
|
|
196
|
+
dc = cls.__new__(cls)
|
|
197
|
+
# TODO insert here handling more than db
|
|
198
|
+
dc._db = db if isinstance(db, CollectTable) else CollectTable(db)
|
|
199
|
+
|
|
200
|
+
if isinstance(history, (str, tuple)):
|
|
201
|
+
history = hist_item(history)
|
|
202
|
+
dc._history = like and like._history + [history] or [history]
|
|
203
|
+
elif isinstance(history, list):
|
|
204
|
+
dc._history = list(map(hist_item, history))
|
|
205
|
+
elif not history:
|
|
206
|
+
dc._history = like and like._history or []
|
|
207
|
+
else:
|
|
208
|
+
raise TypeError("Invalid history argument")
|
|
209
|
+
|
|
210
|
+
lc = locals()
|
|
211
|
+
for attr in cls._inherit_attrs:
|
|
212
|
+
if not (val := lc[attr]) and like:
|
|
213
|
+
val = getattr(like, attr)
|
|
214
|
+
setattr(dc, attr, val)
|
|
215
|
+
dc.cacher is True and dc._create_cacher(True)
|
|
216
|
+
return dc
|
|
217
|
+
|
|
218
|
+
def __init__(self, name: str = None, *,
|
|
219
|
+
datasets: Union[DataCaster, Iterable[DataCaster]] = None,
|
|
220
|
+
query: str | dict | Tuple[str, str | dict] = None,
|
|
221
|
+
bundle: list[str] = None,
|
|
222
|
+
unique=False,
|
|
223
|
+
data: str | Iterable[str] = DATA_COL,
|
|
224
|
+
drop: str | Iterable[str] = None,
|
|
225
|
+
cache: CacheMode | bool | Literal['LOAD', 'SAVE', 'KEEP', 'PASS'] = None,
|
|
226
|
+
temp_cache: bool | Path | str = None,
|
|
227
|
+
calc_cache: PathT | Cacher | bool = False,
|
|
228
|
+
calc_cache_rel: PathT | bool = True,
|
|
229
|
+
progress: bool = False,
|
|
230
|
+
description: str = None):
|
|
231
|
+
""" Create collection of labeled paths from multiple schemes
|
|
232
|
+
extracting labels from data's folders structure.
|
|
233
|
+
|
|
234
|
+
Requires explicit ``datasets`` (list of DataCaster instances).
|
|
235
|
+
For name-based construction (e.g. ``DataCollection('KITTI')``), use
|
|
236
|
+
the factory in ``dataman`` (e.g. ``dataman.create_collection``).
|
|
237
|
+
|
|
238
|
+
:param name: optional name identifying the collection
|
|
239
|
+
:param datasets: DataCaster instances to collect
|
|
240
|
+
:param query: optional query to select only a subset of the collection
|
|
241
|
+
in str or dict form
|
|
242
|
+
:param data: which labels categories to consider as data (others - index)
|
|
243
|
+
:param unique: leave only labels with multiple unique values
|
|
244
|
+
:param drop: try to drop those categories if that keeps the index unique
|
|
245
|
+
:param cache: optionally override caster's caching mode (``None`` - don't)
|
|
246
|
+
:param temp_cache: optionally override caster's ``temp`` argument (``None`` - don't)
|
|
247
|
+
:param calc_cache: path to cache folder, Cacher, True for auto, False to disable
|
|
248
|
+
:param calc_cache_rel: store cached paths relative to this folder
|
|
249
|
+
:param description: String that describes the collection.
|
|
250
|
+
"""
|
|
251
|
+
from .caster import DataCaster
|
|
252
|
+
if datasets is None:
|
|
253
|
+
raise ValueError("DataCollection requires explicit 'datasets' argument. "
|
|
254
|
+
"For name-based lookup use dataman.create_collection().")
|
|
255
|
+
if isinstance(datasets, DataCaster):
|
|
256
|
+
datasets = [datasets]
|
|
257
|
+
changes = drop_undef(cache=cache, temp_cache=temp_cache)
|
|
258
|
+
|
|
259
|
+
def prep_caster(ds):
|
|
260
|
+
if isinstance(ds, DataCaster):
|
|
261
|
+
return DataCaster(**(ds.config.dict() | changes)) if changes else ds
|
|
262
|
+
if isinstance(ds, dict):
|
|
263
|
+
return DataCaster(**ds, **changes)
|
|
264
|
+
raise TypeError(f"Expected DataCaster or dict, got {type(ds)}")
|
|
265
|
+
|
|
266
|
+
self.casters = [prep_caster(d) for d in as_iter(datasets, no_iter=(DataCaster,))]
|
|
267
|
+
if not self.casters:
|
|
268
|
+
raise ValueError("Can't create collection without datasets")
|
|
269
|
+
from pandas import concat
|
|
270
|
+
db = CollectTable(concat(cst.collect(progress=progress) for cst in self.casters))
|
|
271
|
+
self.name = name or ','.join(filter(None, (c.config.name for c in self.casters)))
|
|
272
|
+
self.description = description
|
|
273
|
+
self._history: list[DataCollection._HistoryItem] = []
|
|
274
|
+
bundle = as_list(bundle) or sum(
|
|
275
|
+
filter(None, map(lambda s: s.bundle, self.casters)), []
|
|
276
|
+
)
|
|
277
|
+
self.bundle = list(set(bundle))
|
|
278
|
+
if db.empty:
|
|
279
|
+
self._db = db
|
|
280
|
+
return
|
|
281
|
+
sq, dq = ('', query) if isinstance(query, dict) else (query, {})
|
|
282
|
+
db: CollectTable = self._select(db, sq, **dq)
|
|
283
|
+
if unique:
|
|
284
|
+
db = db.drop(columns=db.columns[(db.nunique(dropna=False) == 1)])
|
|
285
|
+
data: set = as_list(data, collect=set)
|
|
286
|
+
db = self._init_transforms(db, data)
|
|
287
|
+
self._db = self._init_index(db, data, drop)
|
|
288
|
+
self.cacher: Cacher = self._create_cacher(calc_cache, calc_cache_rel)
|
|
289
|
+
|
|
290
|
+
def drop_flat_categories(self, *drop, keep=None, only_nan=False, fail=False,
|
|
291
|
+
keep_bundle: bool | str | Collection[str] = True):
|
|
292
|
+
"""
|
|
293
|
+
Remove uninformative categories from the index.
|
|
294
|
+
|
|
295
|
+
- If `drop` is provided, select only remove from those.
|
|
296
|
+
- Categories in `keep` are excluded from the drop.
|
|
297
|
+
|
|
298
|
+
:param drop: if provided, only categories from this list are verified
|
|
299
|
+
:param keep: exclude from verification
|
|
300
|
+
:param only_nan: if `True`, drop only if all category values are `None`
|
|
301
|
+
:param keep_bundle: if `True`, *bundle* categories are excluded,
|
|
302
|
+
otherwise bundle may be reduced. by dropped categories,
|
|
303
|
+
unless *all* the bundle is 'flat' in this case it is kept.
|
|
304
|
+
:param fail: raise `ValueError` on errors or just return without changes
|
|
305
|
+
:return:
|
|
306
|
+
"""
|
|
307
|
+
|
|
308
|
+
def _exit(msg):
|
|
309
|
+
_log.error(msg)
|
|
310
|
+
if fail:
|
|
311
|
+
raise ValueError(msg)
|
|
312
|
+
|
|
313
|
+
db = self._db
|
|
314
|
+
all_cats = set(db.index.names)
|
|
315
|
+
drop, keep = (as_list(_, collect=set) for _ in (drop or all_cats, keep))
|
|
316
|
+
|
|
317
|
+
if isinstance(keep_bundle, bool):
|
|
318
|
+
keep_bundle = self.bundle if keep_bundle else []
|
|
319
|
+
else:
|
|
320
|
+
keep_bundle = as_list(keep_bundle, collect=set)
|
|
321
|
+
if invalid := keep_bundle.difference(self.bundle):
|
|
322
|
+
return _exit(f"Invalid bundle categories {invalid}")
|
|
323
|
+
keep = keep.union(keep_bundle)
|
|
324
|
+
|
|
325
|
+
cats = all_cats.difference(keep).intersection(drop)
|
|
326
|
+
|
|
327
|
+
if (sz := len(db)) < 2:
|
|
328
|
+
return _exit(f"Can't determine flat categories in collection with {sz} rows")
|
|
329
|
+
|
|
330
|
+
drop_cats = [name for name in cats if (
|
|
331
|
+
(unq := db.index.unique(name)).size == 1
|
|
332
|
+
and (not only_nan or pd.isna(unq[0]))
|
|
333
|
+
)]
|
|
334
|
+
|
|
335
|
+
if not drop_cats:
|
|
336
|
+
return
|
|
337
|
+
elif len(drop_cats) == len(all_cats):
|
|
338
|
+
return _exit("Can't drop ALL the index levels!")
|
|
339
|
+
|
|
340
|
+
bundle = set(self.bundle).difference(drop_cats)
|
|
341
|
+
if not bundle and self.bundle:
|
|
342
|
+
return _exit("Dropping categories eliminates the bundle, "
|
|
343
|
+
"consider constraining with keep_bundle")
|
|
344
|
+
|
|
345
|
+
self._db = db.droplevel(list(drop_cats))
|
|
346
|
+
self.bundle = list(bundle)
|
|
347
|
+
|
|
348
|
+
def _init_index(self, db, data: set, drop):
|
|
349
|
+
"""
|
|
350
|
+
Move categories (columns) into index, except of:
|
|
351
|
+
- explicitly defined as `data`
|
|
352
|
+
- columns containing any object type except of ``bool``, ``str``
|
|
353
|
+
"""
|
|
354
|
+
def _dtype_has_object_items(series):
|
|
355
|
+
dt = series.dtype
|
|
356
|
+
if getattr(dt, 'hasobject', False):
|
|
357
|
+
return True
|
|
358
|
+
from pandas.api.types import is_object_dtype
|
|
359
|
+
return is_object_dtype(series)
|
|
360
|
+
|
|
361
|
+
data = data.intersection(db.columns) | {c for c, s in db.items() if _dtype_has_object_items(s) and
|
|
362
|
+
not pdt.series_has_types(db[c], (str, bool))}
|
|
363
|
+
drop: set = as_list(drop, collect=set)
|
|
364
|
+
db.drop(columns=list(drop & data), inplace=True) # drop requested if they are not in data
|
|
365
|
+
if index_labels := list(db.columns.drop(data)): # all the non-data labels
|
|
366
|
+
db.set_index(index_labels, inplace=True) # create index
|
|
367
|
+
if db.index.unique().size != db.index.size:
|
|
368
|
+
raise IndexError("Collection index is not unique, invalid scheme?")
|
|
369
|
+
db = self.drop_non_unique(db, drop, inplace=True) # drop requested index levels only if unique
|
|
370
|
+
return db
|
|
371
|
+
|
|
372
|
+
def _init_transforms(self, db, data: set):
|
|
373
|
+
if Col.read_trans in data:
|
|
374
|
+
db[Col.read_trans] = tr.compile_read_transforms(db, ns=tr.transforms())
|
|
375
|
+
db.drop(columns=list(self._TRANS_COL - data), errors='ignore', inplace=True)
|
|
376
|
+
return db
|
|
377
|
+
|
|
378
|
+
def _create_cacher(self, cache: Cacher | PathT | bool, cache_rel: PathT = True) -> Cacher:
|
|
379
|
+
""" Create cacher object from different forms of definitions
|
|
380
|
+
(in case of Cacher input return it ignoring other arguments).
|
|
381
|
+
|
|
382
|
+
:param cache: path to cache folder or Cacher object
|
|
383
|
+
- False to disable cacher
|
|
384
|
+
- True for automatic path (self.common_root / .cache)
|
|
385
|
+
:param cache_rel: store cached paths tables relative to this folder
|
|
386
|
+
which can be provided explicitly or:
|
|
387
|
+
- False | None | '' - store absolute
|
|
388
|
+
- True | 'common' - use this self.common_root folder
|
|
389
|
+
Ignored if cache is Cacher object
|
|
390
|
+
:return: created Cacher object
|
|
391
|
+
"""
|
|
392
|
+
from iad.core.cache import CacheMode, Cacher
|
|
393
|
+
if isinstance(cache, Cacher):
|
|
394
|
+
return cache
|
|
395
|
+
|
|
396
|
+
if cache_rel in (True, 'common'):
|
|
397
|
+
cache_rel = self.common_root
|
|
398
|
+
|
|
399
|
+
mode = CacheMode.PASS
|
|
400
|
+
if cache:
|
|
401
|
+
mode = CacheMode.KEEP
|
|
402
|
+
if isinstance(cache, bool):
|
|
403
|
+
cache = self.common_root / '.cache'
|
|
404
|
+
else:
|
|
405
|
+
cache = Path(cache).expanduser().absolute()
|
|
406
|
+
cache.mkdir(parents=True, exist_ok=True) # make sure cache folder is valid
|
|
407
|
+
_log.debug(f'Caching folder for {self.__class__.__name__} "{self.name}": {str(cache)}')
|
|
408
|
+
|
|
409
|
+
return Cacher(folder=cache, mode=mode,
|
|
410
|
+
pack=pdt.path_fixer(cache_rel, 'crop'),
|
|
411
|
+
unpack=pdt.path_fixer(cache_rel, 'add'),
|
|
412
|
+
load=pdt.pd.read_pickle, save=lambda f, df: df.to_pickle(f))
|
|
413
|
+
|
|
414
|
+
def measure_data(self, msr_fnc: Callable[[...], dict | Sequence], *, df: pd.DataFrame = None,
|
|
415
|
+
pos: StringS = Col.data, kwcol: StringS = None,
|
|
416
|
+
alias: dict[str, str] = None, out: StringS = None,
|
|
417
|
+
cache: Cacher | bool | dict = False, parallel: pdt.Parallel | str | bool = False,
|
|
418
|
+
direct: bool = False, reindex: bool = True, args: tuple = (), **kws) -> pd.DataFrame:
|
|
419
|
+
"""
|
|
420
|
+
Perform measurements on the internal (default) or provided DataFrame.
|
|
421
|
+
|
|
422
|
+
Measurements are per row, and are defined by the provided ``msr_fnc`` which MUST
|
|
423
|
+
- receive `at least one positional argument` (data), and
|
|
424
|
+
- produce results in either named (dict-like) or not form (list-like).
|
|
425
|
+
|
|
426
|
+
``out`` argument may be used provide (or override) names of the results,
|
|
427
|
+
which defines also the names of the resulting columns in the returned DataFrame.
|
|
428
|
+
|
|
429
|
+
Additional arguments of ``msr_fnc`` may come either from columns,
|
|
430
|
+
or are common for all the rows (``args`` and ``kws`` arguments).
|
|
431
|
+
|
|
432
|
+
In current implementation to use index levels as inputs, they should be first reset into columns.
|
|
433
|
+
|
|
434
|
+
``pos``, ``kwcol`` and ``alias`` arguments relate columns names with ``msr_fnc`` arguments,
|
|
435
|
+
and MUST include `data` column in one of them.
|
|
436
|
+
|
|
437
|
+
Caching of the calculations results is configured by `cache` argument.
|
|
438
|
+
Since index is not used, it's not cached either, and is attached to the columns
|
|
439
|
+
loaded from the cache.
|
|
440
|
+
|
|
441
|
+
Parallelization control follows ``pdtools.Parallel.from_flag()``
|
|
442
|
+
|
|
443
|
+
:param msr_fnc: function to
|
|
444
|
+
:param df: if provided used instead of the internal db
|
|
445
|
+
:param pos: name[s] of the columns to be used as msr_fnc positional arguments
|
|
446
|
+
:param kwcol: name[s] of the columns same as msr_fnc keyword arguments
|
|
447
|
+
:param alias: mapping of columns names into the corresponding msr_fnc names
|
|
448
|
+
:param out: names of the resulting columns
|
|
449
|
+
:param cache: a Cacher object | True to use default | dict to update default's parameters
|
|
450
|
+
str as shortcut {'name': str} | False or None to disable
|
|
451
|
+
:param parallel: 'swift'|'jobs'|int|True|False|None
|
|
452
|
+
:param direct: directly perform pre-built transformations
|
|
453
|
+
:param reindex: reindex the resulting frame to source index
|
|
454
|
+
:param args: additional positional arguments passed into msr_fnc
|
|
455
|
+
:param kws: additional keyword arguments passed into msr_fnc
|
|
456
|
+
:return:
|
|
457
|
+
"""
|
|
458
|
+
# --- Pre-process Arguments
|
|
459
|
+
pos, kwcol, out = map(as_list, [pos, kwcol, out])
|
|
460
|
+
alias = alias or {}
|
|
461
|
+
|
|
462
|
+
df = df if df is not None else self.db
|
|
463
|
+
engine = pdt.Parallel.from_flag(parallel) or type(df) # parallel or DataFrame apply-engine
|
|
464
|
+
|
|
465
|
+
def cache_config():
|
|
466
|
+
"""return dict with caching parameters:"""
|
|
467
|
+
|
|
468
|
+
def auto_name(name=''):
|
|
469
|
+
db_hash = self.hash_str(columns=sorted(use_col), index=False)
|
|
470
|
+
return '_'.join(filter(bool, [getattr(msr_fnc, '__name__', ''), name, db_hash]))
|
|
471
|
+
|
|
472
|
+
if not cache:
|
|
473
|
+
return {'mode': False} # switch cacher OFF
|
|
474
|
+
if isinstance(cache, dict):
|
|
475
|
+
return cache | {'name': cache.get('name', auto_name())} # update cacher
|
|
476
|
+
if isinstance(cache, (bool, str)):
|
|
477
|
+
return {'name': auto_name('' if cache is True else cache)}
|
|
478
|
+
raise TypeError(f"Unexpected {type(cache)=}")
|
|
479
|
+
|
|
480
|
+
def req_trans():
|
|
481
|
+
"""Find transform columns required to produce missing columns or raise if impossible!
|
|
482
|
+
Return name of the column with transform and set of columns used by the measurement
|
|
483
|
+
"""
|
|
484
|
+
avl_cols = [*df.columns] # columns available for measurement function
|
|
485
|
+
req_cols = {*pos, *kwcol, *alias}
|
|
486
|
+
if missing := req_cols.difference(avl_cols): # from required by the mappings
|
|
487
|
+
trans_inp, trans_out = {Col.path, Col.read_trans}, {Col.data} # supported transform
|
|
488
|
+
if missing == trans_out and trans_inp.issubset(avl_cols): # transform can provide missing
|
|
489
|
+
return Col.read_trans, (req_cols - trans_out).union(trans_inp)
|
|
490
|
+
raise LookupError(f"Missing columns defined as function arguments: {missing}.")
|
|
491
|
+
return None, req_cols # not needed
|
|
492
|
+
|
|
493
|
+
if not direct:
|
|
494
|
+
# --- Construct measurement function: first by-row version, then prepend transform, then apply
|
|
495
|
+
trans, use_col = req_trans()
|
|
496
|
+
row_fnc = pdt.col_args_row_func(msr_fnc, pos=pos, kwcol=kwcol, alias=alias, out=out)
|
|
497
|
+
row_fnc = tr.row_func_prepend_transform(row_fnc, trans=trans) # does nothing if not trans
|
|
498
|
+
else:
|
|
499
|
+
row_fnc = tr.row_func_prepend_transform(msr_fnc) # use pre-built transformations
|
|
500
|
+
|
|
501
|
+
if not isinstance(cache, Cacher):
|
|
502
|
+
cache = self.cacher.context(**cache_config())
|
|
503
|
+
|
|
504
|
+
@cache # make measurement cachable if 'mode' is active
|
|
505
|
+
def measure(_df, _args, **_kws): # expose arguments caching must be sensitive to
|
|
506
|
+
return engine.apply(_df, row_fnc, axis=1, result_type='expand', args=_args, **_kws)
|
|
507
|
+
|
|
508
|
+
return measure(df.reset_index(), args, **kws).set_index(df.index) if reindex \
|
|
509
|
+
else measure(df.reset_index(), args, **kws)
|
|
510
|
+
|
|
511
|
+
def changelog(self, oper='', desc='', args=(), kws=None, before=None, after=None):
|
|
512
|
+
"""Add to the changelog of the data collection.
|
|
513
|
+
if exactly one of `before` or `after` is provided, the other is taken from the current state.
|
|
514
|
+
|
|
515
|
+
:param oper: name of the operation led to the change
|
|
516
|
+
:param desc: description of the operation, could be parameters
|
|
517
|
+
:param args: arguments the operation function was called with used
|
|
518
|
+
:param kws: keyword arguments the operation function was called with used
|
|
519
|
+
"""
|
|
520
|
+
if before is None and after is not None:
|
|
521
|
+
before = len(self._db)
|
|
522
|
+
elif before is not None and after is None:
|
|
523
|
+
after = len(self._db)
|
|
524
|
+
self._history.append(self._HistoryItem(oper, desc, args, kws or {}, before, after))
|
|
525
|
+
|
|
526
|
+
def copy(self):
|
|
527
|
+
"""Return a copy of the object , deep if requested"""
|
|
528
|
+
return self.from_db(self.db, like=self)
|
|
529
|
+
|
|
530
|
+
@property
|
|
531
|
+
def caster(self):
|
|
532
|
+
"""Caster in the base of the collection or None if multiple"""
|
|
533
|
+
return len(self.casters) == 1 and self.casters[0] or None
|
|
534
|
+
|
|
535
|
+
@property
|
|
536
|
+
def common_root(self) -> Path:
|
|
537
|
+
"""Common root of all the schemes in the collection."""
|
|
538
|
+
if len(self.casters) == 1:
|
|
539
|
+
common = self.casters[0].root
|
|
540
|
+
else:
|
|
541
|
+
from os.path import commonpath
|
|
542
|
+
common = commonpath(s.root for s in self.casters)
|
|
543
|
+
return Path(common)
|
|
544
|
+
|
|
545
|
+
@property
|
|
546
|
+
def categories(self):
|
|
547
|
+
"""Set of categories, in both index and data sections."""
|
|
548
|
+
return set(self.db.columns).union(filter(None, self.db.index.names)) - self._NO_CAT_COL
|
|
549
|
+
|
|
550
|
+
def category(self, name):
|
|
551
|
+
return self.db.index.get_level_values(name).unique()
|
|
552
|
+
|
|
553
|
+
def index_info(self, *, width=70, out=None):
|
|
554
|
+
"""
|
|
555
|
+
Collect and return info about the database index in different forms
|
|
556
|
+
:param out: None - print, str - same as string, dict - {level: unique values}
|
|
557
|
+
:param width: line width in characters
|
|
558
|
+
:return: one of the formats or None if `out` is None
|
|
559
|
+
"""
|
|
560
|
+
import pandas as pd
|
|
561
|
+
|
|
562
|
+
def repr_seq(total, seq):
|
|
563
|
+
if isinstance(seq, str): return seq
|
|
564
|
+
sep_len = len(sep := ', ')
|
|
565
|
+
res = ''
|
|
566
|
+
for s in map(str, seq):
|
|
567
|
+
if len(s) + len(res) + sep_len < total:
|
|
568
|
+
res = res and f"{res}{sep}{s}" or s
|
|
569
|
+
else:
|
|
570
|
+
res = res + f'… [{len(seq)}]'
|
|
571
|
+
break
|
|
572
|
+
return res
|
|
573
|
+
|
|
574
|
+
index = self.db.index
|
|
575
|
+
levels = {
|
|
576
|
+
lvl or '∅': f"Range[{index.start}:{index.stop}:{index.step}]"
|
|
577
|
+
if isinstance(index, pd.RangeIndex) else
|
|
578
|
+
index.unique(lvl) for lvl in index.names
|
|
579
|
+
}
|
|
580
|
+
if out is dict:
|
|
581
|
+
return levels
|
|
582
|
+
|
|
583
|
+
names_width = max(map(len, levels))
|
|
584
|
+
fmt = f"{{:>2}}:{{:<{names_width}}} : {{}}"
|
|
585
|
+
width -= (names_width + len(fmt.format(0, '', '')))
|
|
586
|
+
info = '\n'.join(fmt.format(lid, lvl, repr_seq(width, vals))
|
|
587
|
+
for lid, (lvl, vals) in enumerate(levels.items()))
|
|
588
|
+
return info if out == str else print(info)
|
|
589
|
+
|
|
590
|
+
def __len__(self):
|
|
591
|
+
return len(self.db)
|
|
592
|
+
|
|
593
|
+
def __str__(self):
|
|
594
|
+
return f"📚Collection '{self.name}' [{len(self)}] ({len(self.casters)} schemes)"
|
|
595
|
+
|
|
596
|
+
def __repr__(self):
|
|
597
|
+
if self.empty:
|
|
598
|
+
return f"📚Collection '{self.name}' is EMPTY!"
|
|
599
|
+
bundle = set(self.bundle)
|
|
600
|
+
mark_bundle = lambda c: c in bundle and f"✓{c}" or c
|
|
601
|
+
index_cats = ','.join(mark_bundle(c) for c in self.db.index.names if c)
|
|
602
|
+
data_cats = ','.join(self.db.columns)
|
|
603
|
+
|
|
604
|
+
hsep = ' ▷ '
|
|
605
|
+
hpfx = "➿ Altered: "
|
|
606
|
+
history = hsep.join(map(str, self._history))
|
|
607
|
+
if len(self._history):
|
|
608
|
+
if len(history) > 70:
|
|
609
|
+
history = ('\n ' + hsep).join([hpfx, *map(str, self._history)]) + '\n'
|
|
610
|
+
else:
|
|
611
|
+
history = hpfx + history + '\n'
|
|
612
|
+
|
|
613
|
+
return f"{str(self)}\n" \
|
|
614
|
+
f"{history}" \
|
|
615
|
+
f"🔖 Index[{index_cats}] ⮕ Data[{data_cats}]" \
|
|
616
|
+
f"\n{self.index_info(width=80, out=str)}"
|
|
617
|
+
|
|
618
|
+
def keep_levels(self, level: str | Sequence[str], *, strict=True):
|
|
619
|
+
"""
|
|
620
|
+
**Change internal** ``.db`` by leaving only given level(s) in the *given order*
|
|
621
|
+
in the axis 0 index levels.
|
|
622
|
+
|
|
623
|
+
:param level: name or sequence of names of levels
|
|
624
|
+
:param strict: if True raise when unknown level is provided.
|
|
625
|
+
otherwise just ignore it.
|
|
626
|
+
:param: return self with updated db.
|
|
627
|
+
"""
|
|
628
|
+
self._db = self._db.keep_levels(level, strict=strict)
|
|
629
|
+
return self
|
|
630
|
+
|
|
631
|
+
@staticmethod # Consider: move to pdtools
|
|
632
|
+
def drop_non_unique(src: pdt.DTable, drop: str | Iterable[str], *, inplace=True):
|
|
633
|
+
"""Try to drop categories in drop if that keeps index unique.
|
|
634
|
+
:param src: data frame
|
|
635
|
+
:param drop: categories to drop if all the values are not unique
|
|
636
|
+
:param inplace: if True operate on the input collection db
|
|
637
|
+
:return: updated collection
|
|
638
|
+
"""
|
|
639
|
+
trg = src if inplace else src.copy(True)
|
|
640
|
+
for cat in as_list(drop):
|
|
641
|
+
if cat in trg.index.names:
|
|
642
|
+
new_idx = trg.index.droplevel(cat)
|
|
643
|
+
if len(new_idx.unique()) == len(new_idx):
|
|
644
|
+
trg.index = new_idx
|
|
645
|
+
return trg
|
|
646
|
+
|
|
647
|
+
@staticmethod
|
|
648
|
+
def _select(df: pd.DataFrame, query: str = None, **kws) -> pd.DataFrame:
|
|
649
|
+
""" Select from the given DataFrame a subset according to a query in
|
|
650
|
+
string or dict form.
|
|
651
|
+
:param df: DataFrame
|
|
652
|
+
:param query: str may include an arbitrary python expression supported by
|
|
653
|
+
DataFrame.query() in terms of the categories of the labels:
|
|
654
|
+
``"cat1 == value1 or cat2 * 2 < value"``
|
|
655
|
+
:param kws: query in category=value pairs selects data
|
|
656
|
+
where ``cat1 == value1 and cat2 == value and ...``
|
|
657
|
+
Either kws OR string query may be provided
|
|
658
|
+
:return: resulted selected as a DataFrame
|
|
659
|
+
"""
|
|
660
|
+
if kws and not query:
|
|
661
|
+
query = kws
|
|
662
|
+
elif query and kws:
|
|
663
|
+
raise ValueError("Both query AND keywords arguments are provided!")
|
|
664
|
+
if not query:
|
|
665
|
+
return df
|
|
666
|
+
if isinstance(query, dict):
|
|
667
|
+
query = _dict_to_query(query)
|
|
668
|
+
if not isinstance(query, str):
|
|
669
|
+
raise TypeError('Query must be a string!')
|
|
670
|
+
return df.query(query, engine='python')
|
|
671
|
+
|
|
672
|
+
def sample(self, selection: slice | int | float,
|
|
673
|
+
comment: str = None, shuffle=False, bundle=True, inplace=False):
|
|
674
|
+
"""
|
|
675
|
+
Sample the db using the slice.
|
|
676
|
+
|
|
677
|
+
:param selection: slice to use (slc:int means [0:scl])
|
|
678
|
+
:param comment: describe this operation for logging
|
|
679
|
+
:param shuffle: if True - random shuffle before sampling
|
|
680
|
+
:param bundle: list of index levels to group and sample from groups.
|
|
681
|
+
``True`` - use `self.bundle`.
|
|
682
|
+
``False`` - sampling rows.
|
|
683
|
+
:param inplace: change the db itself
|
|
684
|
+
:return: changed object or copy
|
|
685
|
+
"""
|
|
686
|
+
bundle = self.bundle if bundle is True else bundle
|
|
687
|
+
bundle_info = "" if not bundle else bundle == self.bundle and "<bundle>" or bundle
|
|
688
|
+
|
|
689
|
+
db = pdt.sample(self._db, selection=selection, shuffle=shuffle, groups=bundle)
|
|
690
|
+
before, after = len(self._db), len(db)
|
|
691
|
+
if before == after:
|
|
692
|
+
return self
|
|
693
|
+
|
|
694
|
+
comment = comment or f'[{selection}]{shuffle and " Shuffle" or ""} {bundle_info}'
|
|
695
|
+
history = self._history + [self._HistoryItem('sample', comment, (selection,), {}, before, after)]
|
|
696
|
+
if inplace:
|
|
697
|
+
self._db = db
|
|
698
|
+
self._history = history
|
|
699
|
+
return self
|
|
700
|
+
return type(self).from_db(db, history=history, like=self)
|
|
701
|
+
|
|
702
|
+
# Consider: rename to query?
|
|
703
|
+
def select(self, query: str = None, **kws) -> pd.DataFrame:
|
|
704
|
+
""" Select from the internal db.
|
|
705
|
+
:param query: str may include an arbitrary python expression supported by
|
|
706
|
+
DataFrame.query() in terms of the categories of the labels:
|
|
707
|
+
``"cat1 == value1 or cat2 * 2 < value"``
|
|
708
|
+
:param kws: query in category=value pairs selects data
|
|
709
|
+
where ``cat1 == value1 and cat2 == value and ...``
|
|
710
|
+
Either kws OR string query may be provided
|
|
711
|
+
:return: resulted selected as a DataFrame
|
|
712
|
+
"""
|
|
713
|
+
return self._select(self.db, query, **kws)
|
|
714
|
+
|
|
715
|
+
# Consider: why both filter and select?
|
|
716
|
+
def filter(self, query: str = None, *, comment: str = None, inplace=False, **kws):
|
|
717
|
+
""" Select from the internal db.
|
|
718
|
+
:param query: str may include an arbitrary python expression supported by
|
|
719
|
+
DataFrame.query() in terms of the categories of the labels:
|
|
720
|
+
``"cat1 == value1 or cat2 * 2 < value"``
|
|
721
|
+
:param comment: description of the meaning of this filter
|
|
722
|
+
:param inplace: if True change own data-base and return self
|
|
723
|
+
:param kws: query in category=value pairs selects data
|
|
724
|
+
where ``cat1 == value1 and cat2 == value and ...``
|
|
725
|
+
Either kws OR string query may be provided
|
|
726
|
+
:return: DataCollection Filtered
|
|
727
|
+
"""
|
|
728
|
+
trg = self if inplace else self.copy()
|
|
729
|
+
if not (query or kws):
|
|
730
|
+
return trg
|
|
731
|
+
|
|
732
|
+
args = ((query,), kws)
|
|
733
|
+
if kws and not query:
|
|
734
|
+
query, kws = _dict_to_query(kws), {}
|
|
735
|
+
|
|
736
|
+
db = trg.select(query=query, **kws)
|
|
737
|
+
before, after = len(trg._db), len(db)
|
|
738
|
+
if before != after:
|
|
739
|
+
trg.changelog('filter', str(comment or query), *args, before, after)
|
|
740
|
+
trg._db = db
|
|
741
|
+
return trg
|
|
742
|
+
|
|
743
|
+
def qix(self, *args, drop_level: bool = False, trans=False,
|
|
744
|
+
axis=None, key_err=True, as_dc=False, **kws) -> CollectTable | DataCollection:
|
|
745
|
+
"""Fuzzy query of data collection index and return either filtered
|
|
746
|
+
version of the data collection (as_dc argument) or
|
|
747
|
+
filtered `db` table, optionally in *transformed* formed (data loaded).
|
|
748
|
+
|
|
749
|
+
Different query types are supported:
|
|
750
|
+
- by
|
|
751
|
+
|
|
752
|
+
:param args: list of values from one of the index levels
|
|
753
|
+
- will try all until first is found or raise KeyError
|
|
754
|
+
:param drop_level: if True - drop found levels from the result
|
|
755
|
+
:param trans: if True apply data transforms when producing results
|
|
756
|
+
:param axis: if specified query only this axis
|
|
757
|
+
:param key_err: if False ignore key errors
|
|
758
|
+
:param as_dc: results are returned as a new DataCollection
|
|
759
|
+
:param kws: {level: value} - eliminates exhaustive search in all levels
|
|
760
|
+
|
|
761
|
+
:return: Transformed selection's data as DataSeries object.
|
|
762
|
+
"""
|
|
763
|
+
import pandas as pd
|
|
764
|
+
# support of usage of direct index instead of smart queries
|
|
765
|
+
if trans and as_dc:
|
|
766
|
+
raise ValueError("Transforms can not be applied if producing data collection")
|
|
767
|
+
|
|
768
|
+
# a single unnamed argument could be an index or list of indices
|
|
769
|
+
if not kws and len(args) == 1 and isinstance(args[0], (list, tuple, pd.Index)):
|
|
770
|
+
if isinstance(idx := args[0], tuple):
|
|
771
|
+
idx = list(idx) if isinstance(idx[0], tuple) else [idx]
|
|
772
|
+
sub = self.db.loc[idx]
|
|
773
|
+
if len(sub) != (len(idx) if isinstance(idx, list) else 1):
|
|
774
|
+
raise IndexError("Invalid index passed to qix")
|
|
775
|
+
else: # query based indexing
|
|
776
|
+
sub = self.db.qix(*args, drop_level=drop_level, axis=axis, key_err=key_err, **kws)
|
|
777
|
+
|
|
778
|
+
if trans:
|
|
779
|
+
import pandas as pd
|
|
780
|
+
sub = tr.apply_column_transform(sub)
|
|
781
|
+
if isinstance(sub, pd.DataFrame):
|
|
782
|
+
sub = sub[Col.data]
|
|
783
|
+
|
|
784
|
+
if not as_dc:
|
|
785
|
+
return sub
|
|
786
|
+
|
|
787
|
+
# ---- prepare new DataCollection parameters ----
|
|
788
|
+
before, after = len(self.db), len(sub)
|
|
789
|
+
if before == after:
|
|
790
|
+
return self
|
|
791
|
+
|
|
792
|
+
if bundle := self.bundle:
|
|
793
|
+
bundle = [*filter(sub.index.names.__contains__, self.bundle)]
|
|
794
|
+
|
|
795
|
+
from iad.core.strings import dict_str
|
|
796
|
+
join = lambda seq: seq and [','.join(map(str, seq))] or []
|
|
797
|
+
desc = join(join(args) + (kws and [dict_str(kws)] or []))[0]
|
|
798
|
+
return type(self).from_db(sub, like=self, bundle=bundle,
|
|
799
|
+
history=('qix', desc, args, kws, before, after),
|
|
800
|
+
name=isinstance(as_dc, str) and as_dc or self.name)
|
|
801
|
+
|
|
802
|
+
def fetch(self, labels: Sequence[dict[str, Any]], *, levels=None, first_found=False):
|
|
803
|
+
return self._db.select(labels, levels=levels, first_found=first_found).fetch()
|
|
804
|
+
|
|
805
|
+
def add(self, other: pd.DataFrame | DataCollection, *,
|
|
806
|
+
other_name='DataFrame', desc=None, inplace=False, **kws):
|
|
807
|
+
"""Add another data collection or bare db DataFrame
|
|
808
|
+
to append to this one or form a new.
|
|
809
|
+
|
|
810
|
+
:param other: a dc or db to add
|
|
811
|
+
:param other_name: used if other is DataFrame
|
|
812
|
+
:param desc: to use in changelog
|
|
813
|
+
:param inplace: if True update this
|
|
814
|
+
:return: new DC of this updated
|
|
815
|
+
"""
|
|
816
|
+
import pandas as pd
|
|
817
|
+
# when other is None or empty
|
|
818
|
+
if other is None or (isinstance(other, DataCollection) and other.empty):
|
|
819
|
+
return self
|
|
820
|
+
assert isinstance(other, (DataCollection, pd.DataFrame))
|
|
821
|
+
is_dc = isinstance(other, DataCollection)
|
|
822
|
+
assert is_dc or isinstance(other, pd.DataFrame)
|
|
823
|
+
# when self is empty but other have new data
|
|
824
|
+
if self.empty:
|
|
825
|
+
return other if is_dc else self.from_db(other, like=self)
|
|
826
|
+
odb, other_name = (other.db, other.name) if is_dc else (other, other_name)
|
|
827
|
+
name = f"{self.name}+{other_name}"
|
|
828
|
+
|
|
829
|
+
db = pd.concat([self.db.reset_index(), odb.reset_index()])
|
|
830
|
+
idx1, idx2 = self.db.index.names, odb.index.names
|
|
831
|
+
db.set_index(idx1.union(idx2.difference(idx1)), inplace=True)
|
|
832
|
+
before = len(self), len(odb)
|
|
833
|
+
|
|
834
|
+
if is_dc:
|
|
835
|
+
def rename_hist(obj):
|
|
836
|
+
rename_item = lambda h: h._replace(oper=f"<{obj.name}>{h.oper}")
|
|
837
|
+
return [*map(rename_item, obj._history)]
|
|
838
|
+
|
|
839
|
+
upd = dict(
|
|
840
|
+
name=name,
|
|
841
|
+
casters=self.casters + other.casters,
|
|
842
|
+
bundle=list(set(self.bundle + other.bundle)),
|
|
843
|
+
history=sum(map(rename_hist, [self, other]), []),
|
|
844
|
+
cacher=self.cacher or other.cacher or None
|
|
845
|
+
)
|
|
846
|
+
kws.update({k: v for k, v in upd.items() if k not in kws})
|
|
847
|
+
desc = desc or kws.get('name', name)
|
|
848
|
+
|
|
849
|
+
if inplace:
|
|
850
|
+
dc = self
|
|
851
|
+
self._db = db
|
|
852
|
+
any(setattr(self, k, v) for k, v in kws.items() if k != 'cacher')
|
|
853
|
+
else:
|
|
854
|
+
dc = type(self).from_db(db, like=self, **kws)
|
|
855
|
+
dc.changelog('Add', desc, before=before, after=len(db))
|
|
856
|
+
return dc
|
|
857
|
+
|
|
858
|
+
def __iadd__(self, other):
|
|
859
|
+
return self.add(other, desc='+=', inplace=True)
|
|
860
|
+
|
|
861
|
+
def __add__(self, other):
|
|
862
|
+
return self.add(other, desc='+')
|
|
863
|
+
|
|
864
|
+
@classmethod
|
|
865
|
+
def mix(cls, col_sel: Iterable[tuple[DataCollection, Union[float, int, tuple]]],
|
|
866
|
+
shuffle=True, bundle: tuple[list[str]] | list[str] | bool = True) -> DataCollection:
|
|
867
|
+
"""
|
|
868
|
+
Returning new DataCollection with a sample of each input dc with its correspondent proportion.
|
|
869
|
+
|
|
870
|
+
:param col_sel: iterable over tuples: (data collections, sampling params), (or `dict` of such)
|
|
871
|
+
:param shuffle: if True - random shuffle before sampling for each dc.
|
|
872
|
+
:param bundle: The bundle for the sampling
|
|
873
|
+
- ``True`` to each use its own `dc.bundle`, or list of bundles per
|
|
874
|
+
or user defined bundle for each dc
|
|
875
|
+
- ``list[str]`` bundle - for a mutual bundle for all
|
|
876
|
+
None - sampling rows
|
|
877
|
+
:return: DataCollection with mix of collections.
|
|
878
|
+
"""
|
|
879
|
+
if isinstance(col_sel, dict):
|
|
880
|
+
col_sel = col_sel.items()
|
|
881
|
+
if not isinstance(bundle, list):
|
|
882
|
+
from itertools import repeat
|
|
883
|
+
bundle = repeat(bundle)
|
|
884
|
+
|
|
885
|
+
return sum(
|
|
886
|
+
(dc.sample(selection=prop, shuffle=shuffle, bundle=bnd)
|
|
887
|
+
for (dc, prop), bnd in zip(col_sel, bundle)),
|
|
888
|
+
cls.from_db(CollectTable())
|
|
889
|
+
)
|
|
890
|
+
|
|
891
|
+
def __getitem__(self, req: int | slice | dict):
|
|
892
|
+
if isinstance(req, int) or isinstance(req, slice):
|
|
893
|
+
return self.db.iloc[req]
|
|
894
|
+
if isinstance(req, dict):
|
|
895
|
+
records = self.select(**req).to_dict(orient='records')
|
|
896
|
+
if not len(records):
|
|
897
|
+
raise KeyError(f'Items with key {req} not found in {self.__class__}')
|
|
898
|
+
return records if len(records) > 1 else records[0]
|
|
899
|
+
raise TypeError(f'Unsupported key type {type(req)}!')
|
|
900
|
+
|
|
901
|
+
def __iter__(self):
|
|
902
|
+
return self.db.iterrows()
|
|
903
|
+
|
|
904
|
+
def apply(self, *trans, inp=Col.data, out=Col.data):
|
|
905
|
+
"""Apply sequence of transformations to the dataset columns
|
|
906
|
+
:param trans: sequence of transformation stage instructions:
|
|
907
|
+
`(inp, fnc, out)` or `(inp, fnc)` or `fnc`
|
|
908
|
+
:param inp: default inp column for the first stage
|
|
909
|
+
:param out: default output column of any stage if out is missing
|
|
910
|
+
"""
|
|
911
|
+
from .transtools import apply_to
|
|
912
|
+
apply_to(self.db, *trans, inp=inp, out=out)
|
|
913
|
+
|
|
914
|
+
def groups_num(self, group: str | list[str] = None, **kws):
|
|
915
|
+
"""Count number of specific groups in the database.
|
|
916
|
+
|
|
917
|
+
:param group: categories to group by
|
|
918
|
+
:param kws: other DataFrame.groupby arguments
|
|
919
|
+
"""
|
|
920
|
+
if not (group := group or self.bundle):
|
|
921
|
+
raise ValueError("Neither group nor bundle is defined")
|
|
922
|
+
return self.db.groupby(as_list(group), **kws).ngroups
|
|
923
|
+
|
|
924
|
+
class Iter:
|
|
925
|
+
def __init__(self, iterator, tuples: bool, size=NotImplemented):
|
|
926
|
+
self.iter = iterator
|
|
927
|
+
self.tuples = tuples
|
|
928
|
+
self.size = size
|
|
929
|
+
|
|
930
|
+
def __iter__(self):
|
|
931
|
+
return self.iter.__iter__()
|
|
932
|
+
|
|
933
|
+
def __next__(self):
|
|
934
|
+
return self.iter.__next__()
|
|
935
|
+
|
|
936
|
+
def __length_hint__(self):
|
|
937
|
+
return self.size
|
|
938
|
+
|
|
939
|
+
def __mod__(self, filter_func: Callable):
|
|
940
|
+
return self.__class__(
|
|
941
|
+
(it for it in self.iter if filter_func(*(
|
|
942
|
+
it if self.tuples else (it,)
|
|
943
|
+
))),
|
|
944
|
+
tuples=self.tuples)
|
|
945
|
+
|
|
946
|
+
def __truediv__(self, apply_func: Callable):
|
|
947
|
+
return self.__class__(
|
|
948
|
+
(it.__class__(it.gid, apply_func(it.grp)) for it in self.iter)
|
|
949
|
+
if self.tuples else
|
|
950
|
+
(it.__class__(apply_func(it)) for it in self.iter),
|
|
951
|
+
tuples=self.tuples,
|
|
952
|
+
size=self.size)
|
|
953
|
+
|
|
954
|
+
def iter(self, group: str | Sequence[str] = None, *,
|
|
955
|
+
index: str | Sequence[str] = None,
|
|
956
|
+
data: str | Sequence[str] = None, gid=True,
|
|
957
|
+
trans: bool | str | Tuple[str, str] = False,
|
|
958
|
+
islice=None, shuffle=False,
|
|
959
|
+
out: str = None, progress: bool | dict = None) -> Iter:
|
|
960
|
+
"""
|
|
961
|
+
Wraps `algutils.pdtools.group_iter` to wrok on the internal
|
|
962
|
+
DataFrame (self.db), also can transform and pivot the groups.
|
|
963
|
+
|
|
964
|
+
Docs from `algutils.pdtools.group_iter`:
|
|
965
|
+
-----------------------------------------
|
|
966
|
+
Create iterator over groups of paths with flexible organization,
|
|
967
|
+
producing either tuples (if `gid` is True): (group_index, group_data)
|
|
968
|
+
or just `group_data` items (DataFrame or CollectionTable)
|
|
969
|
+
|
|
970
|
+
- `group_index` - tuple of (or single) values of categories used for grouping
|
|
971
|
+
- `group_data` - grouped data elements in form of DataFrame
|
|
972
|
+
|
|
973
|
+
Specific structure of the group is controlled by arguments:
|
|
974
|
+
|
|
975
|
+
* by which categories the items are grouped as iteration entities:
|
|
976
|
+
* the content and structure of the `group_data` collection (DataFrame)
|
|
977
|
+
|
|
978
|
+
Multi-level indexing is supported for one axis of the frame, by default for the rows.
|
|
979
|
+
That allows to combine hierarchical access to the labels values and iteration.
|
|
980
|
+
|
|
981
|
+
Example:
|
|
982
|
+
|
|
983
|
+
>>> ds.categories == ['scene', 'alg', 'kind', 'side', 'time', 'path', 'transforms']
|
|
984
|
+
>>> (scene, alg), group = next(
|
|
985
|
+
... ds.iter(['scene', 'alg'], index=['kind', 'side'], data='path'))
|
|
986
|
+
... # Notice drop of all the categories not explicitly requested as index or data,
|
|
987
|
+
... # like 'ext', 'transforms', but 'time' remains to ensure unique indexing
|
|
988
|
+
... scene, alg == 'Piano', 'NU4'
|
|
989
|
+
... group.index == ['kind', 'side', 'time']
|
|
990
|
+
... group.columns == ['path']
|
|
991
|
+
|
|
992
|
+
:param shuffle:
|
|
993
|
+
:param group: one or list of categories to group by.
|
|
994
|
+
:param index: list of categories to be used as multi-index levels
|
|
995
|
+
of the resulting groups (also defines their order)
|
|
996
|
+
if 'db_index' use the index of the db
|
|
997
|
+
if ``None`` - keep original index, or
|
|
998
|
+
remove from it ''group'' levels if ``gid`` is True
|
|
999
|
+
:param data: keep those categories in the data (as colums)
|
|
1000
|
+
if `None`: produce `DataFrame` with all data categories as columns
|
|
1001
|
+
:param gid: if True return namedtuple `GID(gid, grp)` otherwise only `grp`
|
|
1002
|
+
:param out: form of the output (None|"frame"|"series"|"pivot")
|
|
1003
|
+
if "frame": return DataFrame with columns as data categories
|
|
1004
|
+
if "series": return `Series` - only if single data column is defined!
|
|
1005
|
+
if "pivot": return DatFrame in pivoted form - to have data-columns as rows,
|
|
1006
|
+
and the `index` argument as columns
|
|
1007
|
+
- column-based multi-level indexing allows hierarchical access:
|
|
1008
|
+
`data.level1.level2 == value`
|
|
1009
|
+
- row-based may be more convinient to `data.itertuples()`:
|
|
1010
|
+
`for (level1, level2), values in data.itertuples():`
|
|
1011
|
+
if None (default): operates as "frame" or "series" if single data column
|
|
1012
|
+
:param trans: a tuple (inp, out), or just out, equivalent to (out, out) -
|
|
1013
|
+
columns names to apply transforms and alignments (in-place):
|
|
1014
|
+
`inp -> apply_transforms -> out -> align_transformed -> out`
|
|
1015
|
+
If more flexibility is required, disable automatic transfomations
|
|
1016
|
+
trans = False or None, and apply explicitely calling
|
|
1017
|
+
`from ds.transform: apply_transforms, align_transformed`
|
|
1018
|
+
:param progress: Show progress bar if `True` or `dict` with args for `tqdm`,
|
|
1019
|
+
`None` - decide automatically
|
|
1020
|
+
:param shuffle: shuffle order of groups
|
|
1021
|
+
:param islice: arguments for ``itertool.islice`` (start, [stop, [step]])
|
|
1022
|
+
:return: iterator over tuples: ((grouped by categories), group)
|
|
1023
|
+
or, if gid is False - over group CollectionTable ( or DataSeries)
|
|
1024
|
+
"""
|
|
1025
|
+
import pandas as pd
|
|
1026
|
+
if not (group := group or self.bundle):
|
|
1027
|
+
raise ValueError("Neither group nor bundle is defined")
|
|
1028
|
+
|
|
1029
|
+
if trans: # various options to define dict(inp=..., out=...)
|
|
1030
|
+
if trans is True:
|
|
1031
|
+
trans = [Col.path, Col.data]
|
|
1032
|
+
elif isinstance(trans, str):
|
|
1033
|
+
trans = [trans] * 2
|
|
1034
|
+
if not isinstance(trans, dict):
|
|
1035
|
+
trans = dict(zip(('inp', 'out'), trans))
|
|
1036
|
+
assert len(trans) == 2 and {'inp', 'out'}.issubset(trans)
|
|
1037
|
+
|
|
1038
|
+
data = (trans['out'] if trans else list(self.db.columns)
|
|
1039
|
+
) if data is None else as_list(data)
|
|
1040
|
+
group = as_list(group)
|
|
1041
|
+
if not index:
|
|
1042
|
+
index = self.categories.difference([*self.db.columns, *(group if gid else [])])
|
|
1043
|
+
elif index == 'db_index': # ToDo: rename to 'keep'
|
|
1044
|
+
index = self.db.index.names
|
|
1045
|
+
|
|
1046
|
+
def prep_group(item: Tuple[Tuple, pd.DataFrame]) -> Tuple[Tuple, pd.DataFrame]:
|
|
1047
|
+
idx, grp = item
|
|
1048
|
+
if trans:
|
|
1049
|
+
grp = tr.apply_column_transform(grp, **trans)
|
|
1050
|
+
grp = grp.sort_index()
|
|
1051
|
+
if isinstance(grp, pd.DataFrame):
|
|
1052
|
+
grp = grp[data]
|
|
1053
|
+
if out == "pivot":
|
|
1054
|
+
grp = grp.T.squeeze()
|
|
1055
|
+
|
|
1056
|
+
return self.Group(group, idx, grp) if gid else grp
|
|
1057
|
+
|
|
1058
|
+
return DataCollection.Iter(map(prep_group, pdt.group_iter(
|
|
1059
|
+
self.db, group=group, index=index, shuffle=shuffle, islice=islice,
|
|
1060
|
+
out=out, progress=progress
|
|
1061
|
+
)), tuples=gid)
|
|
1062
|
+
|
|
1063
|
+
@property
|
|
1064
|
+
def empty(self):
|
|
1065
|
+
return self.db.empty
|
|
1066
|
+
|
|
1067
|
+
def hash_str(self, fmt='{name}_{shape}_{hash}', *, columns=None, index=True, n=6, **kws):
|
|
1068
|
+
"""Calculate string uniquely representing this data collection.
|
|
1069
|
+
|
|
1070
|
+
Content of the string can be controlled by including tags: ``{name}, {shape}, {hash}``
|
|
1071
|
+
representing correspondingly: collection name, its db shape, and db content hash.
|
|
1072
|
+
|
|
1073
|
+
Length of the hash is controlled by argument ``n``, when 0 means all of it.
|
|
1074
|
+
|
|
1075
|
+
:param fmt: format string with tags to substitute
|
|
1076
|
+
:param n: length of hash str from internal db
|
|
1077
|
+
:param columns: list of columns to include in hash calculation
|
|
1078
|
+
:param index: use or not index for hash calculation
|
|
1079
|
+
:param kws: additional tags with values to use in format (external)
|
|
1080
|
+
"""
|
|
1081
|
+
df = self.db[columns] if columns else self.db
|
|
1082
|
+
hash_val = '{hash}' in fmt and df.hash_str(n, index=index) or None
|
|
1083
|
+
return fmt.format(name=self.name, hash=hash_val,
|
|
1084
|
+
shape='x'.join(map(str, self.db.shape)), **kws)
|
|
1085
|
+
|
|
1086
|
+
@staticmethod
|
|
1087
|
+
def collect(labels_iter: Iterable[dict], progress=True) -> pd.DataFrame:
|
|
1088
|
+
""" Gathers files categories into a database supporting :met:`__len__`
|
|
1089
|
+
and queries by categories' values.
|
|
1090
|
+
|
|
1091
|
+
May be overridden to support different database or caching.
|
|
1092
|
+
Then method :met:`query` must be overridden too.
|
|
1093
|
+
|
|
1094
|
+
:param labels_iter: iterable over dicts of labels
|
|
1095
|
+
:param progress: control the display of progress
|
|
1096
|
+
:returns: database object
|
|
1097
|
+
"""
|
|
1098
|
+
import pandas as pd
|
|
1099
|
+
if progress:
|
|
1100
|
+
from tqdm import tqdm
|
|
1101
|
+
from os.path import split
|
|
1102
|
+
loc = split(getattr(labels_iter, 'root', 'file structure'))[-1]
|
|
1103
|
+
labels_iter = tqdm(labels_iter, f"Parsing {loc}", unit=' files')
|
|
1104
|
+
|
|
1105
|
+
return pd.DataFrame(list(labels_iter))
|
|
1106
|
+
|
|
1107
|
+
@staticmethod
|
|
1108
|
+
def query_description(query: str | dict) -> str:
|
|
1109
|
+
"""Try to identify selection description from the query"""
|
|
1110
|
+
if not query:
|
|
1111
|
+
return ''
|
|
1112
|
+
return _dict_to_query(query) if isinstance(query, dict) else query
|
|
1113
|
+
|
|
1114
|
+
|
|
1115
|
+
class SinkRepo:
|
|
1116
|
+
"""
|
|
1117
|
+
Repository supporting addition of new items, but not reading.
|
|
1118
|
+
Data is saved according to the given path scheme and with
|
|
1119
|
+
specific formats of files depending on the labels.
|
|
1120
|
+
"""
|
|
1121
|
+
|
|
1122
|
+
SAVE_KWS_TAG = 'imsave_args'
|
|
1123
|
+
_labels_tag = 'DATA_LABELS'
|
|
1124
|
+
|
|
1125
|
+
def __init__(self, *, root: str | Path, search: 'GuideScan',
|
|
1126
|
+
labels: dict = None, mappings: dict = None,
|
|
1127
|
+
scheme_name: str = None, scheme_description: str = None,
|
|
1128
|
+
data='data', select: str | dict = None,
|
|
1129
|
+
extra_labels: dict = None, create_dir=True):
|
|
1130
|
+
"""
|
|
1131
|
+
Construct write only repository from resolved scheme parameters.
|
|
1132
|
+
|
|
1133
|
+
For name-based construction from ``DatasetRM`` or ``SchemeRM``, use
|
|
1134
|
+
the factory in ``dataman`` (e.g. ``dataman.create_collection``).
|
|
1135
|
+
|
|
1136
|
+
:param root: folder to save under
|
|
1137
|
+
:param search: GuideScan defining the path pattern
|
|
1138
|
+
:param labels: scheme label definitions
|
|
1139
|
+
:param mappings: scheme label mappings
|
|
1140
|
+
:param scheme_name: name for display purposes
|
|
1141
|
+
:param scheme_description: description for display purposes
|
|
1142
|
+
:param data: key of the label with data (image) (default: 'data')
|
|
1143
|
+
:param select: conditions on labels as dict or eval string
|
|
1144
|
+
:param extra_labels: labels to add to every saved item
|
|
1145
|
+
:param create_dir: if False and don't exist raise ``NotADirectoryError``
|
|
1146
|
+
"""
|
|
1147
|
+
from .caster import Labeler
|
|
1148
|
+
from .scan import GuideScan
|
|
1149
|
+
self.labeler = Labeler(labels=labels or {}, mappings=mappings or {},
|
|
1150
|
+
search=search, reverse=True)
|
|
1151
|
+
self.scheme_name = scheme_name
|
|
1152
|
+
self.scheme_description = scheme_description
|
|
1153
|
+
self.root = Path(root)
|
|
1154
|
+
if not self.root.is_dir():
|
|
1155
|
+
msg = f'SinkRepo folder does not exist: {str(self.root)}'
|
|
1156
|
+
if not create_dir:
|
|
1157
|
+
raise NotADirectoryError(msg)
|
|
1158
|
+
_log.warning(msg)
|
|
1159
|
+
self._data_key = data
|
|
1160
|
+
self._filter = _dict_to_query(select)
|
|
1161
|
+
self.labels = extra_labels or {}
|
|
1162
|
+
|
|
1163
|
+
def __str__(self):
|
|
1164
|
+
return f"<{type(self).__name__}>({self.root}){self.labeler._pather.form.str}"
|
|
1165
|
+
|
|
1166
|
+
def __repr__(self):
|
|
1167
|
+
from iad.core.strings import indent_lines as ind
|
|
1168
|
+
seq = lambda _: ', '.join(_)
|
|
1169
|
+
return f"<{type(self).__name__}>({self.root}):\n" + \
|
|
1170
|
+
ind(self.labeler._pather.form.str,
|
|
1171
|
+
ind(f"- categories: {seq(self.labeler.categories)}",
|
|
1172
|
+
f"- anonymous: {seq(self.labeler.anonymous)}"),
|
|
1173
|
+
f"scheme: {self.scheme_name or ''}",
|
|
1174
|
+
self.scheme_description or '')
|
|
1175
|
+
|
|
1176
|
+
def _prepare_path(self, labels: dict, no_tag=None, exist_ok=None) -> Path:
|
|
1177
|
+
path = self.path(no_tag=no_tag, **labels)
|
|
1178
|
+
if path.exists():
|
|
1179
|
+
if exist_ok is False:
|
|
1180
|
+
raise FileExistsError(path)
|
|
1181
|
+
if exist_ok is None:
|
|
1182
|
+
_log.warning(f'Overwriting {path}')
|
|
1183
|
+
else:
|
|
1184
|
+
path.parent.mkdir(mode=0o777, parents=True, exist_ok=True)
|
|
1185
|
+
return path
|
|
1186
|
+
|
|
1187
|
+
def path(self, no_tag: dict | bool | None = None, **labels):
|
|
1188
|
+
"""
|
|
1189
|
+
Construct path according to the repo scheme based on the given labels.
|
|
1190
|
+
|
|
1191
|
+
Raise ``KeyError`` if required categories are missing in labels.
|
|
1192
|
+
Except of 'date' which is then generated automatically.
|
|
1193
|
+
|
|
1194
|
+
`no_tag` controls content of possible unnamed TAG in the pattern.
|
|
1195
|
+
- if a `dict` - used if unnamed TAG is defined or fails.
|
|
1196
|
+
- if `True` - uses for that all the unknown labels (or similarly fails)
|
|
1197
|
+
- if `False` or `None` - ignores unknown tags in the `labels`
|
|
1198
|
+
|
|
1199
|
+
:param no_tag: labels for unnamed tag or permission to take unknown from the `labels`
|
|
1200
|
+
:param labels: {cat: val}
|
|
1201
|
+
:return: path to the file
|
|
1202
|
+
"""
|
|
1203
|
+
labels |= self.labels
|
|
1204
|
+
|
|
1205
|
+
if 'date' in self.labeler.categories and 'date' not in labels:
|
|
1206
|
+
from datetime import datetime
|
|
1207
|
+
labels['date'] = datetime.now().strftime('%y%m%d-%H%M%S')
|
|
1208
|
+
|
|
1209
|
+
# remove label
|
|
1210
|
+
if 'data' in labels:
|
|
1211
|
+
labels.pop('data')
|
|
1212
|
+
|
|
1213
|
+
return self.labeler.path(self.root, no_tag=no_tag, **labels)
|
|
1214
|
+
|
|
1215
|
+
def save_tables(self, labels: dict = None, exist_ok=True, **tables: pd.DataFrame | pd.Series):
|
|
1216
|
+
"""
|
|
1217
|
+
Save pandas tables: DataFrames or Series.
|
|
1218
|
+
Format (hdf or xls) is defined by extension in scheme's path
|
|
1219
|
+
|
|
1220
|
+
Tables are found by type among the labels and each is stored in
|
|
1221
|
+
separate sheet / node in the data, named by its key.
|
|
1222
|
+
Other labels are also saved as auto-created 'labels' Series
|
|
1223
|
+
|
|
1224
|
+
All the labels are updating those provided in constructor.
|
|
1225
|
+
|
|
1226
|
+
:param labels: dict with labels defining the data
|
|
1227
|
+
:param exist_ok: if False and file exists raises `FileExistsError`
|
|
1228
|
+
if True - ignores existed
|
|
1229
|
+
if None: warning
|
|
1230
|
+
:param kws: additional labels
|
|
1231
|
+
:return:
|
|
1232
|
+
"""
|
|
1233
|
+
import pandas as pd
|
|
1234
|
+
labels = self.labels | (labels or {}) # combine labels into single dict
|
|
1235
|
+
path = self._prepare_path(labels, exist_ok=exist_ok)
|
|
1236
|
+
|
|
1237
|
+
is_sub = lambda x, cls: type(x) is not cls and issubclass(type(x), cls)
|
|
1238
|
+
to_base = lambda x: (pd.DataFrame(x) if is_sub(x, pd.DataFrame) else
|
|
1239
|
+
pd.Series(x) if is_sub(df, pd.Series) else x)
|
|
1240
|
+
|
|
1241
|
+
with Timer(f"Table saved in {{:.3}}s to {path}", _log.info):
|
|
1242
|
+
ext = path.suffix.lower()
|
|
1243
|
+
if ext in ('.hdf', '.hdf5'):
|
|
1244
|
+
if any('.' in name for name in tables):
|
|
1245
|
+
import warnings
|
|
1246
|
+
from tables import NaturalNameWarning
|
|
1247
|
+
warnings.filterwarnings('ignore', category=NaturalNameWarning)
|
|
1248
|
+
|
|
1249
|
+
with pd.HDFStore(path, mode='a') as store:
|
|
1250
|
+
if labels:
|
|
1251
|
+
store.put(self._labels_tag, pd.Series(labels))
|
|
1252
|
+
|
|
1253
|
+
for key, df in tables.items():
|
|
1254
|
+
store.put(key, to_base(df)) # pytabels failing on pandas subclasses
|
|
1255
|
+
|
|
1256
|
+
elif ext in '.xls': # TODO: add index description meta-data
|
|
1257
|
+
with pd.ExcelWriter(path) as store:
|
|
1258
|
+
if labels:
|
|
1259
|
+
pd.Series(labels).to_excel(self._labels_tag, sheet_name=self._labels_tag)
|
|
1260
|
+
|
|
1261
|
+
for key, df in tables.items():
|
|
1262
|
+
df.to_excel(store, sheet_name=key, merge_cells=True)
|
|
1263
|
+
else:
|
|
1264
|
+
raise NotImplementedError(f'Not supported saving DataFrame in {path.suffix} format')
|
|
1265
|
+
|
|
1266
|
+
self.read(labels=labels)
|
|
1267
|
+
|
|
1268
|
+
@staticmethod
|
|
1269
|
+
def _default_compression(a: np.ndarray, path: str):
|
|
1270
|
+
"""Deduce default compression kwargs from path (ext) and data (dtype)"""
|
|
1271
|
+
if path.rsplit('.', 1)[-1] in ('tif', 'tiff'):
|
|
1272
|
+
if a.dtype.kind == 'f':
|
|
1273
|
+
return {"compression": 'zstd', 'predictor': 3}
|
|
1274
|
+
else:
|
|
1275
|
+
return {"compression": "jpeg2000"}
|
|
1276
|
+
return {}
|
|
1277
|
+
|
|
1278
|
+
def save(self, items: pd.DataFrame | Iterable[dict] | pd.Series | dict, *,
|
|
1279
|
+
data: str | np.ndarray = None, compress: bool | dict | None = False,
|
|
1280
|
+
labels: dict = None, exist_ok=None, no_tag: dict | bool = None) -> int:
|
|
1281
|
+
"""
|
|
1282
|
+
Saves data item(s) to the repository according to its labels structure.
|
|
1283
|
+
|
|
1284
|
+
Every data item contains labels for identification and establishing
|
|
1285
|
+
its naming, location and formatting in the repository.
|
|
1286
|
+
|
|
1287
|
+
A single item may be passed in form of dict-like object with
|
|
1288
|
+
optional data field (ndarray) and named 'data' or as indicated by
|
|
1289
|
+
`data` argument if provided.
|
|
1290
|
+
Alternatively data (ndarray) may be passed in the `data` argument.
|
|
1291
|
+
|
|
1292
|
+
Multiple items passed as:
|
|
1293
|
+
- DataFrame - row per item, considering columns and named levels
|
|
1294
|
+
in multi-level index as labels, or
|
|
1295
|
+
- Iterable of dict-like objects
|
|
1296
|
+
In this case `data` argument may be used to specify data field name
|
|
1297
|
+
|
|
1298
|
+
`no_tag` controls content of possible unnamed TAG in the pattern.
|
|
1299
|
+
- if a `dict` - used if unnamed TAG is defined or fails.
|
|
1300
|
+
- if `True` - uses for that all the unknown labels (or similarly fails)
|
|
1301
|
+
- if `False` or `None` - ignores unknown tags in the `labels`
|
|
1302
|
+
|
|
1303
|
+
**Compression**
|
|
1304
|
+
|
|
1305
|
+
Optional (loseless) of data can be controlled by setting `compress=True`
|
|
1306
|
+
or providing dict with arguments for `skimage.io.imsave` or `tifffile.imwrite`,
|
|
1307
|
+
depending on the file extension, `False` or `None` diables altogether.
|
|
1308
|
+
|
|
1309
|
+
If `True`, compression parameters are first looked for in provided
|
|
1310
|
+
`labels[SinkRepo.SAVE_KWS_TAG]`, if not found, then
|
|
1311
|
+
`self._default_compression(data, path)` is called to determine the optimal.
|
|
1312
|
+
|
|
1313
|
+
Examples:
|
|
1314
|
+
---------
|
|
1315
|
+
>>> sink.save(df, data='image') # data in 'image' column
|
|
1316
|
+
>>> sink.save(df.iloc[10]) # Series from a single row
|
|
1317
|
+
>>> sink.save([dict(a=10, b=20, data=rand(10,20)),
|
|
1318
|
+
... dict(a=10, b=22, data=rand(30,20))])
|
|
1319
|
+
>>> sink.save(dict(a=10, b=20), rand(10,20))
|
|
1320
|
+
|
|
1321
|
+
:param items: one or multiple set of data labels.
|
|
1322
|
+
- dict, Series
|
|
1323
|
+
- DataFrame, container or iterator overt dicts
|
|
1324
|
+
:param data: alternative name for data key in `items`, or
|
|
1325
|
+
data array itself, then items contains only labels
|
|
1326
|
+
:param compress: dict with compression arguments for
|
|
1327
|
+
OR `True` for auto, `Fasle` - disable
|
|
1328
|
+
:param labels: optionally common labels for all the items.
|
|
1329
|
+
(could be overidden by the items labels)
|
|
1330
|
+
:param exist_ok: if False and file exists raises `FileExistsError`
|
|
1331
|
+
if True - ignores existed
|
|
1332
|
+
if None: warning
|
|
1333
|
+
:param no_tag: labels for unnamed tag or permission to take unknown from the `labels`
|
|
1334
|
+
:return: number of data items saved
|
|
1335
|
+
"""
|
|
1336
|
+
from iad.io.imwrite import imsave
|
|
1337
|
+
from iad.io.imread import imread
|
|
1338
|
+
import numpy as np
|
|
1339
|
+
import pandas as pd
|
|
1340
|
+
import os.path
|
|
1341
|
+
from tqdm import tqdm
|
|
1342
|
+
|
|
1343
|
+
is_str = lambda _: isinstance(_, str)
|
|
1344
|
+
count = 0
|
|
1345
|
+
|
|
1346
|
+
compress = compress or {}
|
|
1347
|
+
if not (compress is True or isinstance(compress, dict)):
|
|
1348
|
+
raise TypeError(f"Invalid {compress=}")
|
|
1349
|
+
|
|
1350
|
+
def save_item(lbs: dict, a: np.ndarray):
|
|
1351
|
+
nonlocal count
|
|
1352
|
+
path = self._prepare_path(labels | lbs, no_tag=no_tag, exist_ok=exist_ok)
|
|
1353
|
+
path = str(path)
|
|
1354
|
+
if os.path.isfile(path):
|
|
1355
|
+
if exist_ok:
|
|
1356
|
+
# skipping writing existing files
|
|
1357
|
+
return count
|
|
1358
|
+
if not isinstance(a, np.ndarray):
|
|
1359
|
+
if os.path.isfile(a):
|
|
1360
|
+
a = imread(a)
|
|
1361
|
+
else:
|
|
1362
|
+
raise TypeError(f"Received {type(a)} instead of expected numpy array! or path to file!")
|
|
1363
|
+
|
|
1364
|
+
if (kws := compress) is True: # if auto compression is requested
|
|
1365
|
+
if (kws := lbs.get(self.SAVE_KWS_TAG, None)) is None: # and not defined in labels
|
|
1366
|
+
kws = self._default_compression(a, path) # try to determine default
|
|
1367
|
+
|
|
1368
|
+
imsave(path, a, **(kws or {}))
|
|
1369
|
+
_log.debug(f"Saved {kws or ''} in {self}: {path}")
|
|
1370
|
+
count += 1
|
|
1371
|
+
return count
|
|
1372
|
+
|
|
1373
|
+
# -----------------------------------
|
|
1374
|
+
labels = labels or {}
|
|
1375
|
+
if isinstance(data, np.ndarray):
|
|
1376
|
+
return save_item(items, data)
|
|
1377
|
+
|
|
1378
|
+
data = data or self._data_key
|
|
1379
|
+
|
|
1380
|
+
if isinstance(items, dict):
|
|
1381
|
+
return save_item(items, items[data])
|
|
1382
|
+
if isinstance(items, pd.Series) and all(map(is_str, items)):
|
|
1383
|
+
items = items.to_dict()
|
|
1384
|
+
return save_item(items, items[data])
|
|
1385
|
+
|
|
1386
|
+
if isinstance(items, (pd.Series, pd.DataFrame)):
|
|
1387
|
+
named_levels = list(filter(bool, items.index.names))
|
|
1388
|
+
# leave only required items
|
|
1389
|
+
for _, row in DataCollection._select(
|
|
1390
|
+
items.reset_index(named_levels), query=self._filter
|
|
1391
|
+
).iterrows():
|
|
1392
|
+
row = row.to_dict()
|
|
1393
|
+
save_item(row, row[data])
|
|
1394
|
+
elif isinstance(items, Iterable):
|
|
1395
|
+
for item in tqdm(items):
|
|
1396
|
+
if isinstance(item, dict):
|
|
1397
|
+
save_item(item, item[data])
|
|
1398
|
+
else:
|
|
1399
|
+
if count:
|
|
1400
|
+
raise KeyError("Attempt to save multiple data items under same labels")
|
|
1401
|
+
save_item({}, item)
|
|
1402
|
+
else:
|
|
1403
|
+
raise TypeError
|
|
1404
|
+
|
|
1405
|
+
return count
|
|
1406
|
+
|
|
1407
|
+
def read(self, keys: str | Sequence[str] = None, *,
|
|
1408
|
+
labels: dict = None, path: str = None, out_labels=False):
|
|
1409
|
+
"""Read data from repository given labels
|
|
1410
|
+
(preferable) or path (hack)
|
|
1411
|
+
|
|
1412
|
+
:param keys: name(s) of the fields to read
|
|
1413
|
+
:param labels: identifying item in the repo
|
|
1414
|
+
:param path: instead of labels path of the file
|
|
1415
|
+
:param out_labels: if True return additionally this item's labels dict
|
|
1416
|
+
:return DataFrame if ``key`` is a str, otherwise namedtuple
|
|
1417
|
+
"""
|
|
1418
|
+
import pandas as pd
|
|
1419
|
+
assert path is None or labels is None
|
|
1420
|
+
path = path or self.path(**labels)
|
|
1421
|
+
if path.suffix.lower() in {'.hdf', '.hdf5'}:
|
|
1422
|
+
|
|
1423
|
+
outputs = lambda d: (
|
|
1424
|
+
d, hdf.get(self._labels_tag)
|
|
1425
|
+
if self._labels_tag in hdf.keys() else None
|
|
1426
|
+
) if out_labels else d
|
|
1427
|
+
|
|
1428
|
+
with pd.HDFStore(path, mode='r') as hdf:
|
|
1429
|
+
if isinstance(keys, str): # return specific key
|
|
1430
|
+
return outputs(hdf.get(keys))
|
|
1431
|
+
|
|
1432
|
+
res = {k: hdf.get('/' + k) for k in
|
|
1433
|
+
(keys or (k[1:] for k in hdf.keys()))}
|
|
1434
|
+
res.pop(self._labels_tag, None)
|
|
1435
|
+
|
|
1436
|
+
return outputs(res)
|
|
1437
|
+
|
|
1438
|
+
raise NotImplementedError
|
|
1439
|
+
|
|
1440
|
+
def __call__(self, *args: dict, exist_ok=None, **kwargs):
|
|
1441
|
+
"""Function form interface for saving data.
|
|
1442
|
+
Calls to `save` or `save_table` method depending on the sink type
|
|
1443
|
+
|
|
1444
|
+
:param *args: dict-like data items
|
|
1445
|
+
:param exist_ok: if True - ignores existed
|
|
1446
|
+
if False - raise error
|
|
1447
|
+
if None: warning
|
|
1448
|
+
"""
|
|
1449
|
+
if self.labeler._pather.ext.lower() in {'xls', 'hdf', 'hdf5'}:
|
|
1450
|
+
return self.save_tables(*args, exist_ok=exist_ok, **kwargs)
|
|
1451
|
+
else:
|
|
1452
|
+
return self.save(*args, exist_ok=exist_ok, **kwargs)
|
|
1453
|
+
|
|
1454
|
+
|
|
1455
|
+
def _dict_to_query(dq: str | dict):
|
|
1456
|
+
"""Convert dict to query string with and between items"""
|
|
1457
|
+
if not dq or isinstance(dq, str):
|
|
1458
|
+
return dq
|
|
1459
|
+
if not isinstance(dq, dict):
|
|
1460
|
+
raise TypeError("Invalid query type")
|
|
1461
|
+
|
|
1462
|
+
def to_str(it):
|
|
1463
|
+
k, v = it
|
|
1464
|
+
return f"{k}=='{v}'" if isinstance(v, str) else f"{v}"
|
|
1465
|
+
|
|
1466
|
+
return ' and '.join(map(to_str, dq.items()))
|