aspect-data 0.0.1__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.
aspect/data.py ADDED
@@ -0,0 +1,577 @@
1
+ """Data pipeline class."""
2
+
3
+ from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Mapping, Tuple, Optional, Union
4
+
5
+ from functools import cached_property, partial
6
+ import os
7
+
8
+ from carabiner import cast, print_err
9
+
10
+ if TYPE_CHECKING:
11
+ from datasets import Dataset, DatasetDict, IterableDataset
12
+ else:
13
+ Dataset, DatasetDict, IterableDataset = Any, Any, Any
14
+
15
+ import numpy as np
16
+ from numpy.typing import ArrayLike
17
+
18
+ from . import app_name, __version__
19
+ from .checkpoint_utils import load_checkpoint_file, save_json
20
+ from .io import AutoDataset
21
+ from .package_data import CACHE_DIR
22
+ from .transform.base import ColumnTransform
23
+ from .typing import DataLike, StrOrIterableOfStr
24
+
25
+ DEFAULT_BATCH_SIZE: int = 1024
26
+ DEFAULT_FORMAT: str = "numpy"
27
+
28
+ def _check_column_presence(
29
+ features: StrOrIterableOfStr,
30
+ data: Dataset
31
+ ) -> Iterable[str]:
32
+ columns = cast(features, to=list)
33
+ data_cols = data.column_names
34
+ absent_columns = [col for col in columns if col not in data_cols]
35
+ if absent_columns:
36
+ raise ValueError(
37
+ f"""
38
+ Requested columns ({', '.join(columns)}) not present in
39
+ {type(data)}: {', '.join(absent_columns)}.
40
+ """
41
+ )
42
+ return columns
43
+
44
+
45
+ def _check_is_calculated(
46
+ x: Dataset,
47
+ column_transform: ColumnTransform
48
+ ) -> Tuple[str, bool]:
49
+ """Check named column is in dataset.
50
+
51
+ Examples
52
+ ========
53
+ _check_column_presence passes when columns present:
54
+
55
+ >>> from unittest.mock import MagicMock
56
+ >>> ds = MagicMock()
57
+ >>> ds.column_names = ["smiles", "mic"]
58
+ >>> _check_column_presence(["smiles", "mic"], ds)
59
+ ['smiles', 'mic']
60
+
61
+ _check_column_presence raises on absent columns:
62
+
63
+ >>> _check_column_presence(["smiles", "missing"], ds)
64
+ Traceback (most recent call last):
65
+ ...
66
+ ValueError: ...missing...
67
+
68
+ """
69
+ out_column = column_transform.output_column
70
+ return out_column, out_column in x.column_names
71
+
72
+
73
+ def _fill_na(
74
+ x: Mapping[str, Any],
75
+ types: Mapping[str, Any]
76
+ ) -> Dict[str, Any]:
77
+ """Fill missing values with typed missing.
78
+
79
+ For example, numeric filled with zeros, and strings filled with `""`.
80
+ Examples
81
+ ========
82
+ _fill_na fills by dtype:
83
+
84
+ >>> _fill_na(
85
+ ... {"a": [1, None, 3], "b": ["x", None, "z"], "c": [1.0, None, 3.0]},
86
+ ... {"a": "int64", "b": "string", "c": "float32"}
87
+ ... )
88
+ {'a': [1, 0, 3], 'b': ['x', '', 'z'], 'c': [1.0, 0.0, 3.0]}
89
+
90
+ _fill_na unknown dtype fills None:
91
+
92
+ >>> _fill_na({"a": [1, None]}, {"a": "bool"})
93
+ {'a': [1, None]}
94
+
95
+ """
96
+ for key in x:
97
+ this_type = types[key]
98
+ if this_type.startswith(("int", "uint")):
99
+ fill_value = 0
100
+ elif this_type.startswith("float"):
101
+ fill_value = 0.
102
+ elif this_type in ("string", "large_string"):
103
+ fill_value = ""
104
+ else:
105
+ fill_value = None
106
+
107
+ x[key] = [fill_value if v is None else v for v in x[key]]
108
+ return x
109
+
110
+
111
+ class DataPipeline:
112
+ """Data processing pipeline.
113
+
114
+ Examples
115
+ ========
116
+ Construction from dict spec:
117
+
118
+ >>> p = DataPipeline({"log_affinity": ("affinity", "log")})
119
+ >>> "log_affinity" in p.column_transforms
120
+ True
121
+ >>> len(p.column_transforms["log_affinity"])
122
+ 1
123
+ >>> p.column_transforms["log_affinity"][0].input_column
124
+ 'affinity'
125
+
126
+ Construction from list of 2-tuples:
127
+
128
+ >>> p2 = DataPipeline([("affinity", "log"), ("assay", ["hash"])])
129
+ >>> sorted(p2.column_transforms.keys())
130
+ ['col_00', 'col_01']
131
+
132
+ Chained transforms propagate input → output columns:
133
+
134
+ >>> p3 = DataPipeline({"feat": ("assay", ["hash", "identity"])})
135
+ >>> chain = p3.column_transforms["feat"]
136
+ >>> chain[1].input_column == chain[0].output_column
137
+ True
138
+
139
+ serialize_transforms produces JSON-compatible dicts:
140
+
141
+ >>> serialized = p.serialize_transforms(p.column_transforms)
142
+ >>> isinstance(serialized["log_affinity"], tuple)
143
+ True
144
+ >>> isinstance(serialized["log_affinity"][0], dict)
145
+ True
146
+ >>> serialized["log_affinity"][0]["name"]
147
+ 'log'
148
+
149
+ Invalid column spec raises:
150
+
151
+ >>> DataPipeline({"bad": ("affinity", "log", "extra")})
152
+ Traceback (most recent call last):
153
+ ...
154
+ ValueError: Column transforms must be 2-tuples...
155
+
156
+ Non-string first element raises:
157
+
158
+ >>> DataPipeline({"bad": (123, "log")})
159
+ Traceback (most recent call last):
160
+ ...
161
+ ValueError: First item must be input_column name, or dict with input_column key.
162
+
163
+ __call__ produces output columns:
164
+
165
+ >>> import numpy as np
166
+ >>> p = DataPipeline({"log_affinity": ("affinity", "log")})
167
+ >>> data = {"affinity": [1.0, 10.0, 100.0]}
168
+ >>> out = p(data)
169
+ >>> "log_affinity" in out.column_names
170
+ True
171
+ >>> np.allclose(out["log_affinity"], np.log([1., 10., 100.])[:,None])
172
+ True
173
+
174
+ drop_unused_columns removes input columns:
175
+
176
+ >>> out2 = p(data, drop_unused_columns=True)
177
+ >>> "affinity" not in out2.column_names
178
+ True
179
+ >>> "log_affinity" in out2.column_names
180
+ True
181
+
182
+ keep_extra_columns preserved when dropping:
183
+
184
+ >>> data2 = {"affinity": [1.0, 10.0], "label": [0, 1]}
185
+ >>> out3 = p(data2, drop_unused_columns=True, keep_extra_columns=["label"])
186
+ >>> "label" in out3.column_names
187
+ True
188
+
189
+ Missing input column raises:
190
+
191
+ >>> p({"wrong_col": [1.0, 2.0]})
192
+ Traceback (most recent call last):
193
+ ...
194
+ ValueError: ...affinity...
195
+
196
+ """
197
+ def __init__(
198
+ self,
199
+ column_transforms: Optional[Iterable[Union[str, ColumnTransform]]] = None,
200
+ columns_to_keep: Optional[Iterable[Union[str, ColumnTransform]]] = None,
201
+ output_format: str = DEFAULT_FORMAT,
202
+ output_format_opts: Optional[Mapping[str, Any]] = None,
203
+ cache_dir: Optional[str] = None,
204
+ _version: str = __version__,
205
+ _app: str = app_name
206
+ ):
207
+ self._column_transforms = column_transforms or []
208
+ columns_to_keep = columns_to_keep or []
209
+ if isinstance(columns_to_keep, str):
210
+ columns_to_keep = [columns_to_keep]
211
+ self.columns_to_keep = columns_to_keep
212
+ self._version = _version
213
+ self._app = _app
214
+ self.column_transforms = self.canonicalize_transforms(self._column_transforms)
215
+ self._column_transforms_serialized = self.serialize_transforms(self.column_transforms)
216
+ self.output_format = output_format
217
+ self.output_format_opts = output_format_opts or {}
218
+ self.data_in = None
219
+ self.data_out = None
220
+ self.data_out_example = None
221
+ self.data_out_shape = None
222
+ self.cache_dir = cache_dir or CACHE_DIR
223
+ self._data_in_filename = "data-in.hf"
224
+ self._data_out_filename = "data-out.hf"
225
+ self._data_out_example_filename = "data-out-example.hf"
226
+ self._data_loaded = False
227
+ self._config_filename = "config.json"
228
+
229
+ self._inspect_data_out()
230
+
231
+ def __eq__(self, other):
232
+ if hasattr(other, "column_transforms_serialized"):
233
+ return all([
234
+ self.column_transforms_serialized == other.column_transforms_serialized,
235
+ self.columns_to_keep == other.columns_to_keep,
236
+ ])
237
+ else:
238
+ raise ValueError(f"Cannot compare {type(self)} with {type(other)}.")
239
+
240
+ @cached_property
241
+ def column_transforms_serialized(self):
242
+ self.serialize_transforms(self.column_transforms)
243
+
244
+ def _canonicalize_transforms(
245
+ self,
246
+ column_transforms: Iterable[Union[str, Mapping, ColumnTransform]],
247
+ input_column: Optional[str] = None
248
+ ) -> Tuple[ColumnTransform]:
249
+ if isinstance(column_transforms, (str, dict, ColumnTransform)):
250
+ column_transforms = [column_transforms]
251
+ out = []
252
+ prev_transform = None
253
+ for i, candidate in enumerate(column_transforms):
254
+ if prev_transform is not None:
255
+ input_column = prev_transform.output_column
256
+ elif input_column is None:
257
+ if isinstance(candidate, ColumnTransform):
258
+ input_column = candidate.input_column
259
+ elif isinstance(candidate, dict):
260
+ input_column = candidate["input_column"]
261
+ else:
262
+ raise ValueError(
263
+ "Supply an input_column or a first ColumnTransform object."
264
+ )
265
+ elif not isinstance(input_column, str):
266
+ raise ValueError(
267
+ f"Supplied an input_column must be a str, but was {type(input_column)}: {input_column}."
268
+ )
269
+ if isinstance(candidate, ColumnTransform):
270
+ kwargs = candidate.to_dict()
271
+ elif isinstance(candidate, dict):
272
+ kwargs = candidate
273
+ elif isinstance(candidate, str):
274
+ kwargs = {"name": candidate}
275
+ else:
276
+ raise ValueError(
277
+ "Transform must be a ColumnTransform, dict, or str. "
278
+ f"It was {type(candidate)}: {candidate}"
279
+ )
280
+ transform = ColumnTransform(**({
281
+ "_version": self._version,
282
+ "_app": self._app,
283
+ } | kwargs | {
284
+ "input_column": input_column,
285
+ }))
286
+ out.append(transform)
287
+ prev_transform = transform
288
+ return tuple(out)
289
+
290
+ def canonicalize_transforms(
291
+ self,
292
+ column_transforms: Union[Mapping, Iterable],
293
+ input_column: Optional[str] = None
294
+ ) -> Dict[str, ColumnTransform]:
295
+ if isinstance(column_transforms, (list, tuple)):
296
+ if len(column_transforms) == 0:
297
+ return {}
298
+ elif isinstance(column_transforms[0], str):
299
+ if isinstance(column_transforms[1], (list, tuple, dict, str, ColumnTransform)):
300
+ column_transforms = [[column_transforms]]
301
+ else:
302
+ raise ValueError(
303
+ "Column transforms should be a list or dict of (input, [transforms...])"
304
+ )
305
+ if not isinstance(column_transforms, dict):
306
+ column_transforms = {
307
+ f"col_{i:02d}": v
308
+ for i, v in enumerate(column_transforms)
309
+ }
310
+ for key in column_transforms:
311
+ first_item = column_transforms[key][0]
312
+ if isinstance(first_item, dict) and "input_column" in first_item:
313
+ column_transforms[key] = (first_item["input_column"], tuple(column_transforms[key]))
314
+ elif isinstance(first_item, str):
315
+ pass
316
+ else:
317
+ raise ValueError(f"First item must be input_column name, or dict with input_column key.")
318
+ wrong_lengths = {k: len(v) for k, v in column_transforms.items() if len(v) != 2}
319
+ if wrong_lengths:
320
+ raise ValueError(
321
+ "Column transforms must be 2-tuples. "
322
+ f"These were not: {wrong_lengths}; {column_transforms}"
323
+ )
324
+ no_names = {k: v for k, v in column_transforms.items() if not isinstance(v[0], str)}
325
+ if no_names:
326
+ raise ValueError(
327
+ "First item of column transform tuple must be string column name. "
328
+ f"These were not: {no_names}"
329
+ )
330
+ out = {}
331
+ for name, (input_column, subpipeline) in column_transforms.items():
332
+ out[name] = self._canonicalize_transforms(
333
+ subpipeline,
334
+ input_column=input_column,
335
+ )
336
+ return out
337
+
338
+ def serialize_transforms(
339
+ self,
340
+ column_transforms: Mapping[str, ColumnTransform]
341
+ ) -> Dict[str, Tuple[dict]]:
342
+ return {k: tuple(t.to_dict() for t in v) for k, v in column_transforms.items()}
343
+
344
+ def _inspect_data_out(self) -> None:
345
+ if self.data_out is not None:
346
+ self.data_out = self.data_out.with_format(
347
+ self.output_format,
348
+ **self.output_format_opts,
349
+ )
350
+ self.data_out_example = (
351
+ self.data_out
352
+ .take(1)
353
+ )
354
+ first_item = self.data_out_example.with_format("numpy")[:1]
355
+ self.data_out_shape = {
356
+ col: first_item[col].shape[1:]
357
+ if not isinstance(first_item[col], dict)
358
+ else {
359
+ k: v.shape[1:] if v is not None else None
360
+ for k, v in first_item[col].items()
361
+ }
362
+ for col in self.data_out_example.column_names
363
+ }
364
+ return None
365
+
366
+ def _resolve_data(
367
+ self,
368
+ data: DataLike,
369
+ cache_dir: Optional[str] = None
370
+ ) -> Union[Dataset, IterableDataset]:
371
+ return AutoDataset.load(data, cache=cache_dir or self.cache_dir)._dataset
372
+
373
+ @staticmethod
374
+ def _featurize(
375
+ x: Mapping[str, ArrayLike],
376
+ column_transforms: Mapping[str, dict]
377
+ ) -> Dict[str, np.ndarray]:
378
+
379
+ column_transforms = {
380
+ k: [ColumnTransform(**d) for d in v]
381
+ for k, v in column_transforms.items()
382
+ }
383
+ for name, transforms in column_transforms.items():
384
+ if name in x:
385
+ raise ValueError(
386
+ f"Output column name {name} already in data: {','.join(x)}. "
387
+ "Change transform name to avoid overwriting."
388
+ )
389
+ for name, transforms in column_transforms.items():
390
+ prev_transform = None
391
+ for i, transform in enumerate(transforms):
392
+ if i == 0:
393
+ input_column = transform.input_column
394
+ else:
395
+ input_column = prev_transform.output_column
396
+ x = transform(x)
397
+ prev_transform = transform
398
+ x[name] = x[transform.output_column]
399
+ return x
400
+
401
+ @staticmethod
402
+ def _unsqueeze(
403
+ x: Mapping[str, ArrayLike]
404
+ ) -> Dict[str, np.ndarray]:
405
+ for key in x:
406
+ vals = np.asarray(x[key])
407
+ if vals.ndim == 1:
408
+ x[key] = vals[:, None]
409
+ return x
410
+
411
+ def __call__(
412
+ self,
413
+ dataset: DataLike,
414
+ batch_size: int = DEFAULT_BATCH_SIZE,
415
+ drop_unused_columns: bool = False,
416
+ keep_extra_columns: Optional[Iterable[str]] = None
417
+ ):
418
+ data_in = self._resolve_data(dataset)
419
+ input_columns = sorted(set(
420
+ seq[0].input_column for k, seq in self.column_transforms.items()
421
+ )) # get only the input column for each branch
422
+ output_columns = sorted(set(self.column_transforms))
423
+
424
+ if len(input_columns) == 0:
425
+ raise AttributeError("No input columns specified.")
426
+
427
+ _check_column_presence(
428
+ input_columns,
429
+ data_in,
430
+ )
431
+ if drop_unused_columns:
432
+ if keep_extra_columns is None:
433
+ extra_cols = []
434
+ else:
435
+ extra_cols = list(keep_extra_columns)
436
+ extra_cols = list(set(extra_cols + self.columns_to_keep).intersection(data_in.column_names))
437
+ all_input_columns = list(set(input_columns + extra_cols))
438
+ data_in = (
439
+ data_in
440
+ .select_columns(all_input_columns)
441
+ )
442
+ all_output_columns = output_columns + extra_cols
443
+ else:
444
+ all_input_columns = list(data_in.column_names)
445
+ all_output_columns = all_input_columns + output_columns
446
+
447
+ data_out = (
448
+ data_in
449
+ .map(
450
+ _fill_na,
451
+ fn_kwargs={
452
+ "types": {
453
+ key: f.dtype
454
+ for key, f in data_in.info.features.items()
455
+ },
456
+ },
457
+ batched=True,
458
+ batch_size=batch_size,
459
+ desc="Filling NaN values",
460
+ )
461
+ .map(
462
+ self._featurize,
463
+ fn_kwargs={
464
+ "column_transforms": self._column_transforms_serialized,
465
+ },
466
+ batched=True,
467
+ batch_size=batch_size,
468
+ desc="Featurizing",
469
+ )
470
+ )
471
+ data_out = (
472
+ data_out
473
+ .with_format(None) # guard against tensors
474
+ .select_columns(all_output_columns)
475
+ .map(
476
+ self._unsqueeze,
477
+ batched=True,
478
+ batch_size=batch_size,
479
+ desc="Unsqueezing",
480
+ )
481
+ )
482
+ self.data_in = data_in
483
+ self.data_out = data_out
484
+ self._data_loaded = True
485
+ self._inspect_data_out()
486
+ return self.data_out
487
+
488
+ def save_checkpoint(
489
+ self,
490
+ checkpoint_dir: str,
491
+ skip_data_in: bool = False,
492
+ skip_data_out: bool = False
493
+ ):
494
+ keys = {
495
+ "kwargs": (
496
+ ("_column_transforms_serialized", "column_transforms"),
497
+ "columns_to_keep",
498
+ "output_format",
499
+ "output_format_opts",
500
+ ),
501
+ "state": (
502
+ "_column_transforms",
503
+ "data_out_shape",
504
+ "_data_in_filename",
505
+ "_data_out_filename",
506
+ "_data_out_example_filename",
507
+ "_data_loaded",
508
+ "_config_filename",
509
+ "_version",
510
+ "_app",
511
+ ),
512
+ }
513
+ data_config = {
514
+ section: {
515
+ (key if isinstance(key, str) else key[1]): getattr(
516
+ self,
517
+ (key if isinstance(key, str) else key[0]),
518
+ )
519
+ for key in section_keys
520
+ }
521
+ for section, section_keys in keys.items()
522
+ }
523
+ os.makedirs(checkpoint_dir, exist_ok=True)
524
+ if self.data_in is not None and not skip_data_in:
525
+ self.data_in.save_to_disk(
526
+ os.path.join(checkpoint_dir, self._data_in_filename),
527
+ )
528
+ if self.data_out is not None and not skip_data_out:
529
+ (
530
+ self.data_out
531
+ .save_to_disk(os.path.join(checkpoint_dir, self._data_out_filename)),
532
+ )
533
+ if self.data_out_example is not None:
534
+ (
535
+ self.data_out_example
536
+ .save_to_disk(os.path.join(checkpoint_dir, self._data_out_example_filename)),
537
+ )
538
+ save_json(data_config, os.path.join(checkpoint_dir, self._config_filename))
539
+ return None
540
+
541
+ def load_checkpoint(
542
+ self,
543
+ checkpoint: str,
544
+ skip_data_in: bool = False,
545
+ skip_data_out: bool = False,
546
+ cache_dir: Optional[str] = None
547
+ ):
548
+ cache_dir = cache_dir or self.cache_dir
549
+ data_config = load_checkpoint_file(
550
+ checkpoint,
551
+ filename=self._config_filename,
552
+ callback="json",
553
+ none_on_error=False,
554
+ cache_dir=cache_dir,
555
+ )
556
+ self.__init__(**data_config["kwargs"], cache_dir=cache_dir)
557
+ for key, val in data_config["state"].items():
558
+ setattr(self, key, val)
559
+ if self._data_loaded:
560
+ if not skip_data_in and self._data_loaded:
561
+ self.data_in = load_checkpoint_file(
562
+ checkpoint,
563
+ filename=self._data_in_filename,
564
+ callback="hf-dataset",
565
+ none_on_error=True,
566
+ cache_dir=cache_dir,
567
+ )
568
+ if not skip_data_out:
569
+ self.data_out = load_checkpoint_file(
570
+ checkpoint,
571
+ filename=self._data_out_filename,
572
+ callback="hf-dataset",
573
+ none_on_error=True,
574
+ cache_dir=cache_dir,
575
+ )
576
+ self._inspect_data_out()
577
+ return self