nested-pandas 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.
@@ -0,0 +1,8 @@
1
+ from .nestedframe import NestedFrame
2
+ from .nestedframe.io import read_parquet
3
+
4
+ # Import for registering
5
+ from .series.accessor import NestSeriesAccessor # noqa: F401
6
+ from .series.dtype import NestedDtype
7
+
8
+ __all__ = ["NestedDtype", "NestedFrame", "read_parquet"]
@@ -0,0 +1,16 @@
1
+ # file generated by setuptools_scm
2
+ # don't change, don't track in version control
3
+ TYPE_CHECKING = False
4
+ if TYPE_CHECKING:
5
+ from typing import Tuple, Union
6
+ VERSION_TUPLE = Tuple[Union[int, str], ...]
7
+ else:
8
+ VERSION_TUPLE = object
9
+
10
+ version: str
11
+ __version__: str
12
+ __version_tuple__: VERSION_TUPLE
13
+ version_tuple: VERSION_TUPLE
14
+
15
+ __version__ = version = '0.1'
16
+ __version_tuple__ = version_tuple = (0, 1)
@@ -0,0 +1 @@
1
+ from .generation import * # noqa
@@ -0,0 +1,84 @@
1
+ import numpy as np
2
+
3
+ from nested_pandas import NestedFrame
4
+
5
+
6
+ def generate_data(n_base, n_layer, seed=None) -> NestedFrame:
7
+ """Generates a toy dataset.
8
+
9
+ Parameters
10
+ ----------
11
+ n_base : int
12
+ The number of rows to generate for the base layer
13
+ n_layer : int, or dict
14
+ The number of rows per n_base row to generate for a nested layer.
15
+ Alternatively, a dictionary of layer label, layer_size pairs may be
16
+ specified to created multiple nested columns with custom sizing.
17
+ seed : int
18
+ A seed to use for random generation of data
19
+
20
+ Returns
21
+ -------
22
+ NestedFrame
23
+ The constructed NestedFrame.
24
+
25
+ Examples
26
+ --------
27
+ >>> nested_pandas.datasets.generate_data(10,100)
28
+ >>> nested_pandas.datasets.generate_data(10, {"nested_a": 100, "nested_b": 200})
29
+ """
30
+ # use provided seed, "None" acts as if no seed is provided
31
+ randomstate = np.random.RandomState(seed=seed)
32
+
33
+ # Generate base data
34
+ base_data = {"a": randomstate.random(n_base), "b": randomstate.random(n_base) * 2}
35
+ base_nf = NestedFrame(data=base_data)
36
+
37
+ # In case of int, create a single nested layer called "nested"
38
+ if isinstance(n_layer, int):
39
+ n_layer = {"nested": n_layer}
40
+
41
+ # It should be a dictionary
42
+ if isinstance(n_layer, dict):
43
+ for key in n_layer:
44
+ layer_size = n_layer[key]
45
+ layer_data = {
46
+ "t": randomstate.random(layer_size * n_base) * 20,
47
+ "flux": randomstate.random(layer_size * n_base) * 100,
48
+ "band": randomstate.choice(["r", "g"], size=layer_size * n_base),
49
+ "index": np.arange(layer_size * n_base) % n_base,
50
+ }
51
+ layer_nf = NestedFrame(data=layer_data).set_index("index")
52
+ base_nf = base_nf.add_nested(layer_nf, key)
53
+ return base_nf
54
+ else:
55
+ raise TypeError("Input to n_layer is not an int or dict.")
56
+
57
+
58
+ def generate_parquet_file(n_base, n_layer, path, file_per_layer=False, seed=None):
59
+ """Generates a toy dataset and outputs it to one or more parquet files.
60
+
61
+ Parameters
62
+ ----------
63
+ n_base : int
64
+ The number of rows to generate for the base layer
65
+ n_layer : int, or dict
66
+ The number of rows per n_base row to generate for a nested layer.
67
+ Alternatively, a dictionary of layer label, layer_size pairs may be
68
+ specified to created multiple nested columns with custom sizing.
69
+ path : str,
70
+ The path to the parquet file to write to if `file_per_layer` is `False`,
71
+ and otherwise the path to the directory to write the parquet file for
72
+ each layer.
73
+ file_per_layer : bool, default=False
74
+ If True, write each layer to its own parquet file. Otherwise, write
75
+ the generated to a single parquet file representing a nested dataset.
76
+ seed : int, default=None
77
+ A seed to use for random generation of data
78
+
79
+ Returns
80
+ -------
81
+ None
82
+ """
83
+ nf = generate_data(n_base, n_layer, seed)
84
+ nf.to_parquet(path, by_layer=file_per_layer)
@@ -0,0 +1,14 @@
1
+ """An example module containing simplistic methods under benchmarking."""
2
+
3
+ import random
4
+ import time
5
+
6
+
7
+ def runtime_computation():
8
+ """Runtime computation consuming between 0 and 5 seconds."""
9
+ time.sleep(random.uniform(0, 5))
10
+
11
+
12
+ def memory_computation():
13
+ """Memory computation for a random list up to 512 samples."""
14
+ return [0] * random.randint(0, 512)
@@ -0,0 +1,2 @@
1
+ from .core import NestedFrame # noqa
2
+ from .io import read_parquet # noqa
@@ -0,0 +1,489 @@
1
+ # typing.Self and "|" union syntax don't exist in Python 3.9
2
+ from __future__ import annotations
3
+
4
+ import os
5
+
6
+ import numpy as np
7
+ import pandas as pd
8
+ import pyarrow as pa
9
+ from pandas._libs import lib
10
+ from pandas._typing import Any, AnyAll, Axis, IndexLabel
11
+ from pandas.api.extensions import no_default
12
+
13
+ from nested_pandas.series import packer
14
+ from nested_pandas.series.dtype import NestedDtype
15
+
16
+ from .utils import _ensure_spacing
17
+
18
+
19
+ class NestedFrame(pd.DataFrame):
20
+ """A Pandas Dataframe extension with support for nested structure.
21
+
22
+ See https://pandas.pydata.org/docs/development/extending.html#subclassing-pandas-data-structures
23
+ """
24
+
25
+ # normal properties
26
+ _metadata = ["added_property"]
27
+
28
+ @property
29
+ def _constructor(self) -> Self: # type: ignore[name-defined] # noqa: F821
30
+ return NestedFrame
31
+
32
+ @property
33
+ def _constructor_expanddim(self) -> Self: # type: ignore[name-defined] # noqa: F821
34
+ return NestedFrame
35
+
36
+ @property
37
+ def all_columns(self) -> dict:
38
+ """returns a dictionary of columns for each base/nested dataframe"""
39
+ all_columns = {"base": self.columns}
40
+ for column in self.columns:
41
+ if isinstance(self[column].dtype, NestedDtype):
42
+ nest_cols = self[column].nest.fields
43
+ all_columns[column] = nest_cols
44
+ return all_columns
45
+
46
+ @property
47
+ def nested_columns(self) -> list:
48
+ """retrieves the base column names for all nested dataframes"""
49
+ nest_cols = []
50
+ for column in self.columns:
51
+ if isinstance(self[column].dtype, NestedDtype):
52
+ nest_cols.append(column)
53
+ return nest_cols
54
+
55
+ def _is_known_hierarchical_column(self, colname) -> bool:
56
+ """Determine whether a string is a known hierarchical column name"""
57
+ if "." in colname:
58
+ left, right = colname.split(".")
59
+ if left in self.nested_columns:
60
+ return right in self.all_columns[left]
61
+ return False
62
+ return False
63
+
64
+ def _is_known_column(self, colname) -> bool:
65
+ """Determine whether a string is a known column name"""
66
+ return colname in self.columns or self._is_known_hierarchical_column(colname)
67
+
68
+ def add_nested(
69
+ self, obj, name: str, *, dtype: NestedDtype | pd.ArrowDtype | pa.DataType | None = None
70
+ ) -> Self: # type: ignore[name-defined] # noqa: F821
71
+ """Packs input object to a nested column and adds it to the NestedFrame
72
+
73
+ This method returns a new NestedFrame with the added nested column.
74
+
75
+ Parameters
76
+ ----------
77
+ obj : pd.DataFrame or a sequence of items convertible to nested structures
78
+ The object to be packed into nested pd.Series and added to
79
+ the NestedFrame. If a DataFrame is passed, it must have non-unique
80
+ index values, which are used to pack the DataFrame. If a sequence
81
+ of elements is passed, it is packed into a nested pd.Series.
82
+ Sequence elements may be individual pd.DataFrames, dictionaries
83
+ (keys are nested column names, values are arrays of the same
84
+ length), or any other object convertible to pa.StructArray.
85
+ Additionally, None and pd.NA are allowed as elements to represent
86
+ missing values.
87
+ name : str
88
+ The name of the nested column to be added to the NestedFrame.
89
+ dtype : dtype or None
90
+ NestedDtype to use for the nested column; pd.ArrowDtype or
91
+ pa.DataType can also be used to specify the nested dtype. If None,
92
+ the dtype is inferred from the input object.
93
+
94
+ Returns
95
+ -------
96
+ NestedFrame
97
+ A new NestedFrame with the added nested column.
98
+ """
99
+ # Add sources to objects
100
+ packed = packer.pack(obj, name=name, dtype=dtype)
101
+ label = packed.name
102
+ return self.assign(**{f"{label}": packed})
103
+
104
+ def _split_query(self, expr) -> dict:
105
+ """Splits a pandas query into multiple subqueries for nested and base layers"""
106
+ # Ensure query has needed spacing for upcoming split
107
+ expr = _ensure_spacing(expr)
108
+ nest_exprs = {col: [] for col in self.nested_columns + ["base"]} # type: dict
109
+ split_expr = expr.split(" ")
110
+
111
+ i = 0
112
+ current_focus = "base"
113
+ while i < len(split_expr):
114
+ expr_slice = split_expr[i].strip("()")
115
+ # Check if it's a nested column
116
+ if self._is_known_hierarchical_column(expr_slice):
117
+ nested, colname = split_expr[i].split(".")
118
+ current_focus = nested.strip("()")
119
+ # account for parentheses
120
+ j = 0
121
+ while j < len(nested):
122
+ if nested[j] == "(":
123
+ nest_exprs[current_focus].append("(")
124
+ j += 1
125
+ nest_exprs[current_focus].append(colname)
126
+ # or if it's a top-level column
127
+ elif expr_slice in self.columns:
128
+ current_focus = "base"
129
+ nest_exprs[current_focus].append(split_expr[i])
130
+ else:
131
+ nest_exprs[current_focus].append(split_expr[i])
132
+ i += 1
133
+ return {expr: " ".join(nest_exprs[expr]) for expr in nest_exprs if len(nest_exprs[expr]) > 0}
134
+
135
+ def query(self, expr) -> Self: # type: ignore[name-defined] # noqa: F821
136
+ """
137
+ Query the columns of a NestedFrame with a boolean expression. Specified
138
+ queries can target nested columns in addition to the typical column set
139
+
140
+ Parameters
141
+ ----------
142
+ expr : str
143
+ The query string to evaluate.
144
+
145
+ Access nested columns using `nested_df.nested_col` (where
146
+ `nested_df` refers to a particular nested dataframe and
147
+ `nested_col` is a column of that nested dataframe).
148
+
149
+ You can refer to variables
150
+ in the environment by prefixing them with an '@' character like
151
+ ``@a + b``.
152
+
153
+ You can refer to column names that are not valid Python variable names
154
+ by surrounding them in backticks. Thus, column names containing spaces
155
+ or punctuations (besides underscores) or starting with digits must be
156
+ surrounded by backticks. (For example, a column named "Area (cm^2)" would
157
+ be referenced as ```Area (cm^2)```). Column names which are Python keywords
158
+ (like "list", "for", "import", etc) cannot be used.
159
+
160
+ For example, if one of your columns is called ``a a`` and you want
161
+ to sum it with ``b``, your query should be ```a a` + b``.
162
+
163
+ Returns
164
+ -------
165
+ DataFrame
166
+ DataFrame resulting from the provided query expression.
167
+
168
+ Notes
169
+ -----
170
+ Queries that target a particular nested structure return a dataframe
171
+ with rows of that particular nested structure filtered. For example,
172
+ querying the NestedFrame "df" with nested structure "my_nested" as
173
+ below will return all rows of df, but with mynested filtered by the
174
+ condition:
175
+
176
+ >>> df.query("mynested.a > 2")
177
+ """
178
+
179
+ # Rebuild queries for each specified nested/base layer
180
+ exprs_to_use = self._split_query(expr)
181
+
182
+ # For now (simplicity), limit query to only operating on one layer
183
+ if len(exprs_to_use.keys()) != 1:
184
+ raise ValueError("Queries cannot target multiple structs/layers, write a separate query for each")
185
+
186
+ # Send queries to layers
187
+ # We'll only execute 1 per the Error above, but the loop will be useful
188
+ # for when/if we allow multi-layer queries
189
+ result = self.copy()
190
+ for expr in exprs_to_use:
191
+ if expr == "base":
192
+ result = super().query(exprs_to_use["base"], inplace=False)
193
+ else:
194
+ # TODO: does not work with queries that empty the dataframe
195
+ result[expr] = result[expr].nest.query_flat(exprs_to_use[expr])
196
+ return result
197
+
198
+ def _resolve_dropna_target(self, on_nested, subset):
199
+ """resolves the target layer for a given set of dropna kwargs"""
200
+
201
+ nested_cols = self.nested_columns
202
+ columns = self.columns
203
+
204
+ # first check the subset kwarg input
205
+ subset_target = []
206
+ if subset:
207
+ if isinstance(subset, str):
208
+ subset = [subset]
209
+
210
+ for col in subset:
211
+ col = col.split(".")[0]
212
+ if col in nested_cols:
213
+ subset_target.append(col)
214
+ elif col in columns:
215
+ subset_target.append("base")
216
+ else:
217
+ raise ValueError(f"Column name {col} not found in any base or nested columns")
218
+
219
+ # Check for 1 target
220
+ subset_target = np.unique(subset_target)
221
+ if len(subset_target) > 1: # prohibit multi-target operations
222
+ raise ValueError(
223
+ f"Targeted multiple nested structures ({subset_target}), write one command per target dataframe" # noqa
224
+ )
225
+ subset_target = str(subset_target[0])
226
+
227
+ # Next check the on_nested kwarg input
228
+ if on_nested and on_nested not in nested_cols:
229
+ raise ValueError("Provided nested layer not found in nested dataframes")
230
+
231
+ # Resolve target layer
232
+ target = "base"
233
+ if on_nested and subset_target:
234
+ if on_nested != subset_target:
235
+ raise ValueError(
236
+ f"Provided on_nested={on_nested}, but subset columns are from {subset_target}. Make sure these are aligned or just use subset." # noqa
237
+ )
238
+ else:
239
+ target = subset_target
240
+ elif on_nested:
241
+ target = str(on_nested)
242
+ elif subset_target:
243
+ target = str(subset_target)
244
+ return target, subset
245
+
246
+ def dropna(
247
+ self,
248
+ *,
249
+ axis: Axis = 0,
250
+ how: AnyAll | lib.NoDefault = no_default,
251
+ thresh: int | lib.NoDefault = no_default,
252
+ on_nested: bool = False,
253
+ subset: IndexLabel | None = None,
254
+ inplace: bool = False,
255
+ ignore_index: bool = False,
256
+ ) -> NestedFrame | None:
257
+ """
258
+ Remove missing values for one layer of the NestedFrame.
259
+
260
+ Parameters
261
+ ----------
262
+ axis : {0 or 'index', 1 or 'columns'}, default 0
263
+ Determine if rows or columns which contain missing values are
264
+ removed.
265
+
266
+ * 0, or 'index' : Drop rows which contain missing values.
267
+ * 1, or 'columns' : Drop columns which contain missing value.
268
+
269
+ Only a single axis is allowed.
270
+
271
+ how : {'any', 'all'}, default 'any'
272
+ Determine if row or column is removed from DataFrame, when we have
273
+ at least one NA or all NA.
274
+
275
+ * 'any' : If any NA values are present, drop that row or column.
276
+ * 'all' : If all values are NA, drop that row or column.
277
+ thresh : int, optional
278
+ Require that many non-NA values. Cannot be combined with how.
279
+ on_nested : str or bool, optional
280
+ If not False, applies the call to the nested dataframe in the
281
+ column with label equal to the provided string. If specified,
282
+ the nested dataframe should align with any columns given in
283
+ `subset`.
284
+ subset : column label or sequence of labels, optional
285
+ Labels along other axis to consider, e.g. if you are dropping rows
286
+ these would be a list of columns to include.
287
+
288
+ Access nested columns using `nested_df.nested_col` (where
289
+ `nested_df` refers to a particular nested dataframe and
290
+ `nested_col` is a column of that nested dataframe).
291
+ inplace : bool, default False
292
+ Whether to modify the DataFrame rather than creating a new one.
293
+ ignore_index : bool, default ``False``
294
+ If ``True``, the resulting axis will be labeled 0, 1, …, n - 1.
295
+
296
+ .. versionadded:: 2.0.0
297
+
298
+ Returns
299
+ -------
300
+ DataFrame or None
301
+ DataFrame with NA entries dropped from it or None if ``inplace=True``.
302
+
303
+ Notes
304
+ -----
305
+ Operations that target a particular nested structure return a dataframe
306
+ with rows of that particular nested structure affected.
307
+
308
+ Values for `on_nested` and `subset` should be consistent in pointing
309
+ to a single layer, multi-layer operations are not supported at this
310
+ time.
311
+ """
312
+
313
+ # determine target dataframe
314
+ target, subset = self._resolve_dropna_target(on_nested, subset)
315
+
316
+ if target == "base":
317
+ return super().dropna(
318
+ axis=axis, how=how, thresh=thresh, subset=subset, inplace=inplace, ignore_index=ignore_index
319
+ )
320
+ if subset is not None:
321
+ subset = [col.split(".")[-1] for col in subset]
322
+ if inplace:
323
+ target_flat = self[target].nest.to_flat()
324
+ target_flat.dropna(
325
+ axis=axis,
326
+ how=how,
327
+ thresh=thresh,
328
+ subset=subset,
329
+ inplace=inplace,
330
+ ignore_index=ignore_index,
331
+ )
332
+ self[target] = packer.pack_flat(target_flat)
333
+ return self
334
+ # Or if not inplace
335
+ new_df = self.copy()
336
+ new_df[target] = packer.pack_flat(
337
+ new_df[target]
338
+ .nest.to_flat()
339
+ .dropna(
340
+ axis=axis,
341
+ how=how,
342
+ thresh=thresh,
343
+ subset=subset,
344
+ inplace=inplace,
345
+ ignore_index=ignore_index,
346
+ )
347
+ )
348
+ return new_df
349
+
350
+ def reduce(self, func, *args, **kwargs) -> NestedFrame:
351
+ """
352
+ Takes a function and applies it to each top-level row of the NestedFrame.
353
+
354
+ The user may specify which columns the function is applied to, with
355
+ columns from the 'base' layer being passsed to the function as
356
+ scalars and columns from the nested layers being passed as numpy arrays.
357
+
358
+ Parameters
359
+ ----------
360
+ func : callable
361
+ Function to apply to each nested dataframe. The first arguments to `func` should be which
362
+ columns to apply the function to.
363
+ args : positional arguments
364
+ Positional arguments to pass to the function, the first *args should be the names of the
365
+ columns to apply the function to.
366
+ kwargs : keyword arguments, optional
367
+ Keyword arguments to pass to the function.
368
+
369
+ Returns
370
+ -------
371
+ `NestedFrame`
372
+ `NestedFrame` with the results of the function applied to the columns of the frame.
373
+
374
+ Notes
375
+ -----
376
+ The recommend return value of func should be a `pd.Series` where the indices are the names of the
377
+ output columns in the dataframe returned by `reduce`. Note however that in cases where func
378
+ returns a single value there may be a performance benefit to returning the scalar value
379
+ rather than a `pd.Series`.
380
+
381
+ Example User Function:
382
+ ```
383
+ import pandas as pd
384
+
385
+ def my_sum(col1, col2):
386
+ return pd.Series(
387
+ [sum(col1), sum(col2)],
388
+ index=["sum_col1", "sum_col2"],
389
+ )
390
+
391
+ ```
392
+
393
+ """
394
+ # Parse through the initial args to determine the columns to apply the function to
395
+ requested_columns = []
396
+ for arg in args:
397
+ if not isinstance(arg, str) or not self._is_known_column(arg):
398
+ # We've reached an argument that is not a valid column, so we assume
399
+ # the remaining args are extra arguments to the function
400
+ break
401
+ layer = "base" if "." not in arg else arg.split(".")[0]
402
+ col = arg.split(".")[-1]
403
+ requested_columns.append((layer, col))
404
+
405
+ # We require the first *args to be the columns to apply the function to
406
+ if not requested_columns:
407
+ raise ValueError("No columns in `*args` specified to apply function to")
408
+
409
+ # The remaining args are the extra arguments to the function other than columns
410
+ extra_args: tuple[Any, ...] = () # empty tuple to make mypy happy
411
+ if len(requested_columns) < len(args):
412
+ extra_args = args[len(requested_columns) :]
413
+
414
+ # find targeted layers
415
+ layers = np.unique([col[0] for col in requested_columns])
416
+
417
+ # build a flat dataframe with array columns to apply to the function
418
+ apply_df = NestedFrame()
419
+ for layer in layers:
420
+ if layer == "base":
421
+ columns = [col[1] for col in requested_columns if col[0] == layer]
422
+ apply_df = apply_df.join(self[columns], how="outer")
423
+ else:
424
+ # TODO: It should be faster to pass these columns to to_lists, but its 20x slower
425
+ # columns = [col[1] for col in requested_columns if col[0] == layer]
426
+ apply_df = apply_df.join(self[layer].nest.to_lists(), how="outer")
427
+
428
+ # Translates the requested columns into the scalars or arrays we pass to func.
429
+ def translate_cols(frame, layer, col):
430
+ if layer == "base":
431
+ # We pass the "base" column as a scalar
432
+ return frame[col]
433
+ return np.asarray(frame[col])
434
+
435
+ # send arrays along to the apply call
436
+ result = apply_df.apply(
437
+ lambda x: func(
438
+ *[translate_cols(x, layer, col) for layer, col in requested_columns], *extra_args, **kwargs
439
+ ),
440
+ axis=1, # to apply func on each row of our nested frame)
441
+ )
442
+ return result
443
+
444
+ def to_parquet(self, path, by_layer=False, **kwargs) -> None:
445
+ """Creates parquet file(s) with the data of a NestedFrame, either
446
+ as a single parquet file where each nested dataset is packed into its
447
+ own column or as an individual parquet file for each layer.
448
+
449
+ Note that here we always opt to use the pyarrow engine for writing
450
+ parquet files.
451
+
452
+ Parameters
453
+ ----------
454
+ path : str
455
+ The path to the parquet file to be written if 'by_layer' is False.
456
+ If 'by_layer' is True, this should be the path to an existing.
457
+ by_layer : bool, default False
458
+ If False, writes the entire NestedFrame to a single parquet file.
459
+
460
+ If True, writes each layer to a separate parquet file within the
461
+ directory specified by path. The filename for each outputted file will
462
+ be named after its layer and then the ".parquet" extension.
463
+ For example for the base layer this is always "base.parquet".
464
+ kwargs : keyword arguments, optional
465
+ Keyword arguments to pass to the function.
466
+
467
+ Returns
468
+ -------
469
+ None
470
+ """
471
+ if not by_layer:
472
+ # We just defer to the pandas to_parquet method if we're not writing by layer
473
+ # or there is only one layer in the NestedFrame.
474
+ super().to_parquet(path, engine="pyarrow", **kwargs)
475
+ else:
476
+ # If we're writing by layer, path must be an existing directory
477
+ if not os.path.isdir(path):
478
+ raise ValueError("The provided path must be an existing directory if by_layer=True")
479
+
480
+ # Write the base layer to a parquet file
481
+ base_frame = self.drop(columns=self.nested_columns, inplace=False)
482
+ base_frame.to_parquet(os.path.join(path, "base.parquet"), by_layer=False, **kwargs)
483
+
484
+ # Write each nested layer to a parquet file
485
+ for layer in self.all_columns:
486
+ if layer != "base":
487
+ path_layer = os.path.join(path, f"{layer}.parquet")
488
+ self[layer].nest.to_flat().to_parquet(path_layer, engine="pyarrow", **kwargs)
489
+ return None