From b95d9b32fe01868bd67b437dd5eb9d9dcabb0c26 Mon Sep 17 00:00:00 2001 From: Anatoly Myachev Date: Fri, 15 Sep 2023 09:33:36 +0200 Subject: [PATCH 001/201] FIX-#6394: preserve dtypes for __setitem__ op when using not hashable key (#6547) Signed-off-by: Anatoly Myachev Co-authored-by: Vasily Litvinov --- .../dataframe/pandas/dataframe/dataframe.py | 22 ++++++++++--- .../storage_formats/base/query_compiler.py | 29 +++++++++++++--- .../storage_formats/cudf/query_compiler.py | 20 +++++++++-- .../storage_formats/pandas/query_compiler.py | 33 +++++++++++++++++-- modin/numpy/indexing.py | 29 ++-------------- modin/pandas/dataframe.py | 9 ++--- modin/pandas/indexing.py | 25 +++----------- modin/pandas/utils.py | 21 +++++++++--- .../storage_formats/pandas/test_internals.py | 11 +++++++ 9 files changed, 127 insertions(+), 72 deletions(-) diff --git a/modin/core/dataframe/pandas/dataframe/dataframe.py b/modin/core/dataframe/pandas/dataframe/dataframe.py index a5edd031b51..d81d2b26af4 100644 --- a/modin/core/dataframe/pandas/dataframe/dataframe.py +++ b/modin/core/dataframe/pandas/dataframe/dataframe.py @@ -2804,6 +2804,7 @@ def apply_select_indices( col_labels=None, new_index=None, new_columns=None, + new_dtypes=None, keep_remaining=False, item_to_distribute=no_default, ): @@ -2825,11 +2826,11 @@ def apply_select_indices( The column labels to apply over. Must be provided with `row_labels` to apply over both axes. new_index : list-like, optional - The index of the result. We may know this in advance, - and if not provided it must be computed. + The index of the result, if known in advance. new_columns : list-like, optional - The columns of the result. We may know this in - advance, and if not provided it must be computed. + The columns of the result, if known in advance. + new_dtypes : pandas.Series, optional + The dtypes of the result, if known in advance. keep_remaining : boolean, default: False Whether or not to drop the data that is not computed over. item_to_distribute : np.ndarray or scalar, default: no_default @@ -2845,6 +2846,11 @@ def apply_select_indices( new_index = self.index if axis == 1 else None if new_columns is None: new_columns = self.columns if axis == 0 else None + if new_columns is not None and new_dtypes is not None: + assert new_dtypes.index.equals( + new_columns + ), f"{new_dtypes=} doesn't have the same columns as in {new_columns=}" + if axis is not None: assert apply_indices is not None # Convert indices to numeric indices @@ -2872,7 +2878,12 @@ def apply_select_indices( axis ^ 1: [self.row_lengths, self.column_widths][axis ^ 1], } return self.__constructor__( - new_partitions, new_index, new_columns, lengths_objs[0], lengths_objs[1] + new_partitions, + new_index, + new_columns, + lengths_objs[0], + lengths_objs[1], + new_dtypes, ) else: # We are applying over both axes here, so make sure we have all the right @@ -2899,6 +2910,7 @@ def apply_select_indices( new_columns, self._row_lengths_cache, self._column_widths_cache, + new_dtypes, ) @lazy_metadata_decorator(apply_axis="both") diff --git a/modin/core/storage_formats/base/query_compiler.py b/modin/core/storage_formats/base/query_compiler.py index f3491bb7045..d2f4d8533b3 100644 --- a/modin/core/storage_formats/base/query_compiler.py +++ b/modin/core/storage_formats/base/query_compiler.py @@ -4326,7 +4326,9 @@ def setitem(df, axis, key, value): return DataFrameDefault.register(setitem)(self, axis=axis, key=key, value=value) - def write_items(self, row_numeric_index, col_numeric_index, broadcasted_items): + def write_items( + self, row_numeric_index, col_numeric_index, item, need_columns_reindex=True + ): """ Update QueryCompiler elements at the specified positions by passed values. @@ -4338,15 +4340,21 @@ def write_items(self, row_numeric_index, col_numeric_index, broadcasted_items): Row positions to write value. col_numeric_index : list of ints Column positions to write value. - broadcasted_items : 2D-array - Values to write. Have to be same size as defined by `row_numeric_index` - and `col_numeric_index`. + item : Any + Values to write. If not a scalar will be broadcasted according to + `row_numeric_index` and `col_numeric_index`. + need_columns_reindex : bool, default: True + In the case of assigning columns to a dataframe (broadcasting is + part of the flow), reindexing is not needed. Returns ------- BaseQueryCompiler New QueryCompiler with updated values. """ + # We have to keep this import away from the module level to avoid circular import + from modin.pandas.utils import broadcast_item, is_scalar + if not isinstance(row_numeric_index, slice): row_numeric_index = list(row_numeric_index) if not isinstance(col_numeric_index, slice): @@ -4358,8 +4366,19 @@ def write_items(df, broadcasted_items): df.iloc[row_numeric_index, col_numeric_index] = broadcasted_items return df + if not is_scalar(item): + broadcasted_item, _ = broadcast_item( + self, + row_numeric_index, + col_numeric_index, + item, + need_columns_reindex=need_columns_reindex, + ) + else: + broadcasted_item = item + return DataFrameDefault.register(write_items)( - self, broadcasted_items=broadcasted_items + self, broadcasted_items=broadcasted_item ) # END Abstract methods for QueryCompiler diff --git a/modin/core/storage_formats/cudf/query_compiler.py b/modin/core/storage_formats/cudf/query_compiler.py index de6e0fcc789..c64c6106d4e 100644 --- a/modin/core/storage_formats/cudf/query_compiler.py +++ b/modin/core/storage_formats/cudf/query_compiler.py @@ -31,7 +31,12 @@ def transpose(self, *args, **kwargs): # Switch the index and columns and transpose the data within the blocks. return self.__constructor__(self._modin_frame.transpose()) - def write_items(self, row_numeric_index, col_numeric_index, broadcasted_items): + def write_items( + self, row_numeric_index, col_numeric_index, item, need_columns_reindex=True + ): + # We have to keep this import away from the module level to avoid circular import + from modin.pandas.utils import broadcast_item, is_scalar + def iloc_mut(partition, row_internal_indices, col_internal_indices, item): partition = partition.copy() unique_items = np.unique(item) @@ -54,6 +59,17 @@ def iloc_mut(partition, row_internal_indices, col_internal_indices, item): partition.iloc[i, j] = it return partition + if not is_scalar(item): + broadcasted_item, _ = broadcast_item( + self, + row_numeric_index, + col_numeric_index, + item, + need_columns_reindex=need_columns_reindex, + ) + else: + broadcasted_item = item + new_modin_frame = self._modin_frame.apply_select_indices( axis=None, func=iloc_mut, @@ -62,6 +78,6 @@ def iloc_mut(partition, row_internal_indices, col_internal_indices, item): new_index=self.index, new_columns=self.columns, keep_remaining=True, - item_to_distribute=broadcasted_items, + item_to_distribute=broadcasted_item, ) return self.__constructor__(new_modin_frame) diff --git a/modin/core/storage_formats/pandas/query_compiler.py b/modin/core/storage_formats/pandas/query_compiler.py index cbe41184d00..7eaaa30ed5b 100644 --- a/modin/core/storage_formats/pandas/query_compiler.py +++ b/modin/core/storage_formats/pandas/query_compiler.py @@ -4217,7 +4217,12 @@ def take_2d_positional(self, index=None, columns=None): ) ) - def write_items(self, row_numeric_index, col_numeric_index, broadcasted_items): + def write_items( + self, row_numeric_index, col_numeric_index, item, need_columns_reindex=True + ): + # We have to keep this import away from the module level to avoid circular import + from modin.pandas.utils import broadcast_item, is_scalar + def iloc_mut(partition, row_internal_indices, col_internal_indices, item): """ Write `value` in a specified location in a single partition. @@ -4253,6 +4258,29 @@ def iloc_mut(partition, row_internal_indices, col_internal_indices, item): partition.iloc[row_internal_indices, col_internal_indices] = item.copy() return partition + if not is_scalar(item): + broadcasted_item, broadcasted_dtypes = broadcast_item( + self, + row_numeric_index, + col_numeric_index, + item, + need_columns_reindex=need_columns_reindex, + ) + else: + broadcasted_item, broadcasted_dtypes = item, pandas.Series( + [np.array(item).dtype] * len(col_numeric_index) + ) + + new_dtypes = None + if ( + # compute dtypes only if assigning entire columns + isinstance(row_numeric_index, slice) + and row_numeric_index == slice(None) + and self._modin_frame.has_materialized_dtypes + ): + new_dtypes = self.dtypes.copy() + new_dtypes.iloc[col_numeric_index] = broadcasted_dtypes.values + new_modin_frame = self._modin_frame.apply_select_indices( axis=None, func=iloc_mut, @@ -4260,8 +4288,9 @@ def iloc_mut(partition, row_internal_indices, col_internal_indices, item): col_labels=col_numeric_index, new_index=self.index, new_columns=self.columns, + new_dtypes=new_dtypes, keep_remaining=True, - item_to_distribute=broadcasted_items, + item_to_distribute=broadcasted_item, ) return self.__constructor__(new_modin_frame) diff --git a/modin/numpy/indexing.py b/modin/numpy/indexing.py index 272f277eede..aabe28efa8e 100644 --- a/modin/numpy/indexing.py +++ b/modin/numpy/indexing.py @@ -472,22 +472,6 @@ def get_axis(axis): axis = None return axis - def _write_items(self, row_lookup, col_lookup, item): - """ - Perform remote write and replace blocks. - - Parameters - ---------- - row_lookup : slice or scalar - The global row index to write item to. - col_lookup : slice or scalar - The global col index to write item to. - item : numpy.ndarray - The new item value that needs to be assigned to `self`. - """ - new_qc = self.arr._query_compiler.write_items(row_lookup, col_lookup, item) - self.arr._update_inplace(new_qc) - def _setitem_positional(self, row_lookup, col_lookup, item, axis=None): """ Assign `item` value to located dataset. @@ -512,16 +496,9 @@ def _setitem_positional(self, row_lookup, col_lookup, item, axis=None): row_lookup = range(len(self.arr._query_compiler.index))[row_lookup] if isinstance(col_lookup, slice): col_lookup = range(len(self.arr._query_compiler.columns))[col_lookup] - if axis is None: - if not is_scalar(item): - item = broadcast_item(self.arr, row_lookup, col_lookup, item) - self.arr._query_compiler = self.arr._query_compiler.write_items( - row_lookup, col_lookup, item - ) - else: - if not is_scalar(item): - item = broadcast_item(self.arr, row_lookup, col_lookup, item) - self._write_items(row_lookup, col_lookup, item) + + new_qc = self.arr._query_compiler.write_items(row_lookup, col_lookup, item) + self.arr._update_inplace(new_qc) def __setitem__(self, key, item): """ diff --git a/modin/pandas/dataframe.py b/modin/pandas/dataframe.py index a00c137fa37..a4a37bbf7b3 100644 --- a/modin/pandas/dataframe.py +++ b/modin/pandas/dataframe.py @@ -58,7 +58,6 @@ from .utils import ( from_pandas, from_non_pandas, - broadcast_item, cast_function_modin2pandas, SET_DATAFRAME_ATTRIBUTE_WARNING, ) @@ -2530,16 +2529,12 @@ def __setitem__(self, key, value): value = np.array(value) if len(key) != value.shape[-1]: raise ValueError("Columns must be same length as key") - item = broadcast_item( - self, + new_qc = self._query_compiler.write_items( slice(None), - key, + self.columns.get_indexer_for(key), value, need_columns_reindex=False, ) - new_qc = self._query_compiler.write_items( - slice(None), self.columns.get_indexer_for(key), item - ) self._update_inplace(new_qc) # self.loc[:, key] = value return diff --git a/modin/pandas/indexing.py b/modin/pandas/indexing.py index b0e4f37c569..5b2e6d518b7 100644 --- a/modin/pandas/indexing.py +++ b/modin/pandas/indexing.py @@ -40,7 +40,7 @@ from .dataframe import DataFrame from .series import Series -from .utils import is_scalar, broadcast_item +from .utils import is_scalar def is_slice(x): @@ -435,27 +435,12 @@ def _setitem_positional(self, row_lookup, col_lookup, item, axis=None): assert len(row_lookup) == 1 new_qc = self.qc.setitem(1, self.qc.index[row_lookup[0]], item) self.df._create_or_update_from_compiler(new_qc, inplace=True) + self.qc = self.df._query_compiler # Assignment to both axes. else: - if not is_scalar(item): - item = broadcast_item(self.df, row_lookup, col_lookup, item) - self._write_items(row_lookup, col_lookup, item) - - def _write_items(self, row_lookup, col_lookup, item): - """ - Perform remote write and replace blocks. - - Parameters - ---------- - row_lookup : slice or scalar - The global row index to write item to. - col_lookup : slice or scalar - The global col index to write item to. - item : numpy.ndarray - The new item value that needs to be assigned to `self`. - """ - new_qc = self.qc.write_items(row_lookup, col_lookup, item) - self.df._create_or_update_from_compiler(new_qc, inplace=True) + new_qc = self.qc.write_items(row_lookup, col_lookup, item) + self.df._create_or_update_from_compiler(new_qc, inplace=True) + self.qc = self.df._query_compiler def _determine_setitem_axis(self, row_lookup, col_lookup, row_scalar, col_scalar): """ diff --git a/modin/pandas/utils.py b/modin/pandas/utils.py index abef69318be..f2a4d977283 100644 --- a/modin/pandas/utils.py +++ b/modin/pandas/utils.py @@ -302,7 +302,7 @@ def broadcast_item( Parameters ---------- - obj : DataFrame or Series + obj : DataFrame or Series or query compiler The object containing the necessary information about the axes. row_lookup : slice or scalar The global row index to locate inside of `item`. @@ -316,8 +316,9 @@ def broadcast_item( Returns ------- - np.ndarray - `item` after it was broadcasted to `to_shape`. + (np.ndarray, Optional[Series]) + * np.ndarray - `item` after it was broadcasted to `to_shape`. + * Series - item's dtypes. Raises ------ @@ -345,6 +346,7 @@ def broadcast_item( ) to_shape = new_row_len, new_col_len + dtypes = None if isinstance(item, (pandas.Series, pandas.DataFrame, Series, DataFrame)): # convert indices in lookups to names, as pandas reindex expects them to be so axes_to_reindex = {} @@ -358,12 +360,21 @@ def broadcast_item( # New value for columns/index make that reindex add NaN values if axes_to_reindex: item = item.reindex(**axes_to_reindex) + + dtypes = item.dtypes + if not isinstance(dtypes, pandas.Series): + dtypes = pandas.Series([dtypes]) + try: + # Cast to numpy drop information about heterogeneous types (cast to common) + # TODO: we shouldn't do that, maybe there should be the if branch item = np.array(item) + if dtypes is None: + dtypes = pandas.Series([item.dtype] * len(col_lookup)) if np.prod(to_shape) == np.prod(item.shape): - return item.reshape(to_shape) + return item.reshape(to_shape), dtypes else: - return np.broadcast_to(item, to_shape) + return np.broadcast_to(item, to_shape), dtypes except ValueError: from_shape = np.array(item).shape raise ValueError( diff --git a/modin/test/storage_formats/pandas/test_internals.py b/modin/test/storage_formats/pandas/test_internals.py index 5a5f191ea93..0c9c6a7128f 100644 --- a/modin/test/storage_formats/pandas/test_internals.py +++ b/modin/test/storage_formats/pandas/test_internals.py @@ -1113,6 +1113,17 @@ def test_setitem_bool_preserve_dtypes(): assert df._query_compiler._modin_frame.has_materialized_dtypes +def test_setitem_unhashable_preserve_dtypes(): + df = pd.DataFrame([[1, 2, 3, 4], [5, 6, 7, 8]]) + assert df._query_compiler._modin_frame.has_materialized_dtypes + + df2 = pd.DataFrame([[9, 9], [5, 5]]) + assert df2._query_compiler._modin_frame.has_materialized_dtypes + + df[[1, 2]] = df2 + assert df._query_compiler._modin_frame.has_materialized_dtypes + + @pytest.mark.parametrize( "modify_config", [{ExperimentalGroupbyImpl: True}], indirect=True ) From aa752565fad37b0259785d639a0a5bf317f55c24 Mon Sep 17 00:00:00 2001 From: Anatoly Myachev Date: Fri, 15 Sep 2023 14:19:48 +0200 Subject: [PATCH 002/201] FIX-#5164: Fix unwrap_partitions for virtual partitions when `axis=None` (#6560) Co-authored-by: Rehan Durrani Signed-off-by: Anatoly Myachev --- .../dataframe/pandas/partitions.py | 25 +++++++++++++++++-- modin/test/test_partition_api.py | 22 ++++++++++++++++ 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/modin/distributed/dataframe/pandas/partitions.py b/modin/distributed/dataframe/pandas/partitions.py index aa61b024b1d..1366826c6bf 100644 --- a/modin/distributed/dataframe/pandas/partitions.py +++ b/modin/distributed/dataframe/pandas/partitions.py @@ -23,18 +23,30 @@ if TYPE_CHECKING: from modin.core.execution.ray.implementations.pandas_on_ray.partitioning import ( PandasOnRayDataframePartition, + PandasOnRayDataframeColumnPartition, + PandasOnRayDataframeRowPartition, ) from modin.core.execution.dask.implementations.pandas_on_dask.partitioning import ( PandasOnDaskDataframePartition, + PandasOnDaskDataframeColumnPartition, + PandasOnDaskDataframeRowPartition, ) - from modin.core.execution.unidist.implementations.pandas_on_unidist.partitioning.partition import ( + from modin.core.execution.unidist.implementations.pandas_on_unidist.partitioning import ( PandasOnUnidistDataframePartition, + PandasOnUnidistDataframeColumnPartition, + PandasOnUnidistDataframeRowPartition, ) PartitionUnionType = Union[ PandasOnRayDataframePartition, PandasOnDaskDataframePartition, PandasOnUnidistDataframePartition, + PandasOnRayDataframeColumnPartition, + PandasOnRayDataframeRowPartition, + PandasOnDaskDataframeColumnPartition, + PandasOnDaskDataframeRowPartition, + PandasOnUnidistDataframeColumnPartition, + PandasOnUnidistDataframeRowPartition, ] else: from typing import Any @@ -85,7 +97,10 @@ def _unwrap_partitions() -> list: [p.drain_call_queue() for p in modin_frame._partitions.flatten()] def get_block(partition: PartitionUnionType) -> np.ndarray: - blocks = partition.list_of_blocks + if hasattr(partition, "force_materialization"): + blocks = partition.force_materialization().list_of_blocks + else: + blocks = partition.list_of_blocks assert ( len(blocks) == 1 ), f"Implementation assumes that partition contains a single block, but {len(blocks)} recieved." @@ -109,6 +124,12 @@ def get_block(partition: PartitionUnionType) -> np.ndarray: "PandasOnRayDataframePartition", "PandasOnDaskDataframePartition", "PandasOnUnidistDataframePartition", + "PandasOnRayDataframeColumnPartition", + "PandasOnRayDataframeRowPartition", + "PandasOnDaskDataframeColumnPartition", + "PandasOnDaskDataframeRowPartition", + "PandasOnUnidistDataframeColumnPartition", + "PandasOnUnidistDataframeRowPartition", ): return _unwrap_partitions() raise ValueError( diff --git a/modin/test/test_partition_api.py b/modin/test/test_partition_api.py index a90440aa8fe..64bd0ef8167 100644 --- a/modin/test/test_partition_api.py +++ b/modin/test/test_partition_api.py @@ -114,6 +114,28 @@ def get_df(lib, data): ) +def test_unwrap_virtual_partitions(): + # see #5164 for details + data = test_data["int_data"] + df = pd.DataFrame(data) + virtual_partitioned_df = pd.concat([df] * 10) + actual_partitions = np.array(unwrap_partitions(virtual_partitioned_df, axis=None)) + expected_df = pd.concat([pd.DataFrame(data)] * 10) + expected_partitions = expected_df._query_compiler._modin_frame._partitions + assert expected_partitions.shape == actual_partitions.shape + + for row_idx in range(expected_partitions.shape[0]): + for col_idx in range(expected_partitions.shape[1]): + df_equals( + get_func( + expected_partitions[row_idx][col_idx] + .force_materialization() + .list_of_blocks[0] + ), + get_func(actual_partitions[row_idx][col_idx]), + ) + + @pytest.mark.parametrize("column_widths", [None, "column_widths"]) @pytest.mark.parametrize("row_lengths", [None, "row_lengths"]) @pytest.mark.parametrize("columns", [None, "columns"]) From 0e9cfdb2576408e59ccba7e87bbba0e31f14cd75 Mon Sep 17 00:00:00 2001 From: Anatoly Myachev Date: Fri, 15 Sep 2023 18:10:42 +0200 Subject: [PATCH 003/201] REFACTOR-#4902: use isort (#6551) Signed-off-by: Anatoly Myachev --- .github/workflows/ci.yml | 29 ++--- asv_bench/benchmarks/benchmarks.py | 19 ++-- asv_bench/benchmarks/hdk/benchmarks.py | 26 +++-- asv_bench/benchmarks/hdk/io.py | 11 +- asv_bench/benchmarks/io/csv.py | 8 +- asv_bench/benchmarks/io/parquet.py | 2 +- .../scalability/scalability_benchmarks.py | 9 +- asv_bench/benchmarks/utils/__init__.py | 15 ++- asv_bench/benchmarks/utils/common.py | 12 ++- asv_bench/benchmarks/utils/compatibility.py | 3 +- asv_bench/benchmarks/utils/data_shapes.py | 4 +- asv_bench/test/test_utils.py | 10 +- ci/teamcity/comment_on_pr.py | 3 +- contributing/pre-commit | 4 + docs/conf.py | 4 +- environment-dev.yml | 1 + examples/docker/modin-hdk/census-hdk.py | 10 +- examples/docker/modin-hdk/nyc-taxi-hdk.py | 4 +- examples/docker/modin-hdk/plasticc-hdk.py | 8 +- examples/docker/modin-hdk/utils.py | 2 +- examples/docker/modin-ray/census.py | 9 +- examples/docker/modin-ray/nyc-taxi.py | 15 +-- examples/docker/modin-ray/plasticc.py | 4 +- .../hdk_on_native/test/test_notebooks.py | 2 +- .../pandas_on_dask/test/test_notebooks.py | 4 +- .../pandas_on_ray/test/test_notebooks.py | 4 +- .../pandas_on_unidist/setup_kernel.py | 2 +- .../pandas_on_unidist/test/test_notebooks.py | 4 +- modin/__init__.py | 2 +- modin/_version.py | 2 +- modin/config/__init__.py | 2 +- modin/config/__main__.py | 5 +- modin/config/envvars.py | 16 ++- modin/config/test/test_envvars.py | 7 +- modin/config/test/test_parameter.py | 1 + modin/conftest.py | 48 ++++----- modin/core/dataframe/algebra/__init__.py | 10 +- modin/core/dataframe/algebra/binary.py | 8 +- .../algebra/default2pandas/__init__.py | 12 +-- .../algebra/default2pandas/binary.py | 4 +- .../algebra/default2pandas/dataframe.py | 5 +- .../algebra/default2pandas/default.py | 8 +- .../algebra/default2pandas/groupby.py | 5 +- modin/core/dataframe/algebra/groupby.py | 7 +- .../dataframe/base/dataframe/dataframe.py | 3 +- modin/core/dataframe/base/dataframe/utils.py | 7 +- .../dataframe/pandas/dataframe/dataframe.py | 35 +++--- .../core/dataframe/pandas/dataframe/utils.py | 9 +- .../interchange/dataframe_protocol/buffer.py | 3 +- .../interchange/dataframe_protocol/column.py | 11 +- .../dataframe_protocol/dataframe.py | 4 +- .../dataframe_protocol/from_dataframe.py | 22 ++-- .../dataframe/pandas/metadata/__init__.py | 2 +- modin/core/dataframe/pandas/metadata/index.py | 3 +- .../pandas/partitioning/axis_partition.py | 6 +- .../pandas/partitioning/partition.py | 6 +- .../pandas/partitioning/partition_manager.py | 14 +-- .../execution/dask/common/engine_wrapper.py | 2 +- modin/core/execution/dask/common/utils.py | 6 +- .../pandas_on_dask/dataframe/dataframe.py | 1 + .../implementations/pandas_on_dask/io/io.py | 14 +-- .../pandas_on_dask/partitioning/__init__.py | 2 +- .../pandas_on_dask/partitioning/partition.py | 4 +- .../partitioning/partition_manager.py | 3 +- .../partitioning/virtual_partition.py | 6 +- .../dispatching/factories/dispatcher.py | 4 +- .../dispatching/factories/factories.py | 13 ++- .../factories/test/test_dispatcher.py | 11 +- modin/core/execution/modin_aqp.py | 5 +- .../pandas_on_python/dataframe/dataframe.py | 1 + .../implementations/pandas_on_python/io/io.py | 4 +- .../partitioning/partition_manager.py | 5 +- .../partitioning/virtual_partition.py | 3 +- modin/core/execution/ray/common/utils.py | 23 ++-- .../cudf_on_ray/dataframe/dataframe.py | 9 +- .../cudf_on_ray/io/__init__.py | 1 - .../ray/implementations/cudf_on_ray/io/io.py | 5 +- .../cudf_on_ray/io/text/__init__.py | 1 - .../cudf_on_ray/io/text/csv_dispatcher.py | 5 +- .../cudf_on_ray/partitioning/__init__.py | 2 +- .../partitioning/axis_partition.py | 1 + .../cudf_on_ray/partitioning/gpu_manager.py | 2 +- .../cudf_on_ray/partitioning/partition.py | 6 +- .../partitioning/partition_manager.py | 13 +-- .../pandas_on_ray/dataframe/dataframe.py | 3 +- .../implementations/pandas_on_ray/io/io.py | 13 +-- .../pandas_on_ray/partitioning/__init__.py | 2 +- .../pandas_on_ray/partitioning/partition.py | 6 +- .../partitioning/partition_manager.py | 7 +- .../partitioning/virtual_partition.py | 5 +- .../core/execution/unidist/common/__init__.py | 2 +- .../unidist/common/engine_wrapper.py | 1 + modin/core/execution/unidist/common/utils.py | 1 + .../pandas_on_unidist/dataframe/dataframe.py | 3 +- .../pandas_on_unidist/io/io.py | 13 +-- .../partitioning/__init__.py | 2 +- .../partitioning/partition.py | 4 +- .../partitioning/partition_manager.py | 7 +- .../partitioning/virtual_partition.py | 5 +- modin/core/io/__init__.py | 12 +-- .../column_stores/column_store_dispatcher.py | 4 +- .../io/column_stores/feather_dispatcher.py | 2 +- .../io/column_stores/parquet_dispatcher.py | 12 +-- modin/core/io/file_dispatcher.py | 9 +- modin/core/io/io.py | 6 +- modin/core/io/sql/sql_dispatcher.py | 3 +- modin/core/io/text/excel_dispatcher.py | 12 ++- modin/core/io/text/fwf_dispatcher.py | 2 +- modin/core/io/text/json_dispatcher.py | 7 +- modin/core/io/text/text_file_dispatcher.py | 12 +-- modin/core/storage_formats/base/doc_utils.py | 2 +- .../storage_formats/base/query_compiler.py | 31 +++--- modin/core/storage_formats/cudf/parser.py | 5 +- .../storage_formats/pandas/aggregations.py | 7 +- modin/core/storage_formats/pandas/groupby.py | 6 +- modin/core/storage_formats/pandas/parsers.py | 23 ++-- .../storage_formats/pandas/query_compiler.py | 58 +++++----- modin/core/storage_formats/pandas/utils.py | 4 +- modin/db_conn.py | 2 +- .../distributed/dataframe/pandas/__init__.py | 2 +- .../dataframe/pandas/partitions.py | 17 +-- modin/error_message.py | 3 +- modin/experimental/batch/__init__.py | 1 - modin/experimental/batch/pipeline.py | 7 +- .../experimental/batch/test/test_pipeline.py | 6 +- .../implementations/pandas_on_dask/io/io.py | 25 +++-- .../hdk_on_native/base_worker.py | 4 +- .../hdk_on_native/calcite_algebra.py | 2 +- .../hdk_on_native/calcite_builder.py | 45 ++++---- .../hdk_on_native/calcite_serializer.py | 27 +++-- .../hdk_on_native/dataframe/dataframe.py | 70 ++++++------ .../hdk_on_native/dataframe/utils.py | 10 +- .../hdk_on_native/df_algebra.py | 14 ++- .../implementations/hdk_on_native/expr.py | 14 +-- .../hdk_on_native/hdk_worker.py | 16 ++- .../interchange/dataframe_protocol/buffer.py | 9 +- .../interchange/dataframe_protocol/column.py | 22 ++-- .../dataframe_protocol/dataframe.py | 16 +-- .../interchange/dataframe_protocol/utils.py | 8 +- .../implementations/hdk_on_native/io/io.py | 30 +++--- .../hdk_on_native/partitioning/partition.py | 2 +- .../partitioning/partition_manager.py | 21 ++-- .../hdk_on_native/test/test_dataframe.py | 48 ++++----- .../hdk_on_native/test/test_init.py | 1 + .../hdk_on_native/test/test_utils.py | 5 +- .../hdk_on_native/test/utils.py | 20 ++-- .../implementations/pandas_on_ray/io/io.py | 24 ++--- .../pyarrow_on_ray/dataframe/dataframe.py | 3 +- .../implementations/pyarrow_on_ray/io/io.py | 12 +-- .../partitioning/axis_partition.py | 7 +- .../pyarrow_on_ray/partitioning/partition.py | 2 +- .../partitioning/partition_manager.py | 1 + .../pandas_on_unidist/io/io.py | 28 ++--- modin/experimental/core/io/__init__.py | 4 +- .../core/io/pickle/pickle_dispatcher.py | 2 +- .../core/io/sql/sql_dispatcher.py | 9 +- modin/experimental/core/io/sql/utils.py | 2 +- .../core/io/text/csv_glob_dispatcher.py | 16 +-- .../core/io/text/custom_text_dispatcher.py | 2 +- .../storage_formats/hdk/query_compiler.py | 24 +++-- .../core/storage_formats/pyarrow/__init__.py | 2 +- .../core/storage_formats/pyarrow/parsers.py | 3 +- .../fuzzydata/test/test_fuzzydata.py | 7 +- modin/experimental/pandas/__init__.py | 6 +- modin/experimental/pandas/io.py | 5 +- modin/experimental/pandas/test/test_io_exp.py | 11 +- modin/experimental/spreadsheet/general.py | 3 +- .../spreadsheet/test/test_general.py | 7 +- modin/experimental/sql/__init__.py | 3 +- modin/experimental/sql/hdk/query.py | 4 +- modin/experimental/sql/test/test_sql.py | 9 +- modin/experimental/xgboost/__init__.py | 2 +- .../experimental/xgboost/test/test_default.py | 2 +- .../experimental/xgboost/test/test_dmatrix.py | 9 +- .../experimental/xgboost/test/test_xgboost.py | 22 ++-- modin/experimental/xgboost/utils.py | 1 + modin/experimental/xgboost/xgboost.py | 2 +- modin/experimental/xgboost/xgboost_ray.py | 13 +-- modin/logging/__init__.py | 2 +- modin/logging/config.py | 15 +-- modin/logging/logger_decorator.py | 5 +- modin/numpy/__init__.py | 102 +++++++----------- modin/numpy/arr.py | 15 ++- modin/numpy/array_creation.py | 1 + modin/numpy/array_shaping.py | 1 + modin/numpy/constants.py | 6 +- modin/numpy/indexing.py | 12 ++- modin/numpy/linalg.py | 3 +- modin/numpy/logic.py | 5 +- modin/numpy/math.py | 5 +- modin/numpy/test/test_array.py | 12 ++- modin/numpy/test/test_array_arithmetic.py | 1 + modin/numpy/test/test_array_axis_functions.py | 1 + modin/numpy/test/test_array_creation.py | 1 + modin/numpy/test/test_array_indexing.py | 1 + modin/numpy/test/test_array_linalg.py | 5 +- modin/numpy/test/test_array_logic.py | 4 +- modin/numpy/test/test_array_math.py | 1 + modin/numpy/test/test_array_shaping.py | 1 + modin/numpy/trigonometry.py | 3 +- modin/numpy/utils.py | 2 +- modin/pandas/__init__.py | 82 +++++++------- modin/pandas/accessor.py | 2 +- modin/pandas/base.py | 70 ++++++------ modin/pandas/dataframe.py | 57 +++++----- modin/pandas/general.py | 17 ++- modin/pandas/groupby.py | 28 ++--- modin/pandas/indexing.py | 8 +- modin/pandas/io.py | 63 +++++------ modin/pandas/plotting.py | 3 +- modin/pandas/resample.py | 4 +- modin/pandas/series.py | 37 +++---- modin/pandas/series_utils.py | 4 +- modin/pandas/test/dataframe/test_binary.py | 22 ++-- modin/pandas/test/dataframe/test_default.py | 35 +++--- modin/pandas/test/dataframe/test_indexing.py | 35 +++--- modin/pandas/test/dataframe/test_iter.py | 24 ++--- modin/pandas/test/dataframe/test_join_sort.py | 27 +++-- .../test/dataframe/test_map_metadata.py | 49 ++++----- modin/pandas/test/dataframe/test_pickle.py | 4 +- modin/pandas/test/dataframe/test_reduce.py | 25 +++-- modin/pandas/test/dataframe/test_udf.py | 41 ++++--- modin/pandas/test/dataframe/test_window.py | 34 +++--- .../test/internals/test_benchmark_mode.py | 1 - modin/pandas/test/test_api.py | 5 +- modin/pandas/test/test_concat.py | 11 +- modin/pandas/test/test_expanding.py | 13 +-- modin/pandas/test/test_general.py | 25 ++--- modin/pandas/test/test_groupby.py | 38 +++---- modin/pandas/test/test_io.py | 84 +++++++-------- modin/pandas/test/test_reshape.py | 5 +- modin/pandas/test/test_rolling.py | 13 +-- modin/pandas/test/test_series.py | 94 ++++++++-------- modin/pandas/test/utils.py | 40 +++---- modin/pandas/utils.py | 17 ++- modin/pandas/window.py | 5 +- .../dataframe_protocol/base/test_sanity.py | 2 +- .../dataframe_protocol/base/test_utils.py | 4 +- .../dataframe_protocol/hdk/test_protocol.py | 13 +-- .../dataframe_protocol/hdk/utils.py | 5 +- .../pandas/test_protocol.py | 2 +- .../dataframe_protocol/test_general.py | 5 +- .../storage_formats/base/test_internals.py | 8 +- .../storage_formats/hdk/test_internals.py | 5 +- .../storage_formats/pandas/test_internals.py | 43 +++----- modin/test/test_docstring_urls.py | 9 +- modin/test/test_envvar_catcher.py | 1 + modin/test/test_envvar_npartitions.py | 2 +- modin/test/test_executions_api.py | 6 +- modin/test/test_headers.py | 3 +- modin/test/test_logging.py | 5 +- modin/test/test_partition_api.py | 12 ++- modin/test/test_utils.py | 9 +- modin/utils.py | 39 ++++--- requirements-dev.txt | 1 + scripts/doc_checker.py | 28 ++--- scripts/release.py | 12 +-- scripts/test/examples.py | 7 +- scripts/test/test_doc_checker.py | 7 +- setup.cfg | 3 + setup.py | 3 +- stress_tests/kaggle/kaggle10.py | 21 ++-- stress_tests/kaggle/kaggle12.py | 24 +++-- stress_tests/kaggle/kaggle13.py | 5 +- stress_tests/kaggle/kaggle14.py | 15 +-- stress_tests/kaggle/kaggle18.py | 29 ++--- stress_tests/kaggle/kaggle19.py | 5 +- stress_tests/kaggle/kaggle20.py | 13 +-- stress_tests/kaggle/kaggle22.py | 5 +- stress_tests/kaggle/kaggle3.py | 21 ++-- stress_tests/kaggle/kaggle4.py | 18 ++-- stress_tests/kaggle/kaggle5.py | 13 ++- stress_tests/kaggle/kaggle6.py | 17 +-- stress_tests/kaggle/kaggle7.py | 15 +-- stress_tests/kaggle/kaggle8.py | 3 +- stress_tests/kaggle/kaggle9.py | 10 +- stress_tests/test_kaggle_ipynb.py | 7 +- versioneer.py | 3 + 278 files changed, 1692 insertions(+), 1624 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f8729fa25ef..9e7323cdfbb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,16 +26,17 @@ env: MODIN_GITHUB_CI: true jobs: - lint-black: - name: lint (black) + lint-black-isort: + name: lint (black and isort) runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - uses: ./.github/actions/python-only - - run: pip install black + - run: pip install black isort>=5.12 # NOTE: keep the black command here in sync with the pre-commit hook in # /contributing/pre-commit - run: black --check --diff modin/ asv_bench/benchmarks scripts/doc_checker.py + - run: isort . --check-only lint-mypy: name: lint (mypy) @@ -77,7 +78,7 @@ jobs: - uses: ./.github/actions/upload-coverage test-clean-install: - needs: [lint-flake8, lint-black] + needs: [lint-flake8, lint-black-isort] strategy: matrix: os: @@ -99,7 +100,7 @@ jobs: MODIN_ENGINE=unidist UNIDIST_BACKEND=mpi mpiexec -n 1 python -c "import modin.pandas as pd; print(pd.DataFrame([1,2,3]))" test-internals: - needs: [lint-flake8, lint-black] + needs: [lint-flake8, lint-black-isort] runs-on: ubuntu-latest defaults: run: @@ -124,7 +125,7 @@ jobs: - uses: ./.github/actions/upload-coverage test-defaults: - needs: [lint-flake8, lint-black] + needs: [lint-flake8, lint-black-isort] runs-on: ubuntu-latest defaults: run: @@ -155,7 +156,7 @@ jobs: - uses: ./.github/actions/upload-coverage test-hdk: - needs: [lint-flake8, lint-black] + needs: [lint-flake8, lint-black-isort] runs-on: ubuntu-latest defaults: run: @@ -212,7 +213,7 @@ jobs: test-asv-benchmarks: if: github.event_name == 'pull_request' - needs: [lint-flake8, lint-black] + needs: [lint-flake8, lint-black-isort] runs-on: ubuntu-latest defaults: run: @@ -322,7 +323,7 @@ jobs: "${{ steps.filter.outputs.ray }}" "${{ steps.filter.outputs.dask }}" >> $GITHUB_OUTPUT test-all-unidist: - needs: [lint-flake8, lint-black, execution-filter] + needs: [lint-flake8, lint-black-isort, execution-filter] if: github.event_name == 'push' || needs.execution-filter.outputs.unidist == 'true' runs-on: ubuntu-latest defaults: @@ -387,7 +388,7 @@ jobs: - uses: ./.github/actions/upload-coverage test-all: - needs: [lint-flake8, lint-black, execution-filter] + needs: [lint-flake8, lint-black-isort, execution-filter] strategy: matrix: os: @@ -521,7 +522,7 @@ jobs: if: matrix.os == 'windows' test-sanity: - needs: [lint-flake8, lint-black, execution-filter] + needs: [lint-flake8, lint-black-isort, execution-filter] if: github.event_name == 'pull_request' strategy: matrix: @@ -644,7 +645,7 @@ jobs: - uses: ./.github/actions/upload-coverage test-experimental: - needs: [lint-flake8, lint-black] + needs: [lint-flake8, lint-black-isort] runs-on: ubuntu-latest defaults: run: @@ -673,7 +674,7 @@ jobs: - uses: ./.github/actions/upload-coverage test-pyarrow: - needs: [lint-flake8, lint-black] + needs: [lint-flake8, lint-black-isort] runs-on: ubuntu-latest defaults: run: @@ -703,7 +704,7 @@ jobs: - run: python -m pytest modin/pandas/test/test_io.py::TestCsv --verbose test-spreadsheet: - needs: [lint-flake8, lint-black] + needs: [lint-flake8, lint-black-isort] runs-on: ubuntu-latest defaults: run: diff --git a/asv_bench/benchmarks/benchmarks.py b/asv_bench/benchmarks/benchmarks.py index aaae2b00cd4..47920de5811 100644 --- a/asv_bench/benchmarks/benchmarks.py +++ b/asv_bench/benchmarks/benchmarks.py @@ -19,23 +19,24 @@ # define `MODIN_ASV_USE_IMPL` env var to choose library for using in performance # measurements +import math + import numpy as np import pandas._testing as tm -import math from .utils import ( - generate_dataframe, - gen_nan_data, - RAND_LOW, - RAND_HIGH, - random_string, - random_columns, - random_booleans, GROUPBY_NGROUPS, IMPL, + RAND_HIGH, + RAND_LOW, execute, - translator_groupby_ngroups, + gen_nan_data, + generate_dataframe, get_benchmark_shapes, + random_booleans, + random_columns, + random_string, + translator_groupby_ngroups, trigger_import, ) diff --git a/asv_bench/benchmarks/hdk/benchmarks.py b/asv_bench/benchmarks/hdk/benchmarks.py index c755acc9c42..d7868fe5a27 100644 --- a/asv_bench/benchmarks/hdk/benchmarks.py +++ b/asv_bench/benchmarks/hdk/benchmarks.py @@ -13,26 +13,24 @@ """General Modin on HDK storage format benchmarks.""" +import numpy as np +import pandas + +from ..benchmarks import TimeIndexing as TimeIndexingPandasExecution +from ..benchmarks import TimeIndexingColumns as TimeIndexingColumnsPandasExecution from ..utils import ( - generate_dataframe, - gen_nan_data, - RAND_LOW, - RAND_HIGH, GROUPBY_NGROUPS, IMPL, + RAND_HIGH, + RAND_LOW, execute, - translator_groupby_ngroups, - random_columns, + gen_nan_data, + generate_dataframe, + get_benchmark_shapes, random_booleans, + random_columns, + translator_groupby_ngroups, trigger_import, - get_benchmark_shapes, -) -import numpy as np -import pandas - -from ..benchmarks import ( - TimeIndexing as TimeIndexingPandasExecution, - TimeIndexingColumns as TimeIndexingColumnsPandasExecution, ) diff --git a/asv_bench/benchmarks/hdk/io.py b/asv_bench/benchmarks/hdk/io.py index 6f15837d924..6a5fd22926d 100644 --- a/asv_bench/benchmarks/hdk/io.py +++ b/asv_bench/benchmarks/hdk/io.py @@ -13,19 +13,18 @@ """IO Modin on HDK storage format benchmarks.""" +from ..io.csv import TimeReadCsvTrueFalseValues # noqa: F401 from ..utils import ( - generate_dataframe, - RAND_LOW, - RAND_HIGH, ASV_USE_IMPL, IMPL, + RAND_HIGH, + RAND_LOW, + generate_dataframe, + get_benchmark_shapes, get_shape_id, trigger_import, - get_benchmark_shapes, ) -from ..io.csv import TimeReadCsvTrueFalseValues # noqa: F401 - class TimeReadCsvNames: shapes = get_benchmark_shapes("hdk.TimeReadCsvNames") diff --git a/asv_bench/benchmarks/io/csv.py b/asv_bench/benchmarks/io/csv.py index 526e450139b..65752ba6b70 100644 --- a/asv_bench/benchmarks/io/csv.py +++ b/asv_bench/benchmarks/io/csv.py @@ -14,16 +14,16 @@ import numpy as np from ..utils import ( - generate_dataframe, - RAND_LOW, - RAND_HIGH, ASV_USE_IMPL, ASV_USE_STORAGE_FORMAT, IMPL, + RAND_HIGH, + RAND_LOW, execute, + generate_dataframe, + get_benchmark_shapes, get_shape_id, prepare_io_data, - get_benchmark_shapes, ) diff --git a/asv_bench/benchmarks/io/parquet.py b/asv_bench/benchmarks/io/parquet.py index 5e5bae44016..d23227f5329 100644 --- a/asv_bench/benchmarks/io/parquet.py +++ b/asv_bench/benchmarks/io/parquet.py @@ -15,9 +15,9 @@ ASV_USE_IMPL, IMPL, execute, + get_benchmark_shapes, get_shape_id, prepare_io_data_parquet, - get_benchmark_shapes, ) diff --git a/asv_bench/benchmarks/scalability/scalability_benchmarks.py b/asv_bench/benchmarks/scalability/scalability_benchmarks.py index f9850ff1999..740dcde5272 100644 --- a/asv_bench/benchmarks/scalability/scalability_benchmarks.py +++ b/asv_bench/benchmarks/scalability/scalability_benchmarks.py @@ -17,18 +17,19 @@ from modin.pandas.utils import from_pandas try: - from modin.utils import to_pandas, to_numpy + from modin.utils import to_numpy, to_pandas except ImportError: # This provides compatibility with older versions of the Modin, allowing us to test old commits. from modin.pandas.utils import to_pandas + import pandas from ..utils import ( - gen_data, - generate_dataframe, - RAND_LOW, RAND_HIGH, + RAND_LOW, execute, + gen_data, + generate_dataframe, get_benchmark_shapes, ) diff --git a/asv_bench/benchmarks/utils/__init__.py b/asv_bench/benchmarks/utils/__init__.py index debd94a23cb..9a8fa7bcf11 100644 --- a/asv_bench/benchmarks/utils/__init__.py +++ b/asv_bench/benchmarks/utils/__init__.py @@ -13,27 +13,24 @@ """Modin benchmarks utils.""" -from .compatibility import ( - ASV_USE_IMPL, - ASV_USE_STORAGE_FORMAT, -) -from .data_shapes import RAND_LOW, RAND_HIGH, GROUPBY_NGROUPS, get_benchmark_shapes from .common import ( IMPL, execute, - get_shape_id, gen_data, gen_nan_data, generate_dataframe, + get_shape_id, prepare_io_data, prepare_io_data_parquet, - random_string, - random_columns, random_booleans, + random_columns, + random_string, + setup, translator_groupby_ngroups, trigger_import, - setup, ) +from .compatibility import ASV_USE_IMPL, ASV_USE_STORAGE_FORMAT +from .data_shapes import GROUPBY_NGROUPS, RAND_HIGH, RAND_LOW, get_benchmark_shapes __all__ = [ "ASV_USE_IMPL", diff --git a/asv_bench/benchmarks/utils/common.py b/asv_bench/benchmarks/utils/common.py index 9a0a1dab276..81f2292a5fc 100644 --- a/asv_bench/benchmarks/utils/common.py +++ b/asv_bench/benchmarks/utils/common.py @@ -20,19 +20,21 @@ """ import logging -import modin.pandas -import pandas -import numpy as np import uuid from typing import Optional, Union +import numpy as np +import pandas + +import modin.pandas + from .compatibility import ( - ASV_USE_IMPL, ASV_DATASET_SIZE, ASV_USE_ENGINE, + ASV_USE_IMPL, ASV_USE_STORAGE_FORMAT, ) -from .data_shapes import RAND_LOW, RAND_HIGH +from .data_shapes import RAND_HIGH, RAND_LOW POSSIBLE_IMPL = { "modin": modin.pandas, diff --git a/asv_bench/benchmarks/utils/compatibility.py b/asv_bench/benchmarks/utils/compatibility.py index f445b42f402..2aa27f3d8de 100644 --- a/asv_bench/benchmarks/utils/compatibility.py +++ b/asv_bench/benchmarks/utils/compatibility.py @@ -14,6 +14,7 @@ """Compatibility layer for parameters used by ASV.""" import os + import modin.pandas as pd try: @@ -24,7 +25,7 @@ NPARTITIONS = pd.DEFAULT_NPARTITIONS try: - from modin.config import TestDatasetSize, AsvImplementation, Engine, StorageFormat + from modin.config import AsvImplementation, Engine, StorageFormat, TestDatasetSize ASV_USE_IMPL = AsvImplementation.get() ASV_DATASET_SIZE = TestDatasetSize.get() or "Small" diff --git a/asv_bench/benchmarks/utils/data_shapes.py b/asv_bench/benchmarks/utils/data_shapes.py index af3ce71014f..bd96f1ed566 100644 --- a/asv_bench/benchmarks/utils/data_shapes.py +++ b/asv_bench/benchmarks/utils/data_shapes.py @@ -13,10 +13,10 @@ """Define data shapes.""" -import os import json +import os -from .compatibility import ASV_USE_STORAGE_FORMAT, ASV_DATASET_SIZE +from .compatibility import ASV_DATASET_SIZE, ASV_USE_STORAGE_FORMAT RAND_LOW = 0 RAND_HIGH = 1_000_000_000 if ASV_USE_STORAGE_FORMAT == "hdk" else 100 diff --git a/asv_bench/test/test_utils.py b/asv_bench/test/test_utils.py index aa32a927950..5f5c89917ca 100644 --- a/asv_bench/test/test_utils.py +++ b/asv_bench/test/test_utils.py @@ -11,14 +11,14 @@ # ANY KIND, either express or implied. See the License for the specific language # governing permissions and limitations under the License. -import pytest -from unittest.mock import patch, mock_open, Mock -import numpy as np +from unittest.mock import Mock, mock_open, patch -from benchmarks.utils import data_shapes, get_benchmark_shapes, execute +import numpy as np +import pytest +from benchmarks.utils import data_shapes, execute, get_benchmark_shapes -from modin.config import AsvDataSizeConfig import modin.pandas as pd +from modin.config import AsvDataSizeConfig @pytest.mark.parametrize( diff --git a/ci/teamcity/comment_on_pr.py b/ci/teamcity/comment_on_pr.py index 82b5d8644d0..8496855a2a0 100644 --- a/ci/teamcity/comment_on_pr.py +++ b/ci/teamcity/comment_on_pr.py @@ -7,10 +7,11 @@ ``` """ -from github import Github import os import sys +from github import Github + # Check if this is a pull request or not based on the environment variable try: pr_id = int(os.environ["GITHUB_PR_NUMBER"].split("/")[-1]) diff --git a/contributing/pre-commit b/contributing/pre-commit index 4b7400b878e..446e4c1c529 100755 --- a/contributing/pre-commit +++ b/contributing/pre-commit @@ -11,6 +11,10 @@ printf "running black. This script will preempt the commit if black fails.\n" black --check --diff modin/ asv_bench/benchmarks scripts/doc_checker.py printf 'black passed!\n' +printf "running isort. This script will preempt the commit if isort fails.\n" +isort . --check-only +printf 'isort passed!\n' + printf "running flake8. This script will preempt the commit if flake8 fails.\n" flake8 modin/ asv_bench/benchmarks scripts/doc_checker.py printf "flake8 passed!\n" diff --git a/docs/conf.py b/docs/conf.py index 28a39c1c629..75bbc258912 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -6,9 +6,10 @@ # full list see the documentation: # http://www.sphinx-doc.org/en/stable/config +import os + # -- Project information ----------------------------------------------------- import sys -import os import types import ray @@ -54,7 +55,6 @@ def noop_decorator(*args, **kwargs): sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) import modin - from modin.config.__main__ import export_config_help configs_file_path = os.path.abspath( diff --git a/environment-dev.yml b/environment-dev.yml index 6813027b464..004ed213b76 100644 --- a/environment-dev.yml +++ b/environment-dev.yml @@ -60,6 +60,7 @@ dependencies: - flake8-print>=5.0.0 - mypy>=1.0.0 - pandas-stubs>=2.0.0 + - isort>=5.12 - pip: - asv==0.5.1 diff --git a/examples/docker/modin-hdk/census-hdk.py b/examples/docker/modin-hdk/census-hdk.py index 4d325320a58..71a4f9a20ef 100644 --- a/examples/docker/modin-hdk/census-hdk.py +++ b/examples/docker/modin-hdk/census-hdk.py @@ -12,11 +12,11 @@ # governing permissions and limitations under the License. import sys -from utils import measure -import modin.pandas as pd - import numpy as np +from utils import measure + +import modin.pandas as pd def read(filename): @@ -192,12 +192,12 @@ def cod(y_test, y_pred): def ml(X, y, random_state, n_runs, test_size): # to not install ML dependencies unless required - from sklearn import config_context import sklearnex + from sklearn import config_context sklearnex.patch_sklearn() - from sklearn.model_selection import train_test_split import sklearn.linear_model as lm + from sklearn.model_selection import train_test_split clf = lm.Ridge() diff --git a/examples/docker/modin-hdk/nyc-taxi-hdk.py b/examples/docker/modin-hdk/nyc-taxi-hdk.py index b13d3ab9b23..bab8ec0f630 100644 --- a/examples/docker/modin-hdk/nyc-taxi-hdk.py +++ b/examples/docker/modin-hdk/nyc-taxi-hdk.py @@ -12,10 +12,12 @@ # governing permissions and limitations under the License. import sys + from utils import measure + import modin.pandas as pd -from modin.pandas.test.utils import df_equals from modin.experimental.sql import query +from modin.pandas.test.utils import df_equals def read(filename): diff --git a/examples/docker/modin-hdk/plasticc-hdk.py b/examples/docker/modin-hdk/plasticc-hdk.py index 9a4632ec1ea..bf39a41d44b 100644 --- a/examples/docker/modin-hdk/plasticc-hdk.py +++ b/examples/docker/modin-hdk/plasticc-hdk.py @@ -14,10 +14,11 @@ import sys from collections import OrderedDict from functools import partial -from utils import measure -import modin.pandas as pd import numpy as np +from utils import measure + +import modin.pandas as pd ################ helper functions ############################### @@ -194,12 +195,11 @@ def etl(df, df_meta): def ml(train_final, test_final): # to not install ML dependencies unless required - import xgboost as xgb import sklearnex + import xgboost as xgb sklearnex.patch_sklearn() - X_train, y_train, X_test, y_test, Xt, classes, class_weights = split_step( train_final, test_final ) diff --git a/examples/docker/modin-hdk/utils.py b/examples/docker/modin-hdk/utils.py index 09aedcdd0ca..08dbe330ab1 100644 --- a/examples/docker/modin-hdk/utils.py +++ b/examples/docker/modin-hdk/utils.py @@ -13,7 +13,7 @@ import sys import time -from os.path import abspath, join, dirname +from os.path import abspath, dirname, join MODIN_DIR = abspath(join(dirname(__file__), *[".." for _ in range(3)])) if MODIN_DIR not in sys.path: diff --git a/examples/docker/modin-ray/census.py b/examples/docker/modin-ray/census.py index 3e066dd8f7e..3f3299e50a0 100644 --- a/examples/docker/modin-ray/census.py +++ b/examples/docker/modin-ray/census.py @@ -13,15 +13,16 @@ import sys import time -import modin.pandas as pd -from sklearn import config_context import sklearnex +from sklearn import config_context + +import modin.pandas as pd sklearnex.patch_sklearn() -from sklearn.model_selection import train_test_split -import sklearn.linear_model as lm import numpy as np +import sklearn.linear_model as lm +from sklearn.model_selection import train_test_split def read(filename): diff --git a/examples/docker/modin-ray/nyc-taxi.py b/examples/docker/modin-ray/nyc-taxi.py index b2479417a18..d56e099bae3 100644 --- a/examples/docker/modin-ray/nyc-taxi.py +++ b/examples/docker/modin-ray/nyc-taxi.py @@ -13,6 +13,7 @@ import sys import time + import modin.pandas as pd @@ -88,14 +89,14 @@ def q2(df): def q3(df): transformed = pd.DataFrame( - { - "pickup_datetime": df["pickup_datetime"].dt.year, - "passenger_count": df["passenger_count"], - } - ) + { + "pickup_datetime": df["pickup_datetime"].dt.year, + "passenger_count": df["passenger_count"], + } + ) return transformed.groupby( - ["pickup_datetime", "passenger_count"], as_index=False - ).size() + ["pickup_datetime", "passenger_count"], as_index=False + ).size() def q4(df): diff --git a/examples/docker/modin-ray/plasticc.py b/examples/docker/modin-ray/plasticc.py index b0a69ca22f9..fc55be84b8a 100644 --- a/examples/docker/modin-ray/plasticc.py +++ b/examples/docker/modin-ray/plasticc.py @@ -15,12 +15,12 @@ import time from collections import OrderedDict from functools import partial -import modin.pandas as pd import numpy as np +import sklearnex import xgboost as xgb -import sklearnex +import modin.pandas as pd sklearnex.patch_sklearn() from sklearn.model_selection import train_test_split diff --git a/examples/tutorial/jupyter/execution/hdk_on_native/test/test_notebooks.py b/examples/tutorial/jupyter/execution/hdk_on_native/test/test_notebooks.py index 01e928f6bb1..c406998ec43 100644 --- a/examples/tutorial/jupyter/execution/hdk_on_native/test/test_notebooks.py +++ b/examples/tutorial/jupyter/execution/hdk_on_native/test/test_notebooks.py @@ -21,8 +21,8 @@ ) sys.path.insert(0, MODIN_DIR) from examples.tutorial.jupyter.execution.test.utils import ( # noqa: E402 - _replace_str, _execute_notebook, + _replace_str, ) local_notebooks_dir = "examples/tutorial/jupyter/execution/hdk_on_native/local" diff --git a/examples/tutorial/jupyter/execution/pandas_on_dask/test/test_notebooks.py b/examples/tutorial/jupyter/execution/pandas_on_dask/test/test_notebooks.py index 9097a03c34e..596deecd4f0 100644 --- a/examples/tutorial/jupyter/execution/pandas_on_dask/test/test_notebooks.py +++ b/examples/tutorial/jupyter/execution/pandas_on_dask/test/test_notebooks.py @@ -21,10 +21,10 @@ ) sys.path.insert(0, MODIN_DIR) from examples.tutorial.jupyter.execution.test.utils import ( # noqa: E402 - _replace_str, _execute_notebook, - test_dataset_path, + _replace_str, download_taxi_dataset, + test_dataset_path, ) local_notebooks_dir = "examples/tutorial/jupyter/execution/pandas_on_dask/local" diff --git a/examples/tutorial/jupyter/execution/pandas_on_ray/test/test_notebooks.py b/examples/tutorial/jupyter/execution/pandas_on_ray/test/test_notebooks.py index fc9b6750b49..aed7198a129 100644 --- a/examples/tutorial/jupyter/execution/pandas_on_ray/test/test_notebooks.py +++ b/examples/tutorial/jupyter/execution/pandas_on_ray/test/test_notebooks.py @@ -21,11 +21,11 @@ ) sys.path.insert(0, MODIN_DIR) from examples.tutorial.jupyter.execution.test.utils import ( # noqa: E402 - _replace_str, _execute_notebook, _find_code_cell_idx, - test_dataset_path, + _replace_str, download_taxi_dataset, + test_dataset_path, ) local_notebooks_dir = "examples/tutorial/jupyter/execution/pandas_on_ray/local" diff --git a/examples/tutorial/jupyter/execution/pandas_on_unidist/setup_kernel.py b/examples/tutorial/jupyter/execution/pandas_on_unidist/setup_kernel.py index 5de280107ca..b54e7b7c2c4 100644 --- a/examples/tutorial/jupyter/execution/pandas_on_unidist/setup_kernel.py +++ b/examples/tutorial/jupyter/execution/pandas_on_unidist/setup_kernel.py @@ -12,8 +12,8 @@ # governing permissions and limitations under the License. import sys -from ipykernel import kernelspec +from ipykernel import kernelspec default_make_ipkernel_cmd = kernelspec.make_ipkernel_cmd diff --git a/examples/tutorial/jupyter/execution/pandas_on_unidist/test/test_notebooks.py b/examples/tutorial/jupyter/execution/pandas_on_unidist/test/test_notebooks.py index b21d58ce804..c382aa5201d 100644 --- a/examples/tutorial/jupyter/execution/pandas_on_unidist/test/test_notebooks.py +++ b/examples/tutorial/jupyter/execution/pandas_on_unidist/test/test_notebooks.py @@ -21,11 +21,11 @@ ) sys.path.insert(0, MODIN_DIR) from examples.tutorial.jupyter.execution.test.utils import ( # noqa: E402 - _replace_str, _execute_notebook, - test_dataset_path, + _replace_str, download_taxi_dataset, set_kernel, + test_dataset_path, ) # the kernel name "python3mpi" must match the one diff --git a/modin/__init__.py b/modin/__init__.py index e40b324afab..8a444639954 100644 --- a/modin/__init__.py +++ b/modin/__init__.py @@ -11,8 +11,8 @@ # ANY KIND, either express or implied. See the License for the specific language # governing permissions and limitations under the License. -from typing import Any, Optional, Tuple, Union, Type, TYPE_CHECKING import warnings +from typing import TYPE_CHECKING, Any, Optional, Tuple, Type, Union if TYPE_CHECKING: from .config import Engine, StorageFormat diff --git a/modin/_version.py b/modin/_version.py index 8f87e3cd59f..631a1b0ac5b 100644 --- a/modin/_version.py +++ b/modin/_version.py @@ -14,7 +14,7 @@ import re import subprocess import sys -from typing import Any, Callable, Dict, Optional, List, Tuple +from typing import Any, Callable, Dict, List, Optional, Tuple VcsPieces = Dict[str, Any] diff --git a/modin/config/__init__.py b/modin/config/__init__.py index 860e7389dab..ea41412cdf3 100644 --- a/modin/config/__init__.py +++ b/modin/config/__init__.py @@ -13,5 +13,5 @@ """Module houses config entities which can be used for Modin behavior tuning.""" -from .pubsub import Parameter # noqa: F401 from .envvars import * # noqa: F403, F401 +from .pubsub import Parameter # noqa: F401 diff --git a/modin/config/__main__.py b/modin/config/__main__.py index d0c1471dc4c..d0c3f72edfc 100644 --- a/modin/config/__main__.py +++ b/modin/config/__main__.py @@ -19,12 +19,13 @@ provided with this flag. """ +import argparse from textwrap import dedent +import pandas + from . import * # noqa: F403, F401 from .pubsub import Parameter -import pandas -import argparse def print_config_help() -> None: diff --git a/modin/config/envvars.py b/modin/config/envvars.py index ea938008b13..c6a344d7374 100644 --- a/modin/config/envvars.py +++ b/modin/config/envvars.py @@ -14,16 +14,16 @@ """Module houses Modin configs originated from environment variables.""" import os +import secrets import sys -from textwrap import dedent import warnings -from packaging import version -import secrets +from textwrap import dedent +from typing import Any, Optional +from packaging import version from pandas.util._decorators import doc # type: ignore[attr-defined] -from .pubsub import Parameter, _TYPE_PARAMS, ExactStr, ValueSource -from typing import Any, Optional +from .pubsub import _TYPE_PARAMS, ExactStr, Parameter, ValueSource class EnvironmentVariable(Parameter, type=str, abstract=True): @@ -94,11 +94,7 @@ def _get_default(cls) -> str: ------- str """ - from modin.utils import ( - MIN_RAY_VERSION, - MIN_DASK_VERSION, - MIN_UNIDIST_VERSION, - ) + from modin.utils import MIN_DASK_VERSION, MIN_RAY_VERSION, MIN_UNIDIST_VERSION # If there's a custom engine, we don't need to check for any engine # dependencies. Return the default "Python" engine. diff --git a/modin/config/test/test_envvars.py b/modin/config/test/test_envvars.py index 0abfa6dc5ac..4528b8cb1d7 100644 --- a/modin/config/test/test_envvars.py +++ b/modin/config/test/test_envvars.py @@ -12,12 +12,13 @@ # governing permissions and limitations under the License. import os -import pytest -import modin.config as cfg -from modin.config.envvars import EnvironmentVariable, _check_vars, ExactStr +import pytest from packaging import version +import modin.config as cfg +from modin.config.envvars import EnvironmentVariable, ExactStr, _check_vars + @pytest.fixture def make_unknown_env(): diff --git a/modin/config/test/test_parameter.py b/modin/config/test/test_parameter.py index 3df4c808221..f2ae02e9fae 100644 --- a/modin/config/test/test_parameter.py +++ b/modin/config/test/test_parameter.py @@ -12,6 +12,7 @@ # governing permissions and limitations under the License. from collections import defaultdict + import pytest from modin.config.pubsub import Parameter diff --git a/modin/conftest.py b/modin/conftest.py index d8ab9859181..87cc0083792 100644 --- a/modin/conftest.py +++ b/modin/conftest.py @@ -14,21 +14,21 @@ # We turn off mypy type checks in this file because it's not imported anywhere # type: ignore -import boto3 -import s3fs import os import platform +import shutil import subprocess +import sys import time +from typing import Optional -import requests -import sys -import pytest +import boto3 +import numpy as np import pandas +import pytest +import requests +import s3fs from pandas.util._decorators import doc -import numpy as np -import shutil -from typing import Optional assert ( "modin.utils" not in sys.modules @@ -50,37 +50,37 @@ def _saving_make_api_url(token, _make_api_url=modin.utils._make_api_url): modin.utils._make_api_url = _saving_make_api_url +import uuid # noqa: E402 + import modin # noqa: E402 import modin.config # noqa: E402 from modin.config import ( # noqa: E402 - NPartitions, - MinPartitionSize, - IsExperimental, - TestRayClient, - GithubCI, - CIAWSAccessKeyID, - CIAWSSecretAccessKey, AsyncReadMode, BenchmarkMode, + CIAWSAccessKeyID, + CIAWSSecretAccessKey, + GithubCI, + IsExperimental, + MinPartitionSize, + NPartitions, + TestRayClient, ) -import uuid # noqa: E402 - -from modin.core.storage_formats import ( # noqa: E402 - PandasQueryCompiler, - BaseQueryCompiler, -) +from modin.core.execution.dispatching.factories import factories # noqa: E402 from modin.core.execution.python.implementations.pandas_on_python.io import ( # noqa: E402 PandasOnPythonIO, ) -from modin.core.execution.dispatching.factories import factories # noqa: E402 -from modin.utils import get_current_execution # noqa: E402 +from modin.core.storage_formats import ( # noqa: E402 + BaseQueryCompiler, + PandasQueryCompiler, +) from modin.pandas.test.utils import ( # noqa: E402 + NROWS, _make_csv_file, get_unique_filename, make_default_file, teardown_test_files, - NROWS, ) +from modin.utils import get_current_execution # noqa: E402 def pytest_addoption(parser): diff --git a/modin/core/dataframe/algebra/__init__.py b/modin/core/dataframe/algebra/__init__.py index 4134ae50f7c..e900ac49a41 100644 --- a/modin/core/dataframe/algebra/__init__.py +++ b/modin/core/dataframe/algebra/__init__.py @@ -13,13 +13,13 @@ """Modin Dataframe algebra (core operators).""" -from .operator import Operator -from .map import Map -from .tree_reduce import TreeReduce -from .reduce import Reduce -from .fold import Fold from .binary import Binary +from .fold import Fold from .groupby import GroupByReduce +from .map import Map +from .operator import Operator +from .reduce import Reduce +from .tree_reduce import TreeReduce __all__ = [ "Operator", diff --git a/modin/core/dataframe/algebra/binary.py b/modin/core/dataframe/algebra/binary.py index a50644ba35f..1dd263167be 100644 --- a/modin/core/dataframe/algebra/binary.py +++ b/modin/core/dataframe/algebra/binary.py @@ -13,14 +13,16 @@ """Module houses builder class for Binary operator.""" +from typing import Optional + import numpy as np import pandas -from pandas.api.types import is_scalar, is_bool_dtype -from typing import Optional +from pandas.api.types import is_bool_dtype, is_scalar -from .operator import Operator from modin.error_message import ErrorMessage +from .operator import Operator + def coerce_int_to_float64(dtype: np.dtype) -> np.dtype: """ diff --git a/modin/core/dataframe/algebra/default2pandas/__init__.py b/modin/core/dataframe/algebra/default2pandas/__init__.py index e753e591064..1d45dab0436 100644 --- a/modin/core/dataframe/algebra/default2pandas/__init__.py +++ b/modin/core/dataframe/algebra/default2pandas/__init__.py @@ -13,16 +13,16 @@ """Module default2pandas provides templates for a query compiler default-to-pandas methods.""" +from .binary import BinaryDefault +from .cat import CatDefault from .dataframe import DataFrameDefault from .datetime import DateTimeDefault -from .series import SeriesDefault -from .str import StrDefault -from .binary import BinaryDefault -from .resample import ResampleDefault -from .rolling import RollingDefault, ExpandingDefault from .default import DefaultMethod -from .cat import CatDefault from .groupby import GroupByDefault, SeriesGroupByDefault +from .resample import ResampleDefault +from .rolling import ExpandingDefault, RollingDefault +from .series import SeriesDefault +from .str import StrDefault __all__ = [ "DataFrameDefault", diff --git a/modin/core/dataframe/algebra/default2pandas/binary.py b/modin/core/dataframe/algebra/default2pandas/binary.py index 9186ec96504..b834e948c8c 100644 --- a/modin/core/dataframe/algebra/default2pandas/binary.py +++ b/modin/core/dataframe/algebra/default2pandas/binary.py @@ -13,11 +13,11 @@ """Module houses default binary functions builder class.""" -from .default import DefaultMethod - import pandas from pandas.core.dtypes.common import is_list_like +from .default import DefaultMethod + class BinaryDefault(DefaultMethod): """Build default-to-pandas methods which executes binary functions.""" diff --git a/modin/core/dataframe/algebra/default2pandas/dataframe.py b/modin/core/dataframe/algebra/default2pandas/dataframe.py index 147111d2a05..7a68dd50d48 100644 --- a/modin/core/dataframe/algebra/default2pandas/dataframe.py +++ b/modin/core/dataframe/algebra/default2pandas/dataframe.py @@ -13,10 +13,11 @@ """Module houses default DataFrame functions builder class.""" -from .default import DefaultMethod +import pandas + from modin.utils import _inherit_docstrings -import pandas +from .default import DefaultMethod @_inherit_docstrings(DefaultMethod) diff --git a/modin/core/dataframe/algebra/default2pandas/default.py b/modin/core/dataframe/algebra/default2pandas/default.py index 5a65ccc01db..01b8d612470 100644 --- a/modin/core/dataframe/algebra/default2pandas/default.py +++ b/modin/core/dataframe/algebra/default2pandas/default.py @@ -13,11 +13,11 @@ """Module houses default functions builder class.""" -from modin.core.dataframe.algebra import Operator -from modin.utils import try_cast_to_pandas, MODIN_UNNAMED_SERIES_LABEL - -from pandas.core.dtypes.common import is_list_like import pandas +from pandas.core.dtypes.common import is_list_like + +from modin.core.dataframe.algebra.operator import Operator +from modin.utils import MODIN_UNNAMED_SERIES_LABEL, try_cast_to_pandas class ObjTypeDeterminer: diff --git a/modin/core/dataframe/algebra/default2pandas/groupby.py b/modin/core/dataframe/algebra/default2pandas/groupby.py index d5bd9cd4a20..59d4d4196aa 100644 --- a/modin/core/dataframe/algebra/default2pandas/groupby.py +++ b/modin/core/dataframe/algebra/default2pandas/groupby.py @@ -13,17 +13,18 @@ """Module houses default GroupBy functions builder class.""" -from .default import DefaultMethod +from typing import Any import pandas from pandas.core.dtypes.common import is_list_like # Defines a set of string names of functions that are executed in a transform-way in groupby from pandas.core.groupby.base import transformation_kernels -from typing import Any from modin.utils import MODIN_UNNAMED_SERIES_LABEL, hashable +from .default import DefaultMethod + # FIXME: there is no sence of keeping `GroupBy` and `GroupByDefault` logic in a different # classes. They should be combined. diff --git a/modin/core/dataframe/algebra/groupby.py b/modin/core/dataframe/algebra/groupby.py index 78cb1ab8365..e4c03feb63f 100644 --- a/modin/core/dataframe/algebra/groupby.py +++ b/modin/core/dataframe/algebra/groupby.py @@ -15,10 +15,11 @@ import pandas -from .tree_reduce import TreeReduce -from .default2pandas.groupby import GroupBy, GroupByDefault -from modin.utils import hashable, MODIN_UNNAMED_SERIES_LABEL from modin.error_message import ErrorMessage +from modin.utils import MODIN_UNNAMED_SERIES_LABEL, hashable + +from .default2pandas.groupby import GroupBy, GroupByDefault +from .tree_reduce import TreeReduce class GroupByReduce(TreeReduce): diff --git a/modin/core/dataframe/base/dataframe/dataframe.py b/modin/core/dataframe/base/dataframe/dataframe.py index 07274788977..4567f23c37e 100644 --- a/modin/core/dataframe/base/dataframe/dataframe.py +++ b/modin/core/dataframe/base/dataframe/dataframe.py @@ -18,7 +18,8 @@ """ from abc import ABC, abstractmethod -from typing import List, Hashable, Optional, Callable, Union, Dict +from typing import Callable, Dict, Hashable, List, Optional, Union + from modin.core.dataframe.base.dataframe.utils import Axis, JoinType diff --git a/modin/core/dataframe/base/dataframe/utils.py b/modin/core/dataframe/base/dataframe/utils.py index e125b739c8e..7a1d5d98dd7 100644 --- a/modin/core/dataframe/base/dataframe/utils.py +++ b/modin/core/dataframe/base/dataframe/utils.py @@ -18,11 +18,12 @@ JoinType is an enum that represents the `join_type` or `how` argument for the join algebra operator. """ +from enum import Enum +from typing import Dict, List, Sequence, Tuple, cast + import pandas -from pandas.api.types import is_scalar from pandas._typing import IndexLabel -from enum import Enum -from typing import cast, Dict, List, Tuple, Sequence +from pandas.api.types import is_scalar class Axis(Enum): # noqa: PR01 diff --git a/modin/core/dataframe/pandas/dataframe/dataframe.py b/modin/core/dataframe/pandas/dataframe/dataframe.py index d81d2b26af4..4bce1769c91 100644 --- a/modin/core/dataframe/pandas/dataframe/dataframe.py +++ b/modin/core/dataframe/pandas/dataframe/dataframe.py @@ -17,40 +17,35 @@ PandasDataframe is a parent abstract class for any dataframe class for pandas storage format. """ +import datetime from collections import OrderedDict +from typing import TYPE_CHECKING, Callable, Dict, Hashable, List, Optional, Union + import numpy as np import pandas -import datetime +from pandas._libs.lib import no_default from pandas.api.types import is_object_dtype +from pandas.core.dtypes.common import is_list_like, is_numeric_dtype from pandas.core.indexes.api import Index, RangeIndex -from pandas.core.dtypes.common import ( - is_numeric_dtype, - is_list_like, -) -from pandas._libs.lib import no_default -from typing import List, Hashable, Optional, Callable, Union, Dict, TYPE_CHECKING from modin.config import Engine, IsRayCluster, NPartitions -from modin.core.storage_formats.pandas.query_compiler import PandasQueryCompiler -from modin.core.storage_formats.pandas.utils import get_length_list -from modin.error_message import ErrorMessage -from modin.core.storage_formats.pandas.parsers import ( - find_common_type_cat as find_common_type, -) from modin.core.dataframe.base.dataframe.dataframe import ModinDataframe -from modin.core.dataframe.base.dataframe.utils import ( - Axis, - JoinType, -) +from modin.core.dataframe.base.dataframe.utils import Axis, JoinType from modin.core.dataframe.pandas.dataframe.utils import ( ShuffleSortFunctions, lazy_metadata_decorator, ) from modin.core.dataframe.pandas.metadata import ( + LazyProxyCategoricalDtype, ModinDtypes, ModinIndex, - LazyProxyCategoricalDtype, ) +from modin.core.storage_formats.pandas.parsers import ( + find_common_type_cat as find_common_type, +) +from modin.core.storage_formats.pandas.query_compiler import PandasQueryCompiler +from modin.core.storage_formats.pandas.utils import get_length_list +from modin.error_message import ErrorMessage if TYPE_CHECKING: from modin.core.dataframe.base.interchange.dataframe_protocol.dataframe import ( @@ -58,9 +53,9 @@ ) from pandas._typing import npt -from modin.pandas.indexing import is_range_like -from modin.pandas.utils import is_full_grab_slice, check_both_not_none from modin.logging import ClassLogger +from modin.pandas.indexing import is_range_like +from modin.pandas.utils import check_both_not_none, is_full_grab_slice from modin.utils import MODIN_UNNAMED_SERIES_LABEL diff --git a/modin/core/dataframe/pandas/dataframe/utils.py b/modin/core/dataframe/pandas/dataframe/utils.py index 7bbc0a2c4c9..eb909159582 100644 --- a/modin/core/dataframe/pandas/dataframe/utils.py +++ b/modin/core/dataframe/pandas/dataframe/utils.py @@ -13,12 +13,13 @@ """Collection of algebra utility functions, used to shuffle data across partitions.""" +import abc +from collections import namedtuple +from typing import TYPE_CHECKING, Callable, Optional, Union + import numpy as np import pandas -from typing import Callable, Union, Optional, TYPE_CHECKING -from collections import namedtuple -from pandas.core.dtypes.common import is_numeric_dtype, is_list_like -import abc +from pandas.core.dtypes.common import is_list_like, is_numeric_dtype from modin.error_message import ErrorMessage from modin.utils import _inherit_docstrings diff --git a/modin/core/dataframe/pandas/interchange/dataframe_protocol/buffer.py b/modin/core/dataframe/pandas/interchange/dataframe_protocol/buffer.py index d99c6623fd8..0db119e2deb 100644 --- a/modin/core/dataframe/pandas/interchange/dataframe_protocol/buffer.py +++ b/modin/core/dataframe/pandas/interchange/dataframe_protocol/buffer.py @@ -26,9 +26,10 @@ """ import enum -import numpy as np from typing import Tuple +import numpy as np + from modin.core.dataframe.base.interchange.dataframe_protocol.dataframe import ( ProtocolBuffer, ) diff --git a/modin/core/dataframe/pandas/interchange/dataframe_protocol/column.py b/modin/core/dataframe/pandas/interchange/dataframe_protocol/column.py index 5cea1043973..e68460ba2b9 100644 --- a/modin/core/dataframe/pandas/interchange/dataframe_protocol/column.py +++ b/modin/core/dataframe/pandas/interchange/dataframe_protocol/column.py @@ -25,24 +25,25 @@ this is worth looking at again. """ -from typing import Any, Optional, Tuple, Dict, Iterable +from typing import Any, Dict, Iterable, Optional, Tuple + import numpy as np import pandas -from modin.utils import _inherit_docstrings from modin.core.dataframe.base.interchange.dataframe_protocol.dataframe import ( CategoricalDescription, ProtocolColumn, ) from modin.core.dataframe.base.interchange.dataframe_protocol.utils import ( + ColumnNullType, DTypeKind, pandas_dtype_to_arrow_c, - ColumnNullType, ) from modin.core.dataframe.pandas.dataframe.dataframe import PandasDataframe -from .buffer import PandasProtocolBuffer -from .exception import NoValidityBuffer, NoOffsetsBuffer +from modin.utils import _inherit_docstrings +from .buffer import PandasProtocolBuffer +from .exception import NoOffsetsBuffer, NoValidityBuffer _NO_VALIDITY_BUFFER = { ColumnNullType.NON_NULLABLE: "This column is non-nullable so does not have a mask", diff --git a/modin/core/dataframe/pandas/interchange/dataframe_protocol/dataframe.py b/modin/core/dataframe/pandas/interchange/dataframe_protocol/dataframe.py index bc1e3d1a0f1..5fb26c50bb1 100644 --- a/modin/core/dataframe/pandas/interchange/dataframe_protocol/dataframe.py +++ b/modin/core/dataframe/pandas/interchange/dataframe_protocol/dataframe.py @@ -26,7 +26,8 @@ """ import collections -from typing import Any, Dict, Optional, Iterable, Sequence +from typing import Any, Dict, Iterable, Optional, Sequence + import numpy as np from modin.core.dataframe.base.interchange.dataframe_protocol.dataframe import ( @@ -34,6 +35,7 @@ ) from modin.core.dataframe.pandas.dataframe.dataframe import PandasDataframe from modin.utils import _inherit_docstrings + from .column import PandasProtocolColumn diff --git a/modin/core/dataframe/pandas/interchange/dataframe_protocol/from_dataframe.py b/modin/core/dataframe/pandas/interchange/dataframe_protocol/from_dataframe.py index 2b312bd77fd..b7cd9ded3e7 100644 --- a/modin/core/dataframe/pandas/interchange/dataframe_protocol/from_dataframe.py +++ b/modin/core/dataframe/pandas/interchange/dataframe_protocol/from_dataframe.py @@ -13,24 +13,24 @@ """Module houses the functions building a ``pandas.DataFrame`` from a DataFrame exchange protocol object.""" -import pandas -import numpy as np import ctypes import re -from typing import Optional, Tuple, Any, Union +from typing import Any, Optional, Tuple, Union + +import numpy as np +import pandas +from modin.core.dataframe.base.interchange.dataframe_protocol.dataframe import ( + ProtocolBuffer, + ProtocolColumn, + ProtocolDataframe, +) from modin.core.dataframe.base.interchange.dataframe_protocol.utils import ( - DTypeKind, - ColumnNullType, ArrowCTypes, + ColumnNullType, + DTypeKind, Endianness, ) -from modin.core.dataframe.base.interchange.dataframe_protocol.dataframe import ( - ProtocolDataframe, - ProtocolColumn, - ProtocolBuffer, -) - np_types_map = { DTypeKind.INT: {8: np.int8, 16: np.int16, 32: np.int32, 64: np.int64}, diff --git a/modin/core/dataframe/pandas/metadata/__init__.py b/modin/core/dataframe/pandas/metadata/__init__.py index 63453c5d307..1836a0d5ffa 100644 --- a/modin/core/dataframe/pandas/metadata/__init__.py +++ b/modin/core/dataframe/pandas/metadata/__init__.py @@ -13,7 +13,7 @@ """Utilities and classes to handle work with metadata.""" +from .dtypes import LazyProxyCategoricalDtype, ModinDtypes from .index import ModinIndex -from .dtypes import ModinDtypes, LazyProxyCategoricalDtype __all__ = ["ModinDtypes", "ModinIndex", "LazyProxyCategoricalDtype"] diff --git a/modin/core/dataframe/pandas/metadata/index.py b/modin/core/dataframe/pandas/metadata/index.py index c41a308dbea..0d4ec3ed28c 100644 --- a/modin/core/dataframe/pandas/metadata/index.py +++ b/modin/core/dataframe/pandas/metadata/index.py @@ -13,10 +13,11 @@ """Module contains class ModinIndex.""" +import functools import uuid + import pandas from pandas.core.indexes.api import ensure_index -import functools class ModinIndex: diff --git a/modin/core/dataframe/pandas/partitioning/axis_partition.py b/modin/core/dataframe/pandas/partitioning/axis_partition.py index cce4f485122..70a20918d6b 100644 --- a/modin/core/dataframe/pandas/partitioning/axis_partition.py +++ b/modin/core/dataframe/pandas/partitioning/axis_partition.py @@ -13,12 +13,14 @@ """The module defines base interface for an axis partition of a Modin DataFrame.""" -import pandas import numpy as np -from modin.core.storage_formats.pandas.utils import split_result_of_axis_func_pandas +import pandas + from modin.core.dataframe.base.partitioning.axis_partition import ( BaseDataframeAxisPartition, ) +from modin.core.storage_formats.pandas.utils import split_result_of_axis_func_pandas + from .partition import PandasDataframePartition diff --git a/modin/core/dataframe/pandas/partitioning/partition.py b/modin/core/dataframe/pandas/partitioning/partition.py index 6f7666212d8..38cc41caee2 100644 --- a/modin/core/dataframe/pandas/partitioning/partition.py +++ b/modin/core/dataframe/pandas/partitioning/partition.py @@ -13,18 +13,18 @@ """The module defines base interface for a partition of a Modin DataFrame.""" -from abc import ABC -from copy import copy import logging import uuid +from abc import ABC +from copy import copy import pandas from pandas.api.types import is_scalar from pandas.util import cache_readonly -from modin.pandas.indexing import compute_sliced_len from modin.core.storage_formats.pandas.utils import length_fn_pandas, width_fn_pandas from modin.logging import get_logger +from modin.pandas.indexing import compute_sliced_len class PandasDataframePartition(ABC): # pragma: no cover diff --git a/modin/core/dataframe/pandas/partitioning/partition_manager.py b/modin/core/dataframe/pandas/partitioning/partition_manager.py index 6256248cecb..da5c8ef89d7 100644 --- a/modin/core/dataframe/pandas/partitioning/partition_manager.py +++ b/modin/core/dataframe/pandas/partitioning/partition_manager.py @@ -17,22 +17,22 @@ The manager also allows manipulating the data - running functions at each partition, shuffle over the distribution, etc. """ +import os +import warnings from abc import ABC from functools import wraps +from typing import TYPE_CHECKING + import numpy as np import pandas from pandas._libs.lib import no_default -import warnings -from typing import TYPE_CHECKING -from modin.error_message import ErrorMessage -from modin.core.storage_formats.pandas.utils import compute_chunksize +from modin.config import BenchmarkMode, NPartitions, ProgressBar from modin.core.dataframe.pandas.utils import concatenate -from modin.config import NPartitions, ProgressBar, BenchmarkMode +from modin.core.storage_formats.pandas.utils import compute_chunksize +from modin.error_message import ErrorMessage from modin.logging import ClassLogger -import os - if TYPE_CHECKING: from modin.core.dataframe.pandas.dataframe.utils import ShuffleFunctions diff --git a/modin/core/execution/dask/common/engine_wrapper.py b/modin/core/execution/dask/common/engine_wrapper.py index ea574a2c6bf..0090db9bb0a 100644 --- a/modin/core/execution/dask/common/engine_wrapper.py +++ b/modin/core/execution/dask/common/engine_wrapper.py @@ -15,8 +15,8 @@ from collections import UserDict -from distributed.client import default_client from dask.distributed import wait +from distributed.client import default_client def _deploy_dask_func(func, *args, **kwargs): # pragma: no cover diff --git a/modin/core/execution/dask/common/utils.py b/modin/core/execution/dask/common/utils.py index 7aac9f35fea..cf544ab343c 100644 --- a/modin/core/execution/dask/common/utils.py +++ b/modin/core/execution/dask/common/utils.py @@ -16,12 +16,12 @@ import os from modin.config import ( + CIAWSAccessKeyID, + CIAWSSecretAccessKey, CpuCount, + GithubCI, Memory, NPartitions, - GithubCI, - CIAWSAccessKeyID, - CIAWSSecretAccessKey, ) from modin.error_message import ErrorMessage diff --git a/modin/core/execution/dask/implementations/pandas_on_dask/dataframe/dataframe.py b/modin/core/execution/dask/implementations/pandas_on_dask/dataframe/dataframe.py index 78c40853ec1..77c78e29f9c 100644 --- a/modin/core/execution/dask/implementations/pandas_on_dask/dataframe/dataframe.py +++ b/modin/core/execution/dask/implementations/pandas_on_dask/dataframe/dataframe.py @@ -14,6 +14,7 @@ """Module houses class that implements ``PandasDataframe``.""" from modin.core.dataframe.pandas.dataframe.dataframe import PandasDataframe + from ..partitioning.partition_manager import PandasOnDaskDataframePartitionManager diff --git a/modin/core/execution/dask/implementations/pandas_on_dask/io/io.py b/modin/core/execution/dask/implementations/pandas_on_dask/io/io.py index be0da2cb5d8..23cf38d9301 100644 --- a/modin/core/execution/dask/implementations/pandas_on_dask/io/io.py +++ b/modin/core/execution/dask/implementations/pandas_on_dask/io/io.py @@ -13,8 +13,7 @@ """Module houses class that implements ``BaseIO`` using Dask as an execution engine.""" -from modin.core.io import BaseIO -from modin.core.storage_formats.pandas.query_compiler import PandasQueryCompiler +from modin.core.execution.dask.common import DaskWrapper from modin.core.execution.dask.implementations.pandas_on_dask.dataframe import ( PandasOnDaskDataframe, ) @@ -22,24 +21,25 @@ PandasOnDaskDataframePartition, ) from modin.core.io import ( + BaseIO, CSVDispatcher, + ExcelDispatcher, + FeatherDispatcher, FWFDispatcher, JSONDispatcher, ParquetDispatcher, - FeatherDispatcher, SQLDispatcher, - ExcelDispatcher, ) from modin.core.storage_formats.pandas.parsers import ( PandasCSVParser, + PandasExcelParser, + PandasFeatherParser, PandasFWFParser, PandasJSONParser, PandasParquetParser, - PandasFeatherParser, PandasSQLParser, - PandasExcelParser, ) -from modin.core.execution.dask.common import DaskWrapper +from modin.core.storage_formats.pandas.query_compiler import PandasQueryCompiler class PandasOnDaskIO(BaseIO): diff --git a/modin/core/execution/dask/implementations/pandas_on_dask/partitioning/__init__.py b/modin/core/execution/dask/implementations/pandas_on_dask/partitioning/__init__.py index 34f8ccac30f..1dcafe8af3f 100644 --- a/modin/core/execution/dask/implementations/pandas_on_dask/partitioning/__init__.py +++ b/modin/core/execution/dask/implementations/pandas_on_dask/partitioning/__init__.py @@ -16,9 +16,9 @@ from .partition import PandasOnDaskDataframePartition from .partition_manager import PandasOnDaskDataframePartitionManager from .virtual_partition import ( - PandasOnDaskDataframeVirtualPartition, PandasOnDaskDataframeColumnPartition, PandasOnDaskDataframeRowPartition, + PandasOnDaskDataframeVirtualPartition, ) __all__ = [ diff --git a/modin/core/execution/dask/implementations/pandas_on_dask/partitioning/partition.py b/modin/core/execution/dask/implementations/pandas_on_dask/partitioning/partition.py index 6173da7b94b..47bc97c3e6c 100644 --- a/modin/core/execution/dask/implementations/pandas_on_dask/partitioning/partition.py +++ b/modin/core/execution/dask/implementations/pandas_on_dask/partitioning/partition.py @@ -17,9 +17,9 @@ from distributed.utils import get_ip from modin.core.dataframe.pandas.partitioning.partition import PandasDataframePartition -from modin.pandas.indexing import compute_sliced_len -from modin.logging import get_logger from modin.core.execution.dask.common import DaskWrapper +from modin.logging import get_logger +from modin.pandas.indexing import compute_sliced_len class PandasOnDaskDataframePartition(PandasDataframePartition): diff --git a/modin/core/execution/dask/implementations/pandas_on_dask/partitioning/partition_manager.py b/modin/core/execution/dask/implementations/pandas_on_dask/partitioning/partition_manager.py index aa3e6a4864a..f045c6ef392 100644 --- a/modin/core/execution/dask/implementations/pandas_on_dask/partitioning/partition_manager.py +++ b/modin/core/execution/dask/implementations/pandas_on_dask/partitioning/partition_manager.py @@ -17,11 +17,12 @@ PandasDataframePartitionManager, ) from modin.core.execution.dask.common import DaskWrapper + +from .partition import PandasOnDaskDataframePartition from .virtual_partition import ( PandasOnDaskDataframeColumnPartition, PandasOnDaskDataframeRowPartition, ) -from .partition import PandasOnDaskDataframePartition class PandasOnDaskDataframePartitionManager(PandasDataframePartitionManager): diff --git a/modin/core/execution/dask/implementations/pandas_on_dask/partitioning/virtual_partition.py b/modin/core/execution/dask/implementations/pandas_on_dask/partitioning/virtual_partition.py index 2eb7687608a..e8f7b6465c6 100644 --- a/modin/core/execution/dask/implementations/pandas_on_dask/partitioning/virtual_partition.py +++ b/modin/core/execution/dask/implementations/pandas_on_dask/partitioning/virtual_partition.py @@ -13,18 +13,18 @@ """Module houses classes responsible for storing a virtual partition and applying a function to it.""" +import pandas from distributed import Future from distributed.utils import get_ip -import pandas - from modin.core.dataframe.pandas.partitioning.axis_partition import ( PandasDataframeAxisPartition, ) -from .partition import PandasOnDaskDataframePartition from modin.core.execution.dask.common import DaskWrapper from modin.utils import _inherit_docstrings +from .partition import PandasOnDaskDataframePartition + class PandasOnDaskDataframeVirtualPartition(PandasDataframeAxisPartition): """ diff --git a/modin/core/execution/dispatching/factories/dispatcher.py b/modin/core/execution/dispatching/factories/dispatcher.py index 24b50a82c44..623bf354d45 100644 --- a/modin/core/execution/dispatching/factories/dispatcher.py +++ b/modin/core/execution/dispatching/factories/dispatcher.py @@ -17,9 +17,9 @@ Dispatcher routes the work to execution-specific functions. """ -from modin.config import Engine, StorageFormat, IsExperimental +from modin.config import Engine, IsExperimental, StorageFormat from modin.core.execution.dispatching.factories import factories -from modin.utils import get_current_execution, _inherit_docstrings +from modin.utils import _inherit_docstrings, get_current_execution class FactoryNotFoundError(AttributeError): diff --git a/modin/core/execution/dispatching/factories/factories.py b/modin/core/execution/dispatching/factories/factories.py index a8b1011d12c..56f6d2c6dbf 100644 --- a/modin/core/execution/dispatching/factories/factories.py +++ b/modin/core/execution/dispatching/factories/factories.py @@ -19,17 +19,16 @@ with a Factory class. """ -import warnings -import typing import re - -from modin.config import Engine -from modin.utils import _inherit_docstrings, get_current_execution -from modin.core.io import BaseIO -from pandas.util._decorators import doc +import typing +import warnings import pandas +from pandas.util._decorators import doc +from modin.config import Engine +from modin.core.io import BaseIO +from modin.utils import _inherit_docstrings, get_current_execution _doc_abstract_factory_class = """ Abstract {role} factory which allows to override the IO module easily. diff --git a/modin/core/execution/dispatching/factories/test/test_dispatcher.py b/modin/core/execution/dispatching/factories/test/test_dispatcher.py index e652c29d95a..28bb2b95b4d 100644 --- a/modin/core/execution/dispatching/factories/test/test_dispatcher.py +++ b/modin/core/execution/dispatching/factories/test/test_dispatcher.py @@ -11,24 +11,23 @@ # ANY KIND, either express or implied. See the License for the specific language # governing permissions and limitations under the License. -import pytest from contextlib import contextmanager -from modin.config import Parameter, Engine, StorageFormat -from modin import set_execution +import pytest +import modin.pandas as pd +from modin import set_execution +from modin.config import Engine, Parameter, StorageFormat +from modin.core.execution.dispatching.factories import factories from modin.core.execution.dispatching.factories.dispatcher import ( FactoryDispatcher, FactoryNotFoundError, ) -from modin.core.execution.dispatching.factories import factories from modin.core.execution.python.implementations.pandas_on_python.io import ( PandasOnPythonIO, ) from modin.core.storage_formats.pandas.query_compiler import PandasQueryCompiler -import modin.pandas as pd - @contextmanager def _switch_execution(engine: str, storage_format: str): diff --git a/modin/core/execution/modin_aqp.py b/modin/core/execution/modin_aqp.py index 36024577b6b..f1ba7bdceea 100644 --- a/modin/core/execution/modin_aqp.py +++ b/modin/core/execution/modin_aqp.py @@ -17,15 +17,14 @@ Modin Automatic Query Progress (AQP). """ -import os -import time import inspect +import os import threading +import time import warnings from modin.config import Engine, ProgressBar - progress_bars = {} bar_lock = threading.Lock() diff --git a/modin/core/execution/python/implementations/pandas_on_python/dataframe/dataframe.py b/modin/core/execution/python/implementations/pandas_on_python/dataframe/dataframe.py index 7380e75bcdd..6e314beaa9c 100644 --- a/modin/core/execution/python/implementations/pandas_on_python/dataframe/dataframe.py +++ b/modin/core/execution/python/implementations/pandas_on_python/dataframe/dataframe.py @@ -18,6 +18,7 @@ """ from modin.core.dataframe.pandas.dataframe.dataframe import PandasDataframe + from ..partitioning.partition_manager import PandasOnPythonDataframePartitionManager diff --git a/modin/core/execution/python/implementations/pandas_on_python/io/io.py b/modin/core/execution/python/implementations/pandas_on_python/io/io.py index 1f22fecb99c..0dba2033460 100644 --- a/modin/core/execution/python/implementations/pandas_on_python/io/io.py +++ b/modin/core/execution/python/implementations/pandas_on_python/io/io.py @@ -13,11 +13,11 @@ """Module for housing IO classes with pandas storage format and Python engine.""" -from modin.core.io import BaseIO -from modin.core.storage_formats.pandas.query_compiler import PandasQueryCompiler from modin.core.execution.python.implementations.pandas_on_python.dataframe.dataframe import ( PandasOnPythonDataframe, ) +from modin.core.io import BaseIO +from modin.core.storage_formats.pandas.query_compiler import PandasQueryCompiler class PandasOnPythonIO(BaseIO): diff --git a/modin/core/execution/python/implementations/pandas_on_python/partitioning/partition_manager.py b/modin/core/execution/python/implementations/pandas_on_python/partitioning/partition_manager.py index 633046100c2..b8facf9a8c1 100644 --- a/modin/core/execution/python/implementations/pandas_on_python/partitioning/partition_manager.py +++ b/modin/core/execution/python/implementations/pandas_on_python/partitioning/partition_manager.py @@ -16,12 +16,13 @@ from modin.core.dataframe.pandas.partitioning.partition_manager import ( PandasDataframePartitionManager, ) +from modin.core.execution.python.common import PythonWrapper + +from .partition import PandasOnPythonDataframePartition from .virtual_partition import ( PandasOnPythonDataframeColumnPartition, PandasOnPythonDataframeRowPartition, ) -from .partition import PandasOnPythonDataframePartition -from modin.core.execution.python.common import PythonWrapper class PandasOnPythonDataframePartitionManager(PandasDataframePartitionManager): diff --git a/modin/core/execution/python/implementations/pandas_on_python/partitioning/virtual_partition.py b/modin/core/execution/python/implementations/pandas_on_python/partitioning/virtual_partition.py index 1a5984fd9be..1204151742f 100644 --- a/modin/core/execution/python/implementations/pandas_on_python/partitioning/virtual_partition.py +++ b/modin/core/execution/python/implementations/pandas_on_python/partitioning/virtual_partition.py @@ -18,9 +18,10 @@ from modin.core.dataframe.pandas.partitioning.axis_partition import ( PandasDataframeAxisPartition, ) -from .partition import PandasOnPythonDataframePartition from modin.utils import _inherit_docstrings +from .partition import PandasOnPythonDataframePartition + class PandasOnPythonDataframeAxisPartition(PandasDataframeAxisPartition): """ diff --git a/modin/core/execution/ray/common/utils.py b/modin/core/execution/ray/common/utils.py index 845159eb0ae..9473ec0fa97 100644 --- a/modin/core/execution/ray/common/utils.py +++ b/modin/core/execution/ray/common/utils.py @@ -15,28 +15,29 @@ import os import sys -import psutil -from packaging import version -from typing import Optional import warnings +from typing import Optional +import psutil import ray +from packaging import version from modin.config import ( - StorageFormat, - IsRayCluster, - RayRedisAddress, - RayRedisPassword, + CIAWSAccessKeyID, + CIAWSSecretAccessKey, CpuCount, + GithubCI, GpuCount, + IsRayCluster, Memory, NPartitions, + RayRedisAddress, + RayRedisPassword, + StorageFormat, ValueSource, - GithubCI, - CIAWSSecretAccessKey, - CIAWSAccessKeyID, ) from modin.error_message import ErrorMessage + from .engine_wrapper import RayWrapper _OBJECT_STORE_TO_SYSTEM_MEMORY_RATIO = 0.6 @@ -148,8 +149,8 @@ def initialize_ray( if StorageFormat.get() == "Cudf": from modin.core.execution.ray.implementations.cudf_on_ray.partitioning import ( - GPUManager, GPU_MANAGERS, + GPUManager, ) # Check that GPU_MANAGERS is empty because _update_engine can be called multiple times diff --git a/modin/core/execution/ray/implementations/cudf_on_ray/dataframe/dataframe.py b/modin/core/execution/ray/implementations/cudf_on_ray/dataframe/dataframe.py index 95d9b005ede..a40d10cd4c1 100644 --- a/modin/core/execution/ray/implementations/cudf_on_ray/dataframe/dataframe.py +++ b/modin/core/execution/ray/implementations/cudf_on_ray/dataframe/dataframe.py @@ -13,16 +13,17 @@ """Module houses class that implements ``PandasOnRayDataframe`` class using cuDF.""" -from typing import List, Hashable, Optional +from typing import Hashable, List, Optional import numpy as np -from modin.error_message import ErrorMessage -from modin.pandas.utils import check_both_not_none +from modin.core.execution.ray.common import RayWrapper from modin.core.execution.ray.implementations.pandas_on_ray.dataframe import ( PandasOnRayDataframe, ) -from modin.core.execution.ray.common import RayWrapper +from modin.error_message import ErrorMessage +from modin.pandas.utils import check_both_not_none + from ..partitioning import ( cuDFOnRayDataframePartition, cuDFOnRayDataframePartitionManager, diff --git a/modin/core/execution/ray/implementations/cudf_on_ray/io/__init__.py b/modin/core/execution/ray/implementations/cudf_on_ray/io/__init__.py index 911bce5451c..42b06aea72e 100644 --- a/modin/core/execution/ray/implementations/cudf_on_ray/io/__init__.py +++ b/modin/core/execution/ray/implementations/cudf_on_ray/io/__init__.py @@ -15,7 +15,6 @@ from .io import cuDFOnRayIO - __all__ = [ "cuDFOnRayIO", ] diff --git a/modin/core/execution/ray/implementations/cudf_on_ray/io/io.py b/modin/core/execution/ray/implementations/cudf_on_ray/io/io.py index 50372fb1da4..d0164081a96 100644 --- a/modin/core/execution/ray/implementations/cudf_on_ray/io/io.py +++ b/modin/core/execution/ray/implementations/cudf_on_ray/io/io.py @@ -13,10 +13,11 @@ """Module holds implementation of ``BaseIO`` using cuDF.""" +from modin.core.execution.ray.common import RayWrapper from modin.core.io import BaseIO -from modin.core.storage_formats.cudf.query_compiler import cuDFQueryCompiler from modin.core.storage_formats.cudf.parser import cuDFCSVParser -from modin.core.execution.ray.common import RayWrapper +from modin.core.storage_formats.cudf.query_compiler import cuDFQueryCompiler + from ..dataframe import cuDFOnRayDataframe from ..partitioning import ( cuDFOnRayDataframePartition, diff --git a/modin/core/execution/ray/implementations/cudf_on_ray/io/text/__init__.py b/modin/core/execution/ray/implementations/cudf_on_ray/io/text/__init__.py index 61584b1f5e1..9b7c977ec42 100644 --- a/modin/core/execution/ray/implementations/cudf_on_ray/io/text/__init__.py +++ b/modin/core/execution/ray/implementations/cudf_on_ray/io/text/__init__.py @@ -15,7 +15,6 @@ from .csv_dispatcher import cuDFCSVDispatcher - __all__ = [ "cuDFCSVDispatcher", ] diff --git a/modin/core/execution/ray/implementations/cudf_on_ray/io/text/csv_dispatcher.py b/modin/core/execution/ray/implementations/cudf_on_ray/io/text/csv_dispatcher.py index 68e7f5b2a00..6593eb89024 100644 --- a/modin/core/execution/ray/implementations/cudf_on_ray/io/text/csv_dispatcher.py +++ b/modin/core/execution/ray/implementations/cudf_on_ray/io/text/csv_dispatcher.py @@ -13,13 +13,14 @@ """Module holds ``cuDFCSVDispatcher`` that is implemented using cuDF-entities.""" +from typing import Tuple + import numpy as np -from modin.core.io import CSVDispatcher from modin.core.execution.ray.implementations.cudf_on_ray.partitioning.partition_manager import ( GPU_MANAGERS, ) -from typing import Tuple +from modin.core.io import CSVDispatcher class cuDFCSVDispatcher(CSVDispatcher): diff --git a/modin/core/execution/ray/implementations/cudf_on_ray/partitioning/__init__.py b/modin/core/execution/ray/implementations/cudf_on_ray/partitioning/__init__.py index ae715c9fc2f..03fa3f6f35c 100644 --- a/modin/core/execution/ray/implementations/cudf_on_ray/partitioning/__init__.py +++ b/modin/core/execution/ray/implementations/cudf_on_ray/partitioning/__init__.py @@ -14,8 +14,8 @@ """Base Modin Dataframe classes related to its partitioning and optimized for cuDF on Ray execution.""" from .gpu_manager import GPUManager -from .partition_manager import cuDFOnRayDataframePartitionManager, GPU_MANAGERS from .partition import cuDFOnRayDataframePartition +from .partition_manager import GPU_MANAGERS, cuDFOnRayDataframePartitionManager __all__ = [ "GPUManager", diff --git a/modin/core/execution/ray/implementations/cudf_on_ray/partitioning/axis_partition.py b/modin/core/execution/ray/implementations/cudf_on_ray/partitioning/axis_partition.py index 08fe33c7502..b5f284fe46b 100644 --- a/modin/core/execution/ray/implementations/cudf_on_ray/partitioning/axis_partition.py +++ b/modin/core/execution/ray/implementations/cudf_on_ray/partitioning/axis_partition.py @@ -16,6 +16,7 @@ import cudf from modin.core.execution.ray.common import RayWrapper + from .partition import cuDFOnRayDataframePartition diff --git a/modin/core/execution/ray/implementations/cudf_on_ray/partitioning/gpu_manager.py b/modin/core/execution/ray/implementations/cudf_on_ray/partitioning/gpu_manager.py index 782f466a2e7..3470173eb1d 100644 --- a/modin/core/execution/ray/implementations/cudf_on_ray/partitioning/gpu_manager.py +++ b/modin/core/execution/ray/implementations/cudf_on_ray/partitioning/gpu_manager.py @@ -13,9 +13,9 @@ """Module holds Ray actor-class that stores ``cudf.DataFrame``s.""" -import ray import cudf import pandas +import ray from modin.core.execution.ray.common import RayWrapper diff --git a/modin/core/execution/ray/implementations/cudf_on_ray/partitioning/partition.py b/modin/core/execution/ray/implementations/cudf_on_ray/partitioning/partition.py index 75dd47f0ba9..42b22efbdaf 100644 --- a/modin/core/execution/ray/implementations/cudf_on_ray/partitioning/partition.py +++ b/modin/core/execution/ray/implementations/cudf_on_ray/partitioning/partition.py @@ -13,14 +13,14 @@ """Module houses class that wraps data (block partition) and its metadata.""" -import ray import cudf import cupy -import numpy as np import cupy as cp +import numpy as np +import ray +from pandas.core.dtypes.common import is_list_like from modin.core.dataframe.pandas.partitioning.partition import PandasDataframePartition -from pandas.core.dtypes.common import is_list_like from modin.core.execution.ray.common import RayWrapper from modin.core.execution.ray.common.utils import ObjectIDType diff --git a/modin/core/execution/ray/implementations/cudf_on_ray/partitioning/partition_manager.py b/modin/core/execution/ray/implementations/cudf_on_ray/partitioning/partition_manager.py index b558ece716f..038ff0c96c8 100644 --- a/modin/core/execution/ray/implementations/cudf_on_ray/partitioning/partition_manager.py +++ b/modin/core/execution/ray/implementations/cudf_on_ray/partitioning/partition_manager.py @@ -16,17 +16,18 @@ import numpy as np import ray +from modin.config import GpuCount +from modin.core.execution.ray.common import RayWrapper +from modin.core.execution.ray.generic.partitioning import ( + GenericRayDataframePartitionManager, +) +from modin.core.storage_formats.pandas.utils import split_result_of_axis_func_pandas + from .axis_partition import ( cuDFOnRayDataframeColumnPartition, cuDFOnRayDataframeRowPartition, ) from .partition import cuDFOnRayDataframePartition -from modin.core.storage_formats.pandas.utils import split_result_of_axis_func_pandas -from modin.config import GpuCount -from modin.core.execution.ray.generic.partitioning import ( - GenericRayDataframePartitionManager, -) -from modin.core.execution.ray.common import RayWrapper # Global view of GPU Actors GPU_MANAGERS = [] diff --git a/modin/core/execution/ray/implementations/pandas_on_ray/dataframe/dataframe.py b/modin/core/execution/ray/implementations/pandas_on_ray/dataframe/dataframe.py index 9070caa078c..3a6704523c7 100644 --- a/modin/core/execution/ray/implementations/pandas_on_ray/dataframe/dataframe.py +++ b/modin/core/execution/ray/implementations/pandas_on_ray/dataframe/dataframe.py @@ -13,9 +13,10 @@ """Module houses class that implements ``PandasDataframe`` using Ray.""" -from ..partitioning.partition_manager import PandasOnRayDataframePartitionManager from modin.core.dataframe.pandas.dataframe.dataframe import PandasDataframe +from ..partitioning.partition_manager import PandasOnRayDataframePartitionManager + class PandasOnRayDataframe(PandasDataframe): """ diff --git a/modin/core/execution/ray/implementations/pandas_on_ray/io/io.py b/modin/core/execution/ray/implementations/pandas_on_ray/io/io.py index c467954ecb3..c6409bd3e17 100644 --- a/modin/core/execution/ray/implementations/pandas_on_ray/io/io.py +++ b/modin/core/execution/ray/implementations/pandas_on_ray/io/io.py @@ -18,27 +18,28 @@ import pandas from pandas.io.common import get_handle -from modin.core.storage_formats.pandas.query_compiler import PandasQueryCompiler +from modin.core.execution.ray.common import RayWrapper, SignalActor from modin.core.execution.ray.generic.io import RayIO from modin.core.io import ( CSVDispatcher, + ExcelDispatcher, + FeatherDispatcher, FWFDispatcher, JSONDispatcher, ParquetDispatcher, - FeatherDispatcher, SQLDispatcher, - ExcelDispatcher, ) from modin.core.storage_formats.pandas.parsers import ( PandasCSVParser, + PandasExcelParser, + PandasFeatherParser, PandasFWFParser, PandasJSONParser, PandasParquetParser, - PandasFeatherParser, PandasSQLParser, - PandasExcelParser, ) -from modin.core.execution.ray.common import RayWrapper, SignalActor +from modin.core.storage_formats.pandas.query_compiler import PandasQueryCompiler + from ..dataframe import PandasOnRayDataframe from ..partitioning import PandasOnRayDataframePartition diff --git a/modin/core/execution/ray/implementations/pandas_on_ray/partitioning/__init__.py b/modin/core/execution/ray/implementations/pandas_on_ray/partitioning/__init__.py index 33cff64148f..93521e38094 100644 --- a/modin/core/execution/ray/implementations/pandas_on_ray/partitioning/__init__.py +++ b/modin/core/execution/ray/implementations/pandas_on_ray/partitioning/__init__.py @@ -16,9 +16,9 @@ from .partition import PandasOnRayDataframePartition from .partition_manager import PandasOnRayDataframePartitionManager from .virtual_partition import ( - PandasOnRayDataframeVirtualPartition, PandasOnRayDataframeColumnPartition, PandasOnRayDataframeRowPartition, + PandasOnRayDataframeVirtualPartition, ) __all__ = [ diff --git a/modin/core/execution/ray/implementations/pandas_on_ray/partitioning/partition.py b/modin/core/execution/ray/implementations/pandas_on_ray/partitioning/partition.py index a7caabf2f9d..380095f7fbc 100644 --- a/modin/core/execution/ray/implementations/pandas_on_ray/partitioning/partition.py +++ b/modin/core/execution/ray/implementations/pandas_on_ray/partitioning/partition.py @@ -16,11 +16,11 @@ import ray from ray.util import get_node_ip_address -from modin.core.execution.ray.common.utils import deserialize, ObjectIDType -from modin.core.execution.ray.common import RayWrapper from modin.core.dataframe.pandas.partitioning.partition import PandasDataframePartition -from modin.pandas.indexing import compute_sliced_len +from modin.core.execution.ray.common import RayWrapper +from modin.core.execution.ray.common.utils import ObjectIDType, deserialize from modin.logging import get_logger +from modin.pandas.indexing import compute_sliced_len compute_sliced_len = ray.remote(compute_sliced_len) diff --git a/modin/core/execution/ray/implementations/pandas_on_ray/partitioning/partition_manager.py b/modin/core/execution/ray/implementations/pandas_on_ray/partitioning/partition_manager.py index 81ba0148bcf..26164984fa7 100644 --- a/modin/core/execution/ray/implementations/pandas_on_ray/partitioning/partition_manager.py +++ b/modin/core/execution/ray/implementations/pandas_on_ray/partitioning/partition_manager.py @@ -13,16 +13,17 @@ """Module houses class that implements ``GenericRayDataframePartitionManager`` using Ray.""" +from modin.core.execution.modin_aqp import progress_bar_wrapper +from modin.core.execution.ray.common import RayWrapper from modin.core.execution.ray.generic.partitioning import ( GenericRayDataframePartitionManager, ) -from modin.core.execution.ray.common import RayWrapper + +from .partition import PandasOnRayDataframePartition from .virtual_partition import ( PandasOnRayDataframeColumnPartition, PandasOnRayDataframeRowPartition, ) -from .partition import PandasOnRayDataframePartition -from modin.core.execution.modin_aqp import progress_bar_wrapper class PandasOnRayDataframePartitionManager(GenericRayDataframePartitionManager): diff --git a/modin/core/execution/ray/implementations/pandas_on_ray/partitioning/virtual_partition.py b/modin/core/execution/ray/implementations/pandas_on_ray/partitioning/virtual_partition.py index f81290faf24..c04680a1a36 100644 --- a/modin/core/execution/ray/implementations/pandas_on_ray/partitioning/virtual_partition.py +++ b/modin/core/execution/ray/implementations/pandas_on_ray/partitioning/virtual_partition.py @@ -20,11 +20,12 @@ from modin.core.dataframe.pandas.partitioning.axis_partition import ( PandasDataframeAxisPartition, ) -from modin.core.execution.ray.common.utils import deserialize from modin.core.execution.ray.common import RayWrapper -from .partition import PandasOnRayDataframePartition +from modin.core.execution.ray.common.utils import deserialize from modin.utils import _inherit_docstrings +from .partition import PandasOnRayDataframePartition + class PandasOnRayDataframeVirtualPartition(PandasDataframeAxisPartition): """ diff --git a/modin/core/execution/unidist/common/__init__.py b/modin/core/execution/unidist/common/__init__.py index 2a8131a46d5..5b97d14e462 100644 --- a/modin/core/execution/unidist/common/__init__.py +++ b/modin/core/execution/unidist/common/__init__.py @@ -13,7 +13,7 @@ """Common utilities for unidist execution engine.""" -from .engine_wrapper import UnidistWrapper, SignalActor +from .engine_wrapper import SignalActor, UnidistWrapper from .utils import initialize_unidist __all__ = [ diff --git a/modin/core/execution/unidist/common/engine_wrapper.py b/modin/core/execution/unidist/common/engine_wrapper.py index 939563ecda9..298cb552058 100644 --- a/modin/core/execution/unidist/common/engine_wrapper.py +++ b/modin/core/execution/unidist/common/engine_wrapper.py @@ -18,6 +18,7 @@ """ import asyncio + import unidist diff --git a/modin/core/execution/unidist/common/utils.py b/modin/core/execution/unidist/common/utils.py index c2705cfeaa2..7de30d560af 100644 --- a/modin/core/execution/unidist/common/utils.py +++ b/modin/core/execution/unidist/common/utils.py @@ -18,6 +18,7 @@ import modin.config as modin_cfg from modin.error_message import ErrorMessage + from .engine_wrapper import UnidistWrapper diff --git a/modin/core/execution/unidist/implementations/pandas_on_unidist/dataframe/dataframe.py b/modin/core/execution/unidist/implementations/pandas_on_unidist/dataframe/dataframe.py index 66323820506..17e58d76b13 100644 --- a/modin/core/execution/unidist/implementations/pandas_on_unidist/dataframe/dataframe.py +++ b/modin/core/execution/unidist/implementations/pandas_on_unidist/dataframe/dataframe.py @@ -13,9 +13,10 @@ """Module houses class that implements ``PandasDataframe`` using unidist.""" -from ..partitioning.partition_manager import PandasOnUnidistDataframePartitionManager from modin.core.dataframe.pandas.dataframe.dataframe import PandasDataframe +from ..partitioning.partition_manager import PandasOnUnidistDataframePartitionManager + class PandasOnUnidistDataframe(PandasDataframe): """ diff --git a/modin/core/execution/unidist/implementations/pandas_on_unidist/io/io.py b/modin/core/execution/unidist/implementations/pandas_on_unidist/io/io.py index 2e4d8c58041..df90e6ac068 100644 --- a/modin/core/execution/unidist/implementations/pandas_on_unidist/io/io.py +++ b/modin/core/execution/unidist/implementations/pandas_on_unidist/io/io.py @@ -17,27 +17,28 @@ import pandas -from modin.core.storage_formats.pandas.query_compiler import PandasQueryCompiler +from modin.core.execution.unidist.common import SignalActor, UnidistWrapper from modin.core.execution.unidist.generic.io import UnidistIO from modin.core.io import ( CSVDispatcher, + ExcelDispatcher, + FeatherDispatcher, FWFDispatcher, JSONDispatcher, ParquetDispatcher, - FeatherDispatcher, SQLDispatcher, - ExcelDispatcher, ) from modin.core.storage_formats.pandas.parsers import ( PandasCSVParser, + PandasExcelParser, + PandasFeatherParser, PandasFWFParser, PandasJSONParser, PandasParquetParser, - PandasFeatherParser, PandasSQLParser, - PandasExcelParser, ) -from modin.core.execution.unidist.common import UnidistWrapper, SignalActor +from modin.core.storage_formats.pandas.query_compiler import PandasQueryCompiler + from ..dataframe import PandasOnUnidistDataframe from ..partitioning import PandasOnUnidistDataframePartition diff --git a/modin/core/execution/unidist/implementations/pandas_on_unidist/partitioning/__init__.py b/modin/core/execution/unidist/implementations/pandas_on_unidist/partitioning/__init__.py index 0cb9a4e092b..87a81ca4964 100644 --- a/modin/core/execution/unidist/implementations/pandas_on_unidist/partitioning/__init__.py +++ b/modin/core/execution/unidist/implementations/pandas_on_unidist/partitioning/__init__.py @@ -16,9 +16,9 @@ from .partition import PandasOnUnidistDataframePartition from .partition_manager import PandasOnUnidistDataframePartitionManager from .virtual_partition import ( - PandasOnUnidistDataframeVirtualPartition, PandasOnUnidistDataframeColumnPartition, PandasOnUnidistDataframeRowPartition, + PandasOnUnidistDataframeVirtualPartition, ) __all__ = [ diff --git a/modin/core/execution/unidist/implementations/pandas_on_unidist/partitioning/partition.py b/modin/core/execution/unidist/implementations/pandas_on_unidist/partitioning/partition.py index fa1c71993cd..05c0538be4f 100644 --- a/modin/core/execution/unidist/implementations/pandas_on_unidist/partitioning/partition.py +++ b/modin/core/execution/unidist/implementations/pandas_on_unidist/partitioning/partition.py @@ -15,11 +15,11 @@ import unidist +from modin.core.dataframe.pandas.partitioning.partition import PandasDataframePartition from modin.core.execution.unidist.common import UnidistWrapper from modin.core.execution.unidist.common.utils import deserialize -from modin.core.dataframe.pandas.partitioning.partition import PandasDataframePartition -from modin.pandas.indexing import compute_sliced_len from modin.logging import get_logger +from modin.pandas.indexing import compute_sliced_len compute_sliced_len = unidist.remote(compute_sliced_len) diff --git a/modin/core/execution/unidist/implementations/pandas_on_unidist/partitioning/partition_manager.py b/modin/core/execution/unidist/implementations/pandas_on_unidist/partitioning/partition_manager.py index bee90aface1..181de9f3f72 100644 --- a/modin/core/execution/unidist/implementations/pandas_on_unidist/partitioning/partition_manager.py +++ b/modin/core/execution/unidist/implementations/pandas_on_unidist/partitioning/partition_manager.py @@ -13,16 +13,17 @@ """Module houses class that implements ``GenericUnidistDataframePartitionManager`` using Unidist.""" +from modin.core.execution.modin_aqp import progress_bar_wrapper +from modin.core.execution.unidist.common import UnidistWrapper from modin.core.execution.unidist.generic.partitioning import ( GenericUnidistDataframePartitionManager, ) -from modin.core.execution.unidist.common import UnidistWrapper + +from .partition import PandasOnUnidistDataframePartition from .virtual_partition import ( PandasOnUnidistDataframeColumnPartition, PandasOnUnidistDataframeRowPartition, ) -from .partition import PandasOnUnidistDataframePartition -from modin.core.execution.modin_aqp import progress_bar_wrapper class PandasOnUnidistDataframePartitionManager(GenericUnidistDataframePartitionManager): diff --git a/modin/core/execution/unidist/implementations/pandas_on_unidist/partitioning/virtual_partition.py b/modin/core/execution/unidist/implementations/pandas_on_unidist/partitioning/virtual_partition.py index b2969a2cd2d..c1abfe9e749 100644 --- a/modin/core/execution/unidist/implementations/pandas_on_unidist/partitioning/virtual_partition.py +++ b/modin/core/execution/unidist/implementations/pandas_on_unidist/partitioning/virtual_partition.py @@ -19,11 +19,12 @@ from modin.core.dataframe.pandas.partitioning.axis_partition import ( PandasDataframeAxisPartition, ) -from modin.core.execution.unidist.common.utils import deserialize from modin.core.execution.unidist.common import UnidistWrapper -from .partition import PandasOnUnidistDataframePartition +from modin.core.execution.unidist.common.utils import deserialize from modin.utils import _inherit_docstrings +from .partition import PandasOnUnidistDataframePartition + class PandasOnUnidistDataframeVirtualPartition(PandasDataframeAxisPartition): """ diff --git a/modin/core/io/__init__.py b/modin/core/io/__init__.py index e542753e474..3a6b429982f 100644 --- a/modin/core/io/__init__.py +++ b/modin/core/io/__init__.py @@ -13,17 +13,17 @@ """IO functions implementations.""" +from .column_stores.feather_dispatcher import FeatherDispatcher +from .column_stores.hdf_dispatcher import HDFDispatcher +from .column_stores.parquet_dispatcher import ParquetDispatcher +from .file_dispatcher import FileDispatcher from .io import BaseIO +from .sql.sql_dispatcher import SQLDispatcher from .text.csv_dispatcher import CSVDispatcher +from .text.excel_dispatcher import ExcelDispatcher from .text.fwf_dispatcher import FWFDispatcher from .text.json_dispatcher import JSONDispatcher -from .text.excel_dispatcher import ExcelDispatcher -from .file_dispatcher import FileDispatcher from .text.text_file_dispatcher import TextFileDispatcher -from .column_stores.parquet_dispatcher import ParquetDispatcher -from .column_stores.hdf_dispatcher import HDFDispatcher -from .column_stores.feather_dispatcher import FeatherDispatcher -from .sql.sql_dispatcher import SQLDispatcher __all__ = [ "BaseIO", diff --git a/modin/core/io/column_stores/column_store_dispatcher.py b/modin/core/io/column_stores/column_store_dispatcher.py index a49ce859eb3..684843d844d 100644 --- a/modin/core/io/column_stores/column_store_dispatcher.py +++ b/modin/core/io/column_stores/column_store_dispatcher.py @@ -22,9 +22,9 @@ import numpy as np import pandas -from modin.core.storage_formats.pandas.utils import compute_chunksize -from modin.core.io.file_dispatcher import FileDispatcher from modin.config import NPartitions +from modin.core.io.file_dispatcher import FileDispatcher +from modin.core.storage_formats.pandas.utils import compute_chunksize class ColumnStoreDispatcher(FileDispatcher): diff --git a/modin/core/io/column_stores/feather_dispatcher.py b/modin/core/io/column_stores/feather_dispatcher.py index c6f394b8be3..e79ec9b8c17 100644 --- a/modin/core/io/column_stores/feather_dispatcher.py +++ b/modin/core/io/column_stores/feather_dispatcher.py @@ -14,8 +14,8 @@ """Module houses `FeatherDispatcher` class, that is used for reading `.feather` files.""" from modin.core.io.column_stores.column_store_dispatcher import ColumnStoreDispatcher -from modin.utils import import_optional_dependency from modin.core.io.file_dispatcher import OpenFile +from modin.utils import import_optional_dependency class FeatherDispatcher(ColumnStoreDispatcher): diff --git a/modin/core/io/column_stores/parquet_dispatcher.py b/modin/core/io/column_stores/parquet_dispatcher.py index afc22bdd1f1..f060e1cb6d1 100644 --- a/modin/core/io/column_stores/parquet_dispatcher.py +++ b/modin/core/io/column_stores/parquet_dispatcher.py @@ -13,24 +13,22 @@ """Module houses `ParquetDispatcher` class, that is used for reading `.parquet` files.""" +import json import os import re -import json import fsspec -from fsspec.core import url_to_fs -from fsspec.spec import AbstractBufferedFile import numpy as np -from pandas.io.common import stringify_path import pandas import pandas._libs.lib as lib +from fsspec.core import url_to_fs +from fsspec.spec import AbstractBufferedFile from packaging import version +from pandas.io.common import stringify_path -from modin.core.storage_formats.pandas.utils import compute_chunksize from modin.config import NPartitions - - from modin.core.io.column_stores.column_store_dispatcher import ColumnStoreDispatcher +from modin.core.storage_formats.pandas.utils import compute_chunksize from modin.utils import _inherit_docstrings diff --git a/modin/core/io/file_dispatcher.py b/modin/core/io/file_dispatcher.py index 0d0cd2a0a7f..68b82aec72d 100644 --- a/modin/core/io/file_dispatcher.py +++ b/modin/core/io/file_dispatcher.py @@ -22,13 +22,12 @@ import fsspec import numpy as np -from pandas.io.common import is_url, is_fsspec_url +from pandas.io.common import is_fsspec_url, is_url -from modin.logging import ClassLogger from modin.config import AsyncReadMode +from modin.logging import ClassLogger from modin.utils import ModinAssumptionError - NOT_IMPLEMENTED_MESSAGE = "Implement in children classes!" @@ -259,9 +258,9 @@ def file_exists(cls, file_path, storage_options=None): try: from botocore.exceptions import ( - NoCredentialsError, - EndpointConnectionError, ConnectTimeoutError, + EndpointConnectionError, + NoCredentialsError, ) credential_error_type = ( diff --git a/modin/core/io/io.py b/modin/core/io/io.py index 30c0e186661..a024d0c6c0e 100644 --- a/modin/core/io/io.py +++ b/modin/core/io/io.py @@ -21,14 +21,14 @@ from typing import Any import pandas -from pandas.util._decorators import doc from pandas._libs.lib import no_default +from pandas.util._decorators import doc +from modin.core.storage_formats.base.query_compiler import BaseQueryCompiler from modin.db_conn import ModinDatabaseConnection from modin.error_message import ErrorMessage -from modin.core.storage_formats.base.query_compiler import BaseQueryCompiler -from modin.utils import _inherit_docstrings from modin.pandas.io import ExcelFile +from modin.utils import _inherit_docstrings _doc_default_io_method = """ {summary} using pandas. diff --git a/modin/core/io/sql/sql_dispatcher.py b/modin/core/io/sql/sql_dispatcher.py index 2625a5d71cc..782207e95d7 100644 --- a/modin/core/io/sql/sql_dispatcher.py +++ b/modin/core/io/sql/sql_dispatcher.py @@ -20,12 +20,13 @@ """ import math + import numpy as np import pandas +from modin.config import NPartitions, ReadSqlEngine from modin.core.io.file_dispatcher import FileDispatcher from modin.db_conn import ModinDatabaseConnection -from modin.config import NPartitions, ReadSqlEngine class SQLDispatcher(FileDispatcher): diff --git a/modin/core/io/text/excel_dispatcher.py b/modin/core/io/text/excel_dispatcher.py index b9235abcae6..c18f4034267 100644 --- a/modin/core/io/text/excel_dispatcher.py +++ b/modin/core/io/text/excel_dispatcher.py @@ -14,14 +14,14 @@ """Module houses `ExcelDispatcher` class, that is used for reading excel files.""" import os +import re +import warnings from io import BytesIO import pandas -import re -import warnings -from modin.core.io.text.text_file_dispatcher import TextFileDispatcher from modin.config import NPartitions +from modin.core.io.text.text_file_dispatcher import TextFileDispatcher from modin.pandas.io import ExcelFile EXCEL_READ_BLOCK_SIZE = 4096 @@ -76,9 +76,11 @@ def _read(cls, io, **kwargs): ) from zipfile import ZipFile - from openpyxl.worksheet.worksheet import Worksheet - from openpyxl.worksheet._reader import WorksheetReader + from openpyxl.reader.excel import ExcelReader + from openpyxl.worksheet._reader import WorksheetReader + from openpyxl.worksheet.worksheet import Worksheet + from modin.core.storage_formats.pandas.parsers import PandasExcelParser sheet_name = kwargs.get("sheet_name", 0) diff --git a/modin/core/io/text/fwf_dispatcher.py b/modin/core/io/text/fwf_dispatcher.py index 344539e40d3..ce27beffccc 100644 --- a/modin/core/io/text/fwf_dispatcher.py +++ b/modin/core/io/text/fwf_dispatcher.py @@ -13,7 +13,7 @@ """Module houses `FWFDispatcher` class, that is used for reading of tables with fixed-width formatted lines.""" -from typing import Optional, Union, Sequence, Tuple +from typing import Optional, Sequence, Tuple, Union from modin.core.io.text.text_file_dispatcher import TextFileDispatcher diff --git a/modin/core/io/text/json_dispatcher.py b/modin/core/io/text/json_dispatcher.py index 1490b4325f1..9d4873c8bb8 100644 --- a/modin/core/io/text/json_dispatcher.py +++ b/modin/core/io/text/json_dispatcher.py @@ -13,13 +13,14 @@ """Module houses `JSONDispatcher` class, that is used for reading `.json` files.""" -from modin.core.io.file_dispatcher import OpenFile -from modin.core.io.text.text_file_dispatcher import TextFileDispatcher from io import BytesIO -import pandas + import numpy as np +import pandas from modin.config import NPartitions +from modin.core.io.file_dispatcher import OpenFile +from modin.core.io.text.text_file_dispatcher import TextFileDispatcher class JSONDispatcher(TextFileDispatcher): diff --git a/modin/core/io/text/text_file_dispatcher.py b/modin/core/io/text/text_file_dispatcher.py index 75f05356bb1..1862862dc99 100644 --- a/modin/core/io/text/text_file_dispatcher.py +++ b/modin/core/io/text/text_file_dispatcher.py @@ -17,23 +17,23 @@ `TextFileDispatcher` contains utils for text formats files, inherits util functions for files from `FileDispatcher` class and can be used as base class for dipatchers of SQL queries. """ -import warnings -import os -import io import codecs -from typing import Union, Sequence, Optional, Tuple, Callable +import io +import os +import warnings from csv import QUOTE_NONE +from typing import Callable, Optional, Sequence, Tuple, Union import numpy as np import pandas import pandas._libs.lib as lib from pandas.core.dtypes.common import is_list_like +from modin.config import NPartitions from modin.core.io.file_dispatcher import FileDispatcher, OpenFile +from modin.core.io.text.utils import CustomNewlineIterator from modin.core.storage_formats.pandas.utils import compute_chunksize from modin.utils import _inherit_docstrings -from modin.core.io.text.utils import CustomNewlineIterator -from modin.config import NPartitions ColumnNamesTypes = Tuple[Union[pandas.Index, pandas.MultiIndex]] IndexColType = Union[int, str, bool, Sequence[int], Sequence[str], None] diff --git a/modin/core/storage_formats/base/doc_utils.py b/modin/core/storage_formats/base/doc_utils.py index 8b58af073ad..ce0f3935731 100644 --- a/modin/core/storage_formats/base/doc_utils.py +++ b/modin/core/storage_formats/base/doc_utils.py @@ -14,8 +14,8 @@ """Module contains decorators for documentation of the query compiler methods.""" from functools import partial -from modin.utils import append_to_docstring, format_string, align_indents +from modin.utils import align_indents, append_to_docstring, format_string _one_column_warning = """ .. warning:: diff --git a/modin/core/storage_formats/base/query_compiler.py b/modin/core/storage_formats/base/query_compiler.py index d2f4d8533b3..8e802cdec2e 100644 --- a/modin/core/storage_formats/base/query_compiler.py +++ b/modin/core/storage_formats/base/query_compiler.py @@ -18,32 +18,33 @@ """ import abc +from typing import Hashable, List, Optional +import numpy as np +import pandas +import pandas.core.resample +from pandas._typing import DtypeBackend, IndexLabel, Suffixes +from pandas.core.dtypes.common import is_number, is_scalar + +from modin.config import StorageFormat from modin.core.dataframe.algebra.default2pandas import ( + BinaryDefault, + CatDefault, DataFrameDefault, - SeriesDefault, DateTimeDefault, - StrDefault, - BinaryDefault, - ResampleDefault, - RollingDefault, ExpandingDefault, - CatDefault, GroupByDefault, + ResampleDefault, + RollingDefault, + SeriesDefault, SeriesGroupByDefault, + StrDefault, ) from modin.error_message import ErrorMessage -from . import doc_utils from modin.logging import ClassLogger from modin.utils import MODIN_UNNAMED_SERIES_LABEL, try_cast_to_pandas -from modin.config import StorageFormat -from pandas.core.dtypes.common import is_scalar, is_number -import pandas.core.resample -import pandas -from pandas._typing import IndexLabel, Suffixes, DtypeBackend -import numpy as np -from typing import List, Hashable, Optional +from . import doc_utils def _get_axis(axis): @@ -4144,10 +4145,10 @@ def get_positions_from_labels(self, row_loc, col_loc): common ``Indexer`` object or range and ``np.ndarray`` only. """ from modin.pandas.indexing import ( + boolean_mask_to_numeric, is_boolean_array, is_list_like, is_range_like, - boolean_mask_to_numeric, ) lookups = [] diff --git a/modin/core/storage_formats/cudf/parser.py b/modin/core/storage_formats/cudf/parser.py index 9daac986e13..1cfa7680893 100644 --- a/modin/core/storage_formats/cudf/parser.py +++ b/modin/core/storage_formats/cudf/parser.py @@ -11,19 +11,20 @@ # ANY KIND, either express or implied. See the License for the specific language # governing permissions and limitations under the License. +import warnings from collections import OrderedDict from io import BytesIO + import numpy as np import pandas from pandas.core.dtypes.cast import find_common_type from pandas.core.dtypes.concat import union_categoricals from pandas.io.common import infer_compression -import warnings -from modin.core.io.file_dispatcher import OpenFile from modin.core.execution.ray.implementations.cudf_on_ray.partitioning.partition_manager import ( GPU_MANAGERS, ) +from modin.core.io.file_dispatcher import OpenFile from modin.core.storage_formats.pandas.utils import split_result_of_axis_func_pandas from modin.error_message import ErrorMessage diff --git a/modin/core/storage_formats/pandas/aggregations.py b/modin/core/storage_formats/pandas/aggregations.py index ed6cfc401a6..e383a38a040 100644 --- a/modin/core/storage_formats/pandas/aggregations.py +++ b/modin/core/storage_formats/pandas/aggregations.py @@ -13,11 +13,12 @@ """Contains implementations for aggregation functions.""" +from enum import Enum +from typing import TYPE_CHECKING, Callable, Tuple + +import numpy as np import pandas from pandas.core.dtypes.common import is_numeric_dtype -import numpy as np -from typing import TYPE_CHECKING, Tuple, Callable -from enum import Enum if TYPE_CHECKING: from .query_compiler import PandasQueryCompiler diff --git a/modin/core/storage_formats/pandas/groupby.py b/modin/core/storage_formats/pandas/groupby.py index 3d501d900de..b89fcfc44c7 100644 --- a/modin/core/storage_formats/pandas/groupby.py +++ b/modin/core/storage_formats/pandas/groupby.py @@ -13,13 +13,13 @@ """Contains implementations for GroupbyReduce functions.""" -import pandas import numpy as np +import pandas -from modin.utils import hashable -from modin.core.dataframe.algebra import GroupByReduce from modin.config import ExperimentalGroupbyImpl +from modin.core.dataframe.algebra import GroupByReduce from modin.error_message import ErrorMessage +from modin.utils import hashable class GroupbyReduceImpl: diff --git a/modin/core/storage_formats/pandas/parsers.py b/modin/core/storage_formats/pandas/parsers.py index 5cdfed2e7e3..a4fb48d1360 100644 --- a/modin/core/storage_formats/pandas/parsers.py +++ b/modin/core/storage_formats/pandas/parsers.py @@ -39,10 +39,13 @@ parameters are passed into `pandas.read_sql` function without modification. """ -import os import json +import os +import warnings from collections import OrderedDict -from io import BytesIO, TextIOWrapper, IOBase +from io import BytesIO, IOBase, TextIOWrapper +from typing import Any, NamedTuple + import fsspec import numpy as np import pandas @@ -50,15 +53,13 @@ from pandas.core.dtypes.concat import union_categoricals from pandas.io.common import infer_compression from pandas.util._decorators import doc -from typing import Any, NamedTuple -import warnings from modin.core.io.file_dispatcher import OpenFile -from modin.db_conn import ModinDatabaseConnection from modin.core.storage_formats.pandas.utils import ( - split_result_of_axis_func_pandas, _nullcontext, + split_result_of_axis_func_pandas, ) +from modin.db_conn import ModinDatabaseConnection from modin.error_message import ErrorMessage from modin.logging import ClassLogger from modin.utils import ModinAssumptionError @@ -590,18 +591,16 @@ def parse(fname, **kwargs): if start is None or end is None: return pandas.read_excel(fname, **kwargs) + import re from zipfile import ZipFile + import openpyxl - from openpyxl.worksheet._reader import WorksheetReader from openpyxl.reader.excel import ExcelReader + from openpyxl.worksheet._reader import WorksheetReader from openpyxl.worksheet.worksheet import Worksheet from pandas.core.dtypes.common import is_list_like - from pandas.io.excel._util import ( - fill_mi_header, - maybe_convert_usecols, - ) + from pandas.io.excel._util import fill_mi_header, maybe_convert_usecols from pandas.io.parsers import TextParser - import re wb = openpyxl.load_workbook(filename=fname, read_only=True) # Get shared strings diff --git a/modin/core/storage_formats/pandas/query_compiler.py b/modin/core/storage_formats/pandas/query_compiler.py index 7eaaa30ed5b..870a80319f4 100644 --- a/modin/core/storage_formats/pandas/query_compiler.py +++ b/modin/core/storage_formats/pandas/query_compiler.py @@ -18,56 +18,58 @@ queries for the ``PandasDataframe``. """ +import hashlib import re +import warnings +from collections.abc import Iterable +from typing import Hashable, List + import numpy as np import pandas +from pandas._libs import lib from pandas.api.types import is_scalar -from pandas.core.common import is_bool_indexer -from pandas.core.indexing import check_bool_indexer -from pandas.core.indexes.api import ensure_index_from_sequences from pandas.core.apply import reconstruct_func +from pandas.core.common import is_bool_indexer +from pandas.core.dtypes.cast import find_common_type from pandas.core.dtypes.common import ( + is_bool_dtype, + is_datetime64_any_dtype, is_list_like, is_numeric_dtype, - is_datetime64_any_dtype, - is_bool_dtype, ) -from pandas.core.dtypes.cast import find_common_type -from pandas.errors import DataError, MergeError -from pandas._libs import lib -from collections.abc import Iterable -from typing import List, Hashable -import warnings -import hashlib from pandas.core.groupby.base import transformation_kernels +from pandas.core.indexes.api import ensure_index_from_sequences +from pandas.core.indexing import check_bool_indexer +from pandas.errors import DataError, MergeError -from modin.core.storage_formats.base.query_compiler import BaseQueryCompiler -from modin.config import ExperimentalGroupbyImpl, CpuCount -from modin.error_message import ErrorMessage -from modin.utils import ( - try_cast_to_pandas, - wrap_udf_function, - hashable, - _inherit_docstrings, - MODIN_UNNAMED_SERIES_LABEL, -) -from modin.core.dataframe.base.dataframe.utils import join_columns +from modin.config import CpuCount, ExperimentalGroupbyImpl from modin.core.dataframe.algebra import ( + Binary, Fold, + GroupByReduce, Map, - TreeReduce, Reduce, - Binary, - GroupByReduce, + TreeReduce, ) from modin.core.dataframe.algebra.default2pandas.groupby import ( GroupBy, GroupByDefault, SeriesGroupByDefault, ) -from .utils import get_group_names, merge_partitioning -from .groupby import GroupbyReduceImpl +from modin.core.dataframe.base.dataframe.utils import join_columns +from modin.core.storage_formats.base.query_compiler import BaseQueryCompiler +from modin.error_message import ErrorMessage +from modin.utils import ( + MODIN_UNNAMED_SERIES_LABEL, + _inherit_docstrings, + hashable, + try_cast_to_pandas, + wrap_udf_function, +) + from .aggregations import CorrCovBuilder +from .groupby import GroupbyReduceImpl +from .utils import get_group_names, merge_partitioning def _get_axis(axis): diff --git a/modin/core/storage_formats/pandas/utils.py b/modin/core/storage_formats/pandas/utils.py index 748321fb030..35570ce1396 100644 --- a/modin/core/storage_formats/pandas/utils.py +++ b/modin/core/storage_formats/pandas/utils.py @@ -13,15 +13,15 @@ """Contains utility functions for frame partitioning.""" +import contextlib import re +from math import ceil from typing import Hashable, List -import contextlib import numpy as np import pandas from modin.config import MinPartitionSize, NPartitions -from math import ceil @contextlib.contextmanager diff --git a/modin/db_conn.py b/modin/db_conn.py index 4b0f3419b4c..46a9faa461d 100644 --- a/modin/db_conn.py +++ b/modin/db_conn.py @@ -23,7 +23,7 @@ driver or a worker wants one. """ -from typing import Any, Sequence, Dict, Optional +from typing import Any, Dict, Optional, Sequence _PSYCOPG_LIB_NAME = "psycopg2" _SQLALCHEMY_LIB_NAME = "sqlalchemy" diff --git a/modin/distributed/dataframe/pandas/__init__.py b/modin/distributed/dataframe/pandas/__init__.py index 709a5e5d471..506501d0637 100644 --- a/modin/distributed/dataframe/pandas/__init__.py +++ b/modin/distributed/dataframe/pandas/__init__.py @@ -13,6 +13,6 @@ """API to operate on distributed pandas DataFrame objects.""" -from .partitions import unwrap_partitions, from_partitions +from .partitions import from_partitions, unwrap_partitions __all__ = ["unwrap_partitions", "from_partitions"] diff --git a/modin/distributed/dataframe/pandas/partitions.py b/modin/distributed/dataframe/pandas/partitions.py index 1366826c6bf..1a71aa5ff30 100644 --- a/modin/distributed/dataframe/pandas/partitions.py +++ b/modin/distributed/dataframe/pandas/partitions.py @@ -13,7 +13,8 @@ """Module houses API to operate on Modin DataFrame partitions that are pandas DataFrame(s).""" -from typing import Optional, Union, TYPE_CHECKING +from typing import TYPE_CHECKING, Optional, Union + import numpy as np from pandas._typing import Axes @@ -21,19 +22,19 @@ from modin.pandas.dataframe import DataFrame, Series if TYPE_CHECKING: - from modin.core.execution.ray.implementations.pandas_on_ray.partitioning import ( - PandasOnRayDataframePartition, - PandasOnRayDataframeColumnPartition, - PandasOnRayDataframeRowPartition, - ) from modin.core.execution.dask.implementations.pandas_on_dask.partitioning import ( - PandasOnDaskDataframePartition, PandasOnDaskDataframeColumnPartition, + PandasOnDaskDataframePartition, PandasOnDaskDataframeRowPartition, ) + from modin.core.execution.ray.implementations.pandas_on_ray.partitioning import ( + PandasOnRayDataframeColumnPartition, + PandasOnRayDataframePartition, + PandasOnRayDataframeRowPartition, + ) from modin.core.execution.unidist.implementations.pandas_on_unidist.partitioning import ( - PandasOnUnidistDataframePartition, PandasOnUnidistDataframeColumnPartition, + PandasOnUnidistDataframePartition, PandasOnUnidistDataframeRowPartition, ) diff --git a/modin/error_message.py b/modin/error_message.py index 631859a09a0..212b698fc2a 100644 --- a/modin/error_message.py +++ b/modin/error_message.py @@ -11,8 +11,9 @@ # ANY KIND, either express or implied. See the License for the specific language # governing permissions and limitations under the License. -from typing import NoReturn, Set import warnings +from typing import NoReturn, Set + from modin.logging import get_logger from modin.utils import get_current_execution diff --git a/modin/experimental/batch/__init__.py b/modin/experimental/batch/__init__.py index 08eaab3867c..e7bcc10169a 100644 --- a/modin/experimental/batch/__init__.py +++ b/modin/experimental/batch/__init__.py @@ -13,7 +13,6 @@ from .pipeline import PandasQueryPipeline - __all__ = [ "PandasQueryPipeline", ] diff --git a/modin/experimental/batch/pipeline.py b/modin/experimental/batch/pipeline.py index 500a5447570..d7cd60cae47 100644 --- a/modin/experimental/batch/pipeline.py +++ b/modin/experimental/batch/pipeline.py @@ -14,15 +14,16 @@ """Module houses ``PandasQueryPipeline`` and ``PandasQuery`` classes, that implement a batch pipeline protocol for Modin Dataframes.""" from typing import Callable, Optional + import numpy as np import modin.pandas as pd -from modin.core.storage_formats.pandas import PandasQueryCompiler -from modin.error_message import ErrorMessage +from modin.config import NPartitions from modin.core.execution.ray.implementations.pandas_on_ray.dataframe.dataframe import ( PandasOnRayDataframe, ) -from modin.config import NPartitions +from modin.core.storage_formats.pandas import PandasQueryCompiler +from modin.error_message import ErrorMessage from modin.utils import get_current_execution diff --git a/modin/experimental/batch/test/test_pipeline.py b/modin/experimental/batch/test/test_pipeline.py index 35424b3f404..b0b55f234fe 100644 --- a/modin/experimental/batch/test/test_pipeline.py +++ b/modin/experimental/batch/test/test_pipeline.py @@ -11,16 +11,16 @@ # ANY KIND, either express or implied. See the License for the specific language # governing permissions and limitations under the License. -import pytest import numpy as np import pandas +import pytest import modin.pandas as pd from modin.config import Engine, NPartitions +from modin.core.execution.ray.common import RayWrapper from modin.distributed.dataframe.pandas.partitions import from_partitions from modin.experimental.batch.pipeline import PandasQueryPipeline from modin.pandas.test.utils import df_equals -from modin.core.execution.ray.common import RayWrapper @pytest.mark.skipif( @@ -458,8 +458,8 @@ def reducer(dfs): def test_pipeline_complex(self): """Create a complex pipeline with both `fan_out`, `repartition_after` and postprocessing and ensure that it runs end to end correctly.""" - from os.path import exists from os import remove + from os.path import exists from time import sleep df = pd.DataFrame([[0, 1, 2]]) diff --git a/modin/experimental/core/execution/dask/implementations/pandas_on_dask/io/io.py b/modin/experimental/core/execution/dask/implementations/pandas_on_dask/io/io.py index 91367927bd2..bfc628eebb8 100644 --- a/modin/experimental/core/execution/dask/implementations/pandas_on_dask/io/io.py +++ b/modin/experimental/core/execution/dask/implementations/pandas_on_dask/io/io.py @@ -18,28 +18,27 @@ Query Compiler API, even if it is only extending the API. """ +from modin.core.execution.dask.common import DaskWrapper +from modin.core.execution.dask.implementations.pandas_on_dask.dataframe import ( + PandasOnDaskDataframe, +) +from modin.core.execution.dask.implementations.pandas_on_dask.io import PandasOnDaskIO +from modin.core.execution.dask.implementations.pandas_on_dask.partitioning import ( + PandasOnDaskDataframePartition, +) from modin.core.storage_formats.pandas.parsers import ( - PandasCSVGlobParser, - ExperimentalPandasPickleParser, ExperimentalCustomTextParser, + ExperimentalPandasPickleParser, + PandasCSVGlobParser, ) from modin.core.storage_formats.pandas.query_compiler import PandasQueryCompiler -from modin.core.execution.dask.implementations.pandas_on_dask.io import PandasOnDaskIO from modin.experimental.core.io import ( ExperimentalCSVGlobDispatcher, - ExperimentalSQLDispatcher, - ExperimentalPickleDispatcher, ExperimentalCustomTextDispatcher, + ExperimentalPickleDispatcher, + ExperimentalSQLDispatcher, ) -from modin.core.execution.dask.implementations.pandas_on_dask.dataframe import ( - PandasOnDaskDataframe, -) -from modin.core.execution.dask.implementations.pandas_on_dask.partitioning import ( - PandasOnDaskDataframePartition, -) -from modin.core.execution.dask.common import DaskWrapper - class ExperimentalPandasOnDaskIO(PandasOnDaskIO): """ diff --git a/modin/experimental/core/execution/native/implementations/hdk_on_native/base_worker.py b/modin/experimental/core/execution/native/implementations/hdk_on_native/base_worker.py index 8bea8400bd5..87b91adf896 100644 --- a/modin/experimental/core/execution/native/implementations/hdk_on_native/base_worker.py +++ b/modin/experimental/core/execution/native/implementations/hdk_on_native/base_worker.py @@ -15,10 +15,10 @@ import abc import uuid -from typing import Tuple, List +from typing import List, Tuple -import pyarrow as pa import numpy as np +import pyarrow as pa from modin.error_message import ErrorMessage diff --git a/modin/experimental/core/execution/native/implementations/hdk_on_native/calcite_algebra.py b/modin/experimental/core/execution/native/implementations/hdk_on_native/calcite_algebra.py index 633878c2e25..99ff2afdf54 100644 --- a/modin/experimental/core/execution/native/implementations/hdk_on_native/calcite_algebra.py +++ b/modin/experimental/core/execution/native/implementations/hdk_on_native/calcite_algebra.py @@ -20,8 +20,8 @@ import abc -from .db_worker import DbTable from .dataframe.utils import ColNameCodec +from .db_worker import DbTable from .expr import BaseExpr diff --git a/modin/experimental/core/execution/native/implementations/hdk_on_native/calcite_builder.py b/modin/experimental/core/execution/native/implementations/hdk_on_native/calcite_builder.py index 1ba9e8cbd85..3b9b7ff9330 100644 --- a/modin/experimental/core/execution/native/implementations/hdk_on_native/calcite_builder.py +++ b/modin/experimental/core/execution/native/implementations/hdk_on_native/calcite_builder.py @@ -12,41 +12,42 @@ # governing permissions and limitations under the License. """Module provides ``CalciteBuilder`` class.""" -from .dataframe.utils import ColNameCodec -from .expr import ( - InputRefExpr, - LiteralExpr, - AggregateExpr, - build_if_then_else, - build_row_idx_filter_expr, -) +from collections import abc + +import pandas +from pandas.core.dtypes.common import _get_dtype, is_bool_dtype + from .calcite_algebra import ( + CalciteAggregateNode, CalciteBaseNode, - CalciteInputRefExpr, + CalciteCollation, + CalciteFilterNode, CalciteInputIdxExpr, - CalciteScanNode, + CalciteInputRefExpr, + CalciteJoinNode, CalciteProjectionNode, - CalciteFilterNode, - CalciteAggregateNode, - CalciteCollation, + CalciteScanNode, CalciteSortNode, - CalciteJoinNode, CalciteUnionNode, ) +from .dataframe.utils import ColNameCodec from .df_algebra import ( + FilterNode, FrameNode, - MaskNode, GroupbyAggNode, - TransformNode, JoinNode, - UnionNode, + MaskNode, SortNode, - FilterNode, + TransformNode, + UnionNode, +) +from .expr import ( + AggregateExpr, + InputRefExpr, + LiteralExpr, + build_if_then_else, + build_row_idx_filter_expr, ) - -from collections import abc -import pandas -from pandas.core.dtypes.common import _get_dtype, is_bool_dtype class CalciteBuilder: diff --git a/modin/experimental/core/execution/native/implementations/hdk_on_native/calcite_serializer.py b/modin/experimental/core/execution/native/implementations/hdk_on_native/calcite_serializer.py index a92e5afd212..7099751dafe 100644 --- a/modin/experimental/core/execution/native/implementations/hdk_on_native/calcite_serializer.py +++ b/modin/experimental/core/execution/native/implementations/hdk_on_native/calcite_serializer.py @@ -13,30 +13,27 @@ """Module provides ``CalciteSerializer`` class.""" +import json + +import numpy as np from pandas.core.dtypes.common import is_datetime64_dtype -from .expr import ( - BaseExpr, - LiteralExpr, - OpExpr, - AggregateExpr, -) +from modin.error_message import ErrorMessage + from .calcite_algebra import ( + CalciteAggregateNode, CalciteBaseNode, - CalciteInputRefExpr, + CalciteCollation, + CalciteFilterNode, CalciteInputIdxExpr, - CalciteScanNode, + CalciteInputRefExpr, + CalciteJoinNode, CalciteProjectionNode, - CalciteFilterNode, - CalciteAggregateNode, - CalciteCollation, + CalciteScanNode, CalciteSortNode, - CalciteJoinNode, CalciteUnionNode, ) -from modin.error_message import ErrorMessage -import json -import numpy as np +from .expr import AggregateExpr, BaseExpr, LiteralExpr, OpExpr def _warn_if_unsigned(dtype): # noqa: GL08 diff --git a/modin/experimental/core/execution/native/implementations/hdk_on_native/dataframe/dataframe.py b/modin/experimental/core/execution/native/implementations/hdk_on_native/dataframe/dataframe.py index d4c9293139c..66a5e81b6ec 100644 --- a/modin/experimental/core/execution/native/implementations/hdk_on_native/dataframe/dataframe.py +++ b/modin/experimental/core/execution/native/implementations/hdk_on_native/dataframe/dataframe.py @@ -14,74 +14,72 @@ """Module provides ``HdkOnNativeDataframe`` class implementing lazy frame.""" import re -import numpy as np from collections import OrderedDict +from typing import Hashable, Iterable, List, Optional, Tuple, Union -from typing import List, Hashable, Optional, Tuple, Union, Iterable - -import pyarrow -from pyarrow.types import is_dictionary - +import numpy as np import pandas as pd +import pyarrow from pandas._libs.lib import no_default -from pandas.core.indexes.api import Index, MultiIndex, RangeIndex from pandas.core.dtypes.common import ( _get_dtype, - is_list_like, is_bool_dtype, - is_string_dtype, - is_integer_dtype, is_datetime64_dtype, + is_integer_dtype, + is_list_like, + is_string_dtype, ) +from pandas.core.indexes.api import Index, MultiIndex, RangeIndex +from pyarrow.types import is_dictionary -from modin.core.dataframe.pandas.dataframe.dataframe import PandasDataframe -from modin.core.dataframe.base.dataframe.utils import Axis, JoinType +from modin.core.dataframe.base.dataframe.utils import Axis, JoinType, join_columns from modin.core.dataframe.base.interchange.dataframe_protocol.dataframe import ( ProtocolDataframe, ) +from modin.core.dataframe.pandas.dataframe.dataframe import PandasDataframe +from modin.core.dataframe.pandas.metadata import LazyProxyCategoricalDtype +from modin.core.dataframe.pandas.utils import concatenate +from modin.error_message import ErrorMessage from modin.experimental.core.storage_formats.hdk.query_compiler import ( DFAlgQueryCompiler, ) -from .utils import ( - ColNameCodec, - maybe_range, - arrow_to_pandas, - check_join_supported, - check_cols_to_join, - get_data_for_join_by_index, - build_categorical_from_at, -) -from ..db_worker import DbTable -from ..partitioning.partition_manager import HdkOnNativeDataframePartitionManager -from modin.core.dataframe.pandas.metadata import LazyProxyCategoricalDtype -from modin.error_message import ErrorMessage +from modin.pandas.indexing import is_range_like +from modin.pandas.utils import check_both_not_none from modin.utils import MODIN_UNNAMED_SERIES_LABEL, _inherit_docstrings -from modin.core.dataframe.pandas.utils import concatenate -from modin.core.dataframe.base.dataframe.utils import join_columns + +from ..db_worker import DbTable from ..df_algebra import ( - MaskNode, + FilterNode, FrameNode, GroupbyAggNode, - TransformNode, - UnionNode, JoinNode, + MaskNode, SortNode, - FilterNode, - translate_exprs_to_base, + TransformNode, + UnionNode, replace_frame_in_exprs, + translate_exprs_to_base, ) from ..expr import ( AggregateExpr, InputRefExpr, LiteralExpr, OpExpr, - build_if_then_else, - build_dt_expr, _get_common_dtype, + build_dt_expr, + build_if_then_else, is_cmp_op, ) -from modin.pandas.utils import check_both_not_none -from modin.pandas.indexing import is_range_like +from ..partitioning.partition_manager import HdkOnNativeDataframePartitionManager +from .utils import ( + ColNameCodec, + arrow_to_pandas, + build_categorical_from_at, + check_cols_to_join, + check_join_supported, + get_data_for_join_by_index, + maybe_range, +) IDX_COL_NAME = ColNameCodec.IDX_COL_NAME ROWID_COL_NAME = ColNameCodec.ROWID_COL_NAME diff --git a/modin/experimental/core/execution/native/implementations/hdk_on_native/dataframe/utils.py b/modin/experimental/core/execution/native/implementations/hdk_on_native/dataframe/utils.py index 6710ed8c33c..45cf0a64b5e 100644 --- a/modin/experimental/core/execution/native/implementations/hdk_on_native/dataframe/utils.py +++ b/modin/experimental/core/execution/native/implementations/hdk_on_native/dataframe/utils.py @@ -14,19 +14,17 @@ """Utilities for internal use by the ``HdkOnNativeDataframe``.""" import re - import typing -from typing import Tuple, Union, List, Any -from functools import lru_cache from collections import OrderedDict +from functools import lru_cache +from typing import Any, List, Tuple, Union import numpy as np import pandas +import pyarrow as pa from pandas import Timestamp -from pandas.core.dtypes.common import _get_dtype, is_string_dtype from pandas.core.arrays.arrow.extension_types import ArrowIntervalType - -import pyarrow as pa +from pandas.core.dtypes.common import _get_dtype, is_string_dtype from pyarrow.types import is_dictionary from modin.pandas.indexing import is_range_like diff --git a/modin/experimental/core/execution/native/implementations/hdk_on_native/df_algebra.py b/modin/experimental/core/execution/native/implementations/hdk_on_native/df_algebra.py index da5e068eac0..b8455558a3d 100644 --- a/modin/experimental/core/execution/native/implementations/hdk_on_native/df_algebra.py +++ b/modin/experimental/core/execution/native/implementations/hdk_on_native/df_algebra.py @@ -14,23 +14,21 @@ """Module provides classes for lazy DataFrame algebra operations.""" import abc - import typing -from typing import TYPE_CHECKING, List, Dict, Union from collections import OrderedDict - -import pandas -from pandas.core.dtypes.common import is_string_dtype +from typing import TYPE_CHECKING, Dict, List, Union import numpy as np +import pandas import pyarrow as pa +from pandas.core.dtypes.common import is_string_dtype -from modin.utils import _inherit_docstrings from modin.pandas.indexing import is_range_like +from modin.utils import _inherit_docstrings -from .expr import InputRefExpr, LiteralExpr, OpExpr -from .dataframe.utils import ColNameCodec, EMPTY_ARROW_TABLE, get_common_arrow_type +from .dataframe.utils import EMPTY_ARROW_TABLE, ColNameCodec, get_common_arrow_type from .db_worker import DbTable +from .expr import InputRefExpr, LiteralExpr, OpExpr if TYPE_CHECKING: from .dataframe.dataframe import HdkOnNativeDataframe diff --git a/modin/experimental/core/execution/native/implementations/hdk_on_native/expr.py b/modin/experimental/core/execution/native/implementations/hdk_on_native/expr.py index 56989410176..ccd657e5f3b 100644 --- a/modin/experimental/core/execution/native/implementations/hdk_on_native/expr.py +++ b/modin/experimental/core/execution/native/implementations/hdk_on_native/expr.py @@ -14,28 +14,28 @@ """Module provides classes for scalar expression trees.""" import abc -from typing import Union, Generator, Type +from typing import Generator, Type, Union import numpy as np +import pandas import pyarrow as pa import pyarrow.compute as pc - -import pandas from pandas.core.dtypes.common import ( - is_list_like, _get_dtype, + is_bool_dtype, + is_datetime64_any_dtype, + is_datetime64_dtype, is_float_dtype, is_int64_dtype, is_integer_dtype, + is_list_like, is_numeric_dtype, is_string_dtype, - is_datetime64_any_dtype, - is_bool_dtype, - is_datetime64_dtype, ) from modin.pandas.indexing import is_range_like from modin.utils import _inherit_docstrings + from .dataframe.utils import ColNameCodec, to_arrow_type diff --git a/modin/experimental/core/execution/native/implementations/hdk_on_native/hdk_worker.py b/modin/experimental/core/execution/native/implementations/hdk_on_native/hdk_worker.py index dfcc7bb6ce6..a87e0e2e775 100644 --- a/modin/experimental/core/execution/native/implementations/hdk_on_native/hdk_worker.py +++ b/modin/experimental/core/execution/native/implementations/hdk_on_native/hdk_worker.py @@ -12,20 +12,18 @@ # governing permissions and limitations under the License. """Module provides ``HdkWorker`` class.""" -from typing import Optional, Tuple, List, Union - -from packaging import version - -import pyarrow as pa import os +from typing import List, Optional, Tuple, Union +import pyarrow as pa import pyhdk -from pyhdk.hdk import HDK, QueryNode, ExecutionResult, RelAlgExecutor - -from .base_worker import DbTable, BaseDbWorker +from packaging import version +from pyhdk.hdk import HDK, ExecutionResult, QueryNode, RelAlgExecutor +from modin.config import HdkFragmentSize, HdkLaunchParameters, OmnisciFragmentSize from modin.utils import _inherit_docstrings -from modin.config import HdkLaunchParameters, OmnisciFragmentSize, HdkFragmentSize + +from .base_worker import BaseDbWorker, DbTable _CAST_DICT = version.parse(pyhdk.__version__) <= version.parse("0.7.0") diff --git a/modin/experimental/core/execution/native/implementations/hdk_on_native/interchange/dataframe_protocol/buffer.py b/modin/experimental/core/execution/native/implementations/hdk_on_native/interchange/dataframe_protocol/buffer.py index e3f4d98d1f3..decbcb3b6d0 100644 --- a/modin/experimental/core/execution/native/implementations/hdk_on_native/interchange/dataframe_protocol/buffer.py +++ b/modin/experimental/core/execution/native/implementations/hdk_on_native/interchange/dataframe_protocol/buffer.py @@ -13,15 +13,16 @@ """The module houses HdkOnNative implementation of the Buffer class of DataFrame exchange protocol.""" +from typing import Optional, Tuple + import pyarrow as pa -from typing import Tuple, Optional -from modin.core.dataframe.base.interchange.dataframe_protocol.utils import ( - DlpackDeviceType, -) from modin.core.dataframe.base.interchange.dataframe_protocol.dataframe import ( ProtocolBuffer, ) +from modin.core.dataframe.base.interchange.dataframe_protocol.utils import ( + DlpackDeviceType, +) from modin.utils import _inherit_docstrings diff --git a/modin/experimental/core/execution/native/implementations/hdk_on_native/interchange/dataframe_protocol/column.py b/modin/experimental/core/execution/native/implementations/hdk_on_native/interchange/dataframe_protocol/column.py index b07ffd0b615..49f98fe4651 100644 --- a/modin/experimental/core/execution/native/implementations/hdk_on_native/interchange/dataframe_protocol/column.py +++ b/modin/experimental/core/execution/native/implementations/hdk_on_native/interchange/dataframe_protocol/column.py @@ -13,25 +13,27 @@ """The module houses HdkOnNative implementation of the Column class of DataFrame exchange protocol.""" -import pyarrow as pa -import pandas -import numpy as np -from typing import Any, Optional, Tuple, Dict, Iterable, TYPE_CHECKING from math import ceil +from typing import TYPE_CHECKING, Any, Dict, Iterable, Optional, Tuple + +import numpy as np +import pandas +import pyarrow as pa +from modin.core.dataframe.base.interchange.dataframe_protocol.dataframe import ( + CategoricalDescription, + ProtocolColumn, +) from modin.core.dataframe.base.interchange.dataframe_protocol.utils import ( - DTypeKind, - ColumnNullType, ArrowCTypes, + ColumnNullType, + DTypeKind, Endianness, pandas_dtype_to_arrow_c, raise_copy_alert, ) -from modin.core.dataframe.base.interchange.dataframe_protocol.dataframe import ( - CategoricalDescription, - ProtocolColumn, -) from modin.utils import _inherit_docstrings + from .buffer import HdkProtocolBuffer from .utils import arrow_dtype_to_arrow_c, arrow_types_map diff --git a/modin/experimental/core/execution/native/implementations/hdk_on_native/interchange/dataframe_protocol/dataframe.py b/modin/experimental/core/execution/native/implementations/hdk_on_native/interchange/dataframe_protocol/dataframe.py index 3003eef70fc..36c9af9ed04 100644 --- a/modin/experimental/core/execution/native/implementations/hdk_on_native/interchange/dataframe_protocol/dataframe.py +++ b/modin/experimental/core/execution/native/implementations/hdk_on_native/interchange/dataframe_protocol/dataframe.py @@ -14,25 +14,27 @@ """The module houses HdkOnNative implementation of the Dataframe class of DataFrame exchange protocol.""" import collections +from typing import Any, Dict, Iterable, Optional, Sequence + import numpy as np import pyarrow as pa -from typing import Optional, Iterable, Sequence, Dict, Any -from modin.experimental.core.execution.native.implementations.hdk_on_native.dataframe.dataframe import ( - HdkOnNativeDataframe, -) from modin.core.dataframe.base.interchange.dataframe_protocol.dataframe import ( ProtocolDataframe, ) -from modin.pandas.indexing import is_range_like -from modin.utils import _inherit_docstrings from modin.error_message import ErrorMessage +from modin.experimental.core.execution.native.implementations.hdk_on_native.dataframe.dataframe import ( + HdkOnNativeDataframe, +) from modin.experimental.core.execution.native.implementations.hdk_on_native.df_algebra import ( - MaskNode, FrameNode, + MaskNode, TransformNode, UnionNode, ) +from modin.pandas.indexing import is_range_like +from modin.utils import _inherit_docstrings + from .column import HdkProtocolColumn from .utils import raise_copy_alert_if_materialize diff --git a/modin/experimental/core/execution/native/implementations/hdk_on_native/interchange/dataframe_protocol/utils.py b/modin/experimental/core/execution/native/implementations/hdk_on_native/interchange/dataframe_protocol/utils.py index 35e87bce803..5e03cc1d6ab 100644 --- a/modin/experimental/core/execution/native/implementations/hdk_on_native/interchange/dataframe_protocol/utils.py +++ b/modin/experimental/core/execution/native/implementations/hdk_on_native/interchange/dataframe_protocol/utils.py @@ -13,18 +13,18 @@ """Utility functions for the DataFrame exchange protocol implementation for ``HdkOnNative`` execution.""" -import pyarrow as pa -import numpy as np import functools +import numpy as np +import pyarrow as pa + from modin.core.dataframe.base.interchange.dataframe_protocol.utils import ( ArrowCTypes, + DTypeKind, pandas_dtype_to_arrow_c, raise_copy_alert, - DTypeKind, ) - arrow_types_map = { DTypeKind.BOOL: {8: pa.bool_()}, DTypeKind.INT: { diff --git a/modin/experimental/core/execution/native/implementations/hdk_on_native/io/io.py b/modin/experimental/core/execution/native/implementations/hdk_on_native/io/io.py index dd102edbc30..0579a13a191 100644 --- a/modin/experimental/core/execution/native/implementations/hdk_on_native/io/io.py +++ b/modin/experimental/core/execution/native/implementations/hdk_on_native/io/io.py @@ -17,30 +17,28 @@ ``HdkOnNativeIO`` is used for storing IO functions implementations with HDK storage format and Native engine. """ -from csv import Dialect -from typing import Union, Sequence, Callable, Dict, Tuple import functools import inspect import os - -from modin.experimental.core.storage_formats.hdk.query_compiler import ( - DFAlgQueryCompiler, -) -from modin.core.io import BaseIO -from modin.experimental.core.execution.native.implementations.hdk_on_native.dataframe.dataframe import ( - HdkOnNativeDataframe, -) -from modin.error_message import ErrorMessage -from modin.core.io.text.text_file_dispatcher import TextFileDispatcher - -from pyarrow.csv import read_csv, ParseOptions, ConvertOptions, ReadOptions -import pyarrow as pa +from csv import Dialect +from typing import Callable, Dict, Sequence, Tuple, Union import pandas import pandas._libs.lib as lib +import pyarrow as pa from pandas.core.dtypes.common import is_list_like -from pandas.io.common import is_url, get_handle +from pandas.io.common import get_handle, is_url +from pyarrow.csv import ConvertOptions, ParseOptions, ReadOptions, read_csv +from modin.core.io import BaseIO +from modin.core.io.text.text_file_dispatcher import TextFileDispatcher +from modin.error_message import ErrorMessage +from modin.experimental.core.execution.native.implementations.hdk_on_native.dataframe.dataframe import ( + HdkOnNativeDataframe, +) +from modin.experimental.core.storage_formats.hdk.query_compiler import ( + DFAlgQueryCompiler, +) from modin.utils import _inherit_docstrings ReadCsvKwargsType = Dict[ diff --git a/modin/experimental/core/execution/native/implementations/hdk_on_native/partitioning/partition.py b/modin/experimental/core/execution/native/implementations/hdk_on_native/partitioning/partition.py index bb5702f2af3..60003c9d960 100644 --- a/modin/experimental/core/execution/native/implementations/hdk_on_native/partitioning/partition.py +++ b/modin/experimental/core/execution/native/implementations/hdk_on_native/partitioning/partition.py @@ -15,10 +15,10 @@ from typing import Union import pandas - import pyarrow as pa from modin.core.dataframe.pandas.partitioning.partition import PandasDataframePartition + from ..dataframe.utils import arrow_to_pandas from ..db_worker import DbTable diff --git a/modin/experimental/core/execution/native/implementations/hdk_on_native/partitioning/partition_manager.py b/modin/experimental/core/execution/native/implementations/hdk_on_native/partitioning/partition_manager.py index 3d256c80266..a9ce5775ea6 100644 --- a/modin/experimental/core/execution/native/implementations/hdk_on_native/partitioning/partition_manager.py +++ b/modin/experimental/core/execution/native/implementations/hdk_on_native/partitioning/partition_manager.py @@ -13,23 +13,24 @@ """Module provides a partition manager class for ``HdkOnNativeDataframe`` frame.""" -from modin.error_message import ErrorMessage -from modin.pandas.utils import is_scalar +import re + import numpy as np +import pandas +import pyarrow +from modin.config import DoUseCalcite from modin.core.dataframe.pandas.partitioning.partition_manager import ( PandasDataframePartitionManager, ) -from ..dataframe.utils import ColNameCodec -from ..partitioning.partition import HdkOnNativeDataframePartition -from ..db_worker import DbTable, DbWorker +from modin.error_message import ErrorMessage +from modin.pandas.utils import is_scalar + from ..calcite_builder import CalciteBuilder from ..calcite_serializer import CalciteSerializer -from modin.config import DoUseCalcite - -import pyarrow -import pandas -import re +from ..dataframe.utils import ColNameCodec +from ..db_worker import DbTable, DbWorker +from ..partitioning.partition import HdkOnNativeDataframePartition class HdkOnNativeDataframePartitionManager(PandasDataframePartitionManager): diff --git a/modin/experimental/core/execution/native/implementations/hdk_on_native/test/test_dataframe.py b/modin/experimental/core/execution/native/implementations/hdk_on_native/test/test_dataframe.py index 452e60b417d..da7d8eb2c87 100644 --- a/modin/experimental/core/execution/native/implementations/hdk_on_native/test/test_dataframe.py +++ b/modin/experimental/core/execution/native/implementations/hdk_on_native/test/test_dataframe.py @@ -12,54 +12,52 @@ # governing permissions and limitations under the License. import os -import pandas +import re + import numpy as np +import pandas import pyarrow import pytest -import re - from pandas._testing import ensure_clean +from pandas.core.dtypes.common import is_list_like +from pyhdk import __version__ as hdk_version from modin.config import StorageFormat from modin.pandas.test.utils import ( - io_ops_bad_exc, default_to_pandas_ignore_string, + io_ops_bad_exc, random_state, test_data, ) from modin.test.interchange.dataframe_protocol.hdk.utils import split_df_into_chunks -from .utils import eval_io, ForceHdkImport, set_execution_mode, run_and_compare -from pandas.core.dtypes.common import is_list_like -from pyhdk import __version__ as hdk_version +from .utils import ForceHdkImport, eval_io, run_and_compare, set_execution_mode StorageFormat.put("hdk") import modin.pandas as pd +from modin.experimental.core.execution.native.implementations.hdk_on_native.calcite_serializer import ( + CalciteSerializer, +) +from modin.experimental.core.execution.native.implementations.hdk_on_native.df_algebra import ( + FrameNode, +) +from modin.experimental.core.execution.native.implementations.hdk_on_native.partitioning.partition_manager import ( + HdkOnNativeDataframePartitionManager, +) from modin.pandas.test.utils import ( - df_equals, bool_arg_values, - to_pandas, - test_data_values, - test_data_keys, - generate_multiindex, - eval_general, + df_equals, df_equals_with_non_stable_indices, + eval_general, + generate_multiindex, + test_data_keys, + test_data_values, time_parsing_csv_path, + to_pandas, ) -from modin.utils import try_cast_to_pandas from modin.pandas.utils import from_arrow - -from modin.experimental.core.execution.native.implementations.hdk_on_native.partitioning.partition_manager import ( - HdkOnNativeDataframePartitionManager, -) -from modin.experimental.core.execution.native.implementations.hdk_on_native.df_algebra import ( - FrameNode, -) -from modin.experimental.core.execution.native.implementations.hdk_on_native.calcite_serializer import ( - CalciteSerializer, -) - +from modin.utils import try_cast_to_pandas # Our configuration in pytest.ini requires that we explicitly catch all # instances of defaulting to pandas, but some test modules, like this one, diff --git a/modin/experimental/core/execution/native/implementations/hdk_on_native/test/test_init.py b/modin/experimental/core/execution/native/implementations/hdk_on_native/test/test_init.py index 8e63fc97d9c..1d1cfa348b2 100644 --- a/modin/experimental/core/execution/native/implementations/hdk_on_native/test/test_init.py +++ b/modin/experimental/core/execution/native/implementations/hdk_on_native/test/test_init.py @@ -15,6 +15,7 @@ class TestInit: def test_num_threads(self): import os + import modin.pandas as pd assert "OMP_NUM_THREADS" not in os.environ diff --git a/modin/experimental/core/execution/native/implementations/hdk_on_native/test/test_utils.py b/modin/experimental/core/execution/native/implementations/hdk_on_native/test/test_utils.py index dfa064f79be..df8d2dc841f 100644 --- a/modin/experimental/core/execution/native/implementations/hdk_on_native/test/test_utils.py +++ b/modin/experimental/core/execution/native/implementations/hdk_on_native/test/test_utils.py @@ -12,10 +12,11 @@ # governing permissions and limitations under the License. import sys +import timeit +from random import choice, randint, uniform + import pandas import pytz -import timeit -from random import randint, uniform, choice from ..dataframe.utils import ColNameCodec diff --git a/modin/experimental/core/execution/native/implementations/hdk_on_native/test/utils.py b/modin/experimental/core/execution/native/implementations/hdk_on_native/test/utils.py index ea2abfd5e92..ec3e99ad954 100644 --- a/modin/experimental/core/execution/native/implementations/hdk_on_native/test/utils.py +++ b/modin/experimental/core/execution/native/implementations/hdk_on_native/test/utils.py @@ -11,24 +11,18 @@ # ANY KIND, either express or implied. See the License for the specific language # governing permissions and limitations under the License. -import pytest +import datetime +import numpy as np import pandas -from pandas.core.dtypes.common import ( - is_object_dtype, - is_datetime64_any_dtype, -) +import pytest +from pandas.core.dtypes.common import is_datetime64_any_dtype, is_object_dtype import modin.pandas as pd +from modin.pandas.test.utils import df_equals +from modin.pandas.test.utils import eval_io as general_eval_io +from modin.pandas.test.utils import io_ops_bad_exc from modin.utils import try_cast_to_pandas -import datetime -import numpy as np - -from modin.pandas.test.utils import ( - df_equals, - io_ops_bad_exc, - eval_io as general_eval_io, -) def eval_io( diff --git a/modin/experimental/core/execution/ray/implementations/pandas_on_ray/io/io.py b/modin/experimental/core/execution/ray/implementations/pandas_on_ray/io/io.py index a4767eed276..31fbd6d92b3 100644 --- a/modin/experimental/core/execution/ray/implementations/pandas_on_ray/io/io.py +++ b/modin/experimental/core/execution/ray/implementations/pandas_on_ray/io/io.py @@ -18,26 +18,26 @@ Query Compiler API, even if it is only extending the API. """ +from modin.core.execution.ray.common import RayWrapper +from modin.core.execution.ray.implementations.pandas_on_ray.dataframe import ( + PandasOnRayDataframe, +) +from modin.core.execution.ray.implementations.pandas_on_ray.io import PandasOnRayIO +from modin.core.execution.ray.implementations.pandas_on_ray.partitioning import ( + PandasOnRayDataframePartition, +) from modin.core.storage_formats.pandas.parsers import ( - PandasCSVGlobParser, - ExperimentalPandasPickleParser, ExperimentalCustomTextParser, + ExperimentalPandasPickleParser, + PandasCSVGlobParser, ) from modin.core.storage_formats.pandas.query_compiler import PandasQueryCompiler -from modin.core.execution.ray.implementations.pandas_on_ray.io import PandasOnRayIO from modin.experimental.core.io import ( ExperimentalCSVGlobDispatcher, - ExperimentalSQLDispatcher, - ExperimentalPickleDispatcher, ExperimentalCustomTextDispatcher, + ExperimentalPickleDispatcher, + ExperimentalSQLDispatcher, ) -from modin.core.execution.ray.implementations.pandas_on_ray.dataframe import ( - PandasOnRayDataframe, -) -from modin.core.execution.ray.implementations.pandas_on_ray.partitioning import ( - PandasOnRayDataframePartition, -) -from modin.core.execution.ray.common import RayWrapper class ExperimentalPandasOnRayIO(PandasOnRayIO): diff --git a/modin/experimental/core/execution/ray/implementations/pyarrow_on_ray/dataframe/dataframe.py b/modin/experimental/core/execution/ray/implementations/pyarrow_on_ray/dataframe/dataframe.py index 2eb08e459bb..5c139e477ad 100644 --- a/modin/experimental/core/execution/ray/implementations/pyarrow_on_ray/dataframe/dataframe.py +++ b/modin/experimental/core/execution/ray/implementations/pyarrow_on_ray/dataframe/dataframe.py @@ -17,9 +17,10 @@ ``PyarrowOnRayDataframe`` is a dataframe class with PyArrow storage format and Ray engine. """ -from ..partitioning.partition_manager import PyarrowOnRayDataframePartitionManager from modin.core.dataframe.pandas.dataframe.dataframe import PandasDataframe +from ..partitioning.partition_manager import PyarrowOnRayDataframePartitionManager + class PyarrowOnRayDataframe(PandasDataframe): """ diff --git a/modin/experimental/core/execution/ray/implementations/pyarrow_on_ray/io/io.py b/modin/experimental/core/execution/ray/implementations/pyarrow_on_ray/io/io.py index 3079de0ce36..8dd2df0756c 100644 --- a/modin/experimental/core/execution/ray/implementations/pyarrow_on_ray/io/io.py +++ b/modin/experimental/core/execution/ray/implementations/pyarrow_on_ray/io/io.py @@ -13,19 +13,19 @@ """Module for housing IO classes with PyArrow storage format and Ray engine.""" -from modin.experimental.core.storage_formats.pyarrow import ( - PyarrowQueryCompiler, - PyarrowCSVParser, -) +from modin.core.execution.ray.common import RayWrapper from modin.core.execution.ray.generic.io import RayIO +from modin.core.io import CSVDispatcher from modin.experimental.core.execution.ray.implementations.pyarrow_on_ray.dataframe.dataframe import ( PyarrowOnRayDataframe, ) from modin.experimental.core.execution.ray.implementations.pyarrow_on_ray.partitioning.partition import ( PyarrowOnRayDataframePartition, ) -from modin.core.execution.ray.common import RayWrapper -from modin.core.io import CSVDispatcher +from modin.experimental.core.storage_formats.pyarrow import ( + PyarrowCSVParser, + PyarrowQueryCompiler, +) class PyarrowOnRayCSVDispatcher(RayWrapper, PyarrowCSVParser, CSVDispatcher): diff --git a/modin/experimental/core/execution/ray/implementations/pyarrow_on_ray/partitioning/axis_partition.py b/modin/experimental/core/execution/ray/implementations/pyarrow_on_ray/partitioning/axis_partition.py index 2336a9d5e41..2c8ba0304b8 100644 --- a/modin/experimental/core/execution/ray/implementations/pyarrow_on_ray/partitioning/axis_partition.py +++ b/modin/experimental/core/execution/ray/implementations/pyarrow_on_ray/partitioning/axis_partition.py @@ -13,13 +13,14 @@ """The module defines interface for an axis partition with PyArrow storage format and Ray engine.""" +import pyarrow +import ray + from modin.core.dataframe.pandas.partitioning.axis_partition import ( BaseDataframeAxisPartition, ) -from .partition import PyarrowOnRayDataframePartition -import ray -import pyarrow +from .partition import PyarrowOnRayDataframePartition class PyarrowOnRayDataframeAxisPartition(BaseDataframeAxisPartition): diff --git a/modin/experimental/core/execution/ray/implementations/pyarrow_on_ray/partitioning/partition.py b/modin/experimental/core/execution/ray/implementations/pyarrow_on_ray/partitioning/partition.py index ec0f269a29d..3bd094498e6 100644 --- a/modin/experimental/core/execution/ray/implementations/pyarrow_on_ray/partitioning/partition.py +++ b/modin/experimental/core/execution/ray/implementations/pyarrow_on_ray/partitioning/partition.py @@ -15,10 +15,10 @@ import pyarrow +from modin.core.execution.ray.common import RayWrapper from modin.core.execution.ray.implementations.pandas_on_ray.partitioning import ( PandasOnRayDataframePartition, ) -from modin.core.execution.ray.common import RayWrapper class PyarrowOnRayDataframePartition(PandasOnRayDataframePartition): diff --git a/modin/experimental/core/execution/ray/implementations/pyarrow_on_ray/partitioning/partition_manager.py b/modin/experimental/core/execution/ray/implementations/pyarrow_on_ray/partitioning/partition_manager.py index 95203a87983..4c45e9096fd 100644 --- a/modin/experimental/core/execution/ray/implementations/pyarrow_on_ray/partitioning/partition_manager.py +++ b/modin/experimental/core/execution/ray/implementations/pyarrow_on_ray/partitioning/partition_manager.py @@ -16,6 +16,7 @@ from modin.core.execution.ray.generic.partitioning import ( GenericRayDataframePartitionManager, ) + from .axis_partition import ( PyarrowOnRayDataframeColumnPartition, PyarrowOnRayDataframeRowPartition, diff --git a/modin/experimental/core/execution/unidist/implementations/pandas_on_unidist/io/io.py b/modin/experimental/core/execution/unidist/implementations/pandas_on_unidist/io/io.py index 16e394bfd2f..c94b0621a4d 100644 --- a/modin/experimental/core/execution/unidist/implementations/pandas_on_unidist/io/io.py +++ b/modin/experimental/core/execution/unidist/implementations/pandas_on_unidist/io/io.py @@ -18,28 +18,28 @@ Query Compiler API, even if it is only extending the API. """ -from modin.core.storage_formats.pandas.parsers import ( - PandasCSVGlobParser, - ExperimentalPandasPickleParser, - ExperimentalCustomTextParser, +from modin.core.execution.unidist.common import UnidistWrapper +from modin.core.execution.unidist.implementations.pandas_on_unidist.dataframe import ( + PandasOnUnidistDataframe, ) -from modin.core.storage_formats.pandas.query_compiler import PandasQueryCompiler from modin.core.execution.unidist.implementations.pandas_on_unidist.io import ( PandasOnUnidistIO, ) +from modin.core.execution.unidist.implementations.pandas_on_unidist.partitioning import ( + PandasOnUnidistDataframePartition, +) +from modin.core.storage_formats.pandas.parsers import ( + ExperimentalCustomTextParser, + ExperimentalPandasPickleParser, + PandasCSVGlobParser, +) +from modin.core.storage_formats.pandas.query_compiler import PandasQueryCompiler from modin.experimental.core.io import ( ExperimentalCSVGlobDispatcher, - ExperimentalSQLDispatcher, - ExperimentalPickleDispatcher, ExperimentalCustomTextDispatcher, + ExperimentalPickleDispatcher, + ExperimentalSQLDispatcher, ) -from modin.core.execution.unidist.implementations.pandas_on_unidist.dataframe import ( - PandasOnUnidistDataframe, -) -from modin.core.execution.unidist.implementations.pandas_on_unidist.partitioning import ( - PandasOnUnidistDataframePartition, -) -from modin.core.execution.unidist.common import UnidistWrapper class ExperimentalPandasOnUnidistIO(PandasOnUnidistIO): diff --git a/modin/experimental/core/io/__init__.py b/modin/experimental/core/io/__init__.py index 6e281649bd0..9f3c64b00a8 100644 --- a/modin/experimental/core/io/__init__.py +++ b/modin/experimental/core/io/__init__.py @@ -13,9 +13,9 @@ """Experimental IO functions implementations.""" -from .text.csv_glob_dispatcher import ExperimentalCSVGlobDispatcher -from .sql.sql_dispatcher import ExperimentalSQLDispatcher from .pickle.pickle_dispatcher import ExperimentalPickleDispatcher +from .sql.sql_dispatcher import ExperimentalSQLDispatcher +from .text.csv_glob_dispatcher import ExperimentalCSVGlobDispatcher from .text.custom_text_dispatcher import ExperimentalCustomTextDispatcher __all__ = [ diff --git a/modin/experimental/core/io/pickle/pickle_dispatcher.py b/modin/experimental/core/io/pickle/pickle_dispatcher.py index 7d043a19a57..b121e6fbccd 100644 --- a/modin/experimental/core/io/pickle/pickle_dispatcher.py +++ b/modin/experimental/core/io/pickle/pickle_dispatcher.py @@ -18,8 +18,8 @@ import pandas -from modin.core.io.file_dispatcher import FileDispatcher from modin.config import NPartitions +from modin.core.io.file_dispatcher import FileDispatcher from modin.core.storage_formats.pandas.query_compiler import PandasQueryCompiler diff --git a/modin/experimental/core/io/sql/sql_dispatcher.py b/modin/experimental/core/io/sql/sql_dispatcher.py index 0a2a24074ad..bc6285c4107 100644 --- a/modin/experimental/core/io/sql/sql_dispatcher.py +++ b/modin/experimental/core/io/sql/sql_dispatcher.py @@ -15,11 +15,11 @@ import warnings -import pandas import numpy as np +import pandas -from modin.core.io import SQLDispatcher from modin.config import NPartitions +from modin.core.io import SQLDispatcher class ExperimentalSQLDispatcher(SQLDispatcher): @@ -66,10 +66,7 @@ def _read( A new query compiler with imported data for further processing. """ # sql deps are optional, so import only when needed - from modin.experimental.core.io.sql.utils import ( - is_distributed, - get_query_info, - ) + from modin.experimental.core.io.sql.utils import get_query_info, is_distributed if not is_distributed(partition_column, lower_bound, upper_bound): message = "Defaulting to Modin core implementation; \ diff --git a/modin/experimental/core/io/sql/utils.py b/modin/experimental/core/io/sql/utils.py index a157b53ae2d..c201bd29fc1 100644 --- a/modin/experimental/core/io/sql/utils.py +++ b/modin/experimental/core/io/sql/utils.py @@ -15,9 +15,9 @@ from collections import OrderedDict -from sqlalchemy import MetaData, Table, create_engine, inspect import pandas import pandas._libs.lib as lib +from sqlalchemy import MetaData, Table, create_engine, inspect from modin.core.storage_formats.pandas.parsers import _split_result_for_readers diff --git a/modin/experimental/core/io/text/csv_glob_dispatcher.py b/modin/experimental/core/io/text/csv_glob_dispatcher.py index 7de275ee846..e8750e98e85 100644 --- a/modin/experimental/core/io/text/csv_glob_dispatcher.py +++ b/modin/experimental/core/io/text/csv_glob_dispatcher.py @@ -13,17 +13,17 @@ """Module houses `ExperimentalCSVGlobDispatcher` class, that is used for reading multiple `.csv` files simultaneously.""" -from contextlib import ExitStack import csv import glob import os -from typing import List, Tuple import warnings -import fsspec +from contextlib import ExitStack +from typing import List, Tuple +import fsspec import pandas import pandas._libs.lib as lib -from pandas.io.common import is_url, is_fsspec_url, stringify_path +from pandas.io.common import is_fsspec_url, is_url, stringify_path from modin.config import NPartitions from modin.core.io.file_dispatcher import OpenFile @@ -284,9 +284,9 @@ def file_exists(cls, file_path: str, storage_options=None) -> bool: try: from botocore.exceptions import ( - NoCredentialsError, - EndpointConnectionError, ConnectTimeoutError, + EndpointConnectionError, + NoCredentialsError, ) credential_error_type = ( @@ -335,9 +335,9 @@ def get_path(cls, file_path: str) -> list: try: from botocore.exceptions import ( - NoCredentialsError, - EndpointConnectionError, ConnectTimeoutError, + EndpointConnectionError, + NoCredentialsError, ) credential_error_type = ( diff --git a/modin/experimental/core/io/text/custom_text_dispatcher.py b/modin/experimental/core/io/text/custom_text_dispatcher.py index b2f13b53301..5ecead9bd8d 100644 --- a/modin/experimental/core/io/text/custom_text_dispatcher.py +++ b/modin/experimental/core/io/text/custom_text_dispatcher.py @@ -15,9 +15,9 @@ import pandas +from modin.config import NPartitions from modin.core.io.file_dispatcher import OpenFile from modin.core.io.text.text_file_dispatcher import TextFileDispatcher -from modin.config import NPartitions class ExperimentalCustomTextDispatcher(TextFileDispatcher): diff --git a/modin/experimental/core/storage_formats/hdk/query_compiler.py b/modin/experimental/core/storage_formats/hdk/query_compiler.py index 6fb82148d7c..691d6e50a47 100644 --- a/modin/experimental/core/storage_formats/hdk/query_compiler.py +++ b/modin/experimental/core/storage_formats/hdk/query_compiler.py @@ -17,22 +17,24 @@ ``DFAlgQueryCompiler`` is used for lazy DataFrame Algebra based engine. """ -from modin.core.storage_formats.base.query_compiler import ( - BaseQueryCompiler, - _set_axis as default_axis_setter, - _get_axis as default_axis_getter, -) -from modin.core.storage_formats.pandas.query_compiler import PandasQueryCompiler -from modin.utils import _inherit_docstrings, MODIN_UNNAMED_SERIES_LABEL -from modin.error_message import ErrorMessage +from functools import wraps +import numpy as np import pandas from pandas._libs.lib import no_default from pandas.core.common import is_bool_indexer -from pandas.core.dtypes.common import is_list_like, is_bool_dtype, is_integer_dtype -from functools import wraps +from pandas.core.dtypes.common import is_bool_dtype, is_integer_dtype, is_list_like -import numpy as np +from modin.core.storage_formats.base.query_compiler import BaseQueryCompiler +from modin.core.storage_formats.base.query_compiler import ( + _get_axis as default_axis_getter, +) +from modin.core.storage_formats.base.query_compiler import ( + _set_axis as default_axis_setter, +) +from modin.core.storage_formats.pandas.query_compiler import PandasQueryCompiler +from modin.error_message import ErrorMessage +from modin.utils import MODIN_UNNAMED_SERIES_LABEL, _inherit_docstrings def is_inoperable(value): diff --git a/modin/experimental/core/storage_formats/pyarrow/__init__.py b/modin/experimental/core/storage_formats/pyarrow/__init__.py index 778627acb60..12bae94625f 100644 --- a/modin/experimental/core/storage_formats/pyarrow/__init__.py +++ b/modin/experimental/core/storage_formats/pyarrow/__init__.py @@ -13,7 +13,7 @@ """Experimental Modin functionality specific to PyArrow storage format.""" -from .query_compiler import PyarrowQueryCompiler from .parsers import PyarrowCSVParser +from .query_compiler import PyarrowQueryCompiler __all__ = ["PyarrowQueryCompiler", "PyarrowCSVParser"] diff --git a/modin/experimental/core/storage_formats/pyarrow/parsers.py b/modin/experimental/core/storage_formats/pyarrow/parsers.py index ace2b938540..ecefd2f63a5 100644 --- a/modin/experimental/core/storage_formats/pyarrow/parsers.py +++ b/modin/experimental/core/storage_formats/pyarrow/parsers.py @@ -13,9 +13,10 @@ """Module houses Modin parser classes, that are used for data parsing on the workers.""" -import pandas from io import BytesIO +import pandas + from modin.core.storage_formats.pandas.utils import compute_chunksize diff --git a/modin/experimental/fuzzydata/test/test_fuzzydata.py b/modin/experimental/fuzzydata/test/test_fuzzydata.py index d110f940a1c..57e11b45ea6 100644 --- a/modin/experimental/fuzzydata/test/test_fuzzydata.py +++ b/modin/experimental/fuzzydata/test/test_fuzzydata.py @@ -11,12 +11,13 @@ # ANY KIND, either express or implied. See the License for the specific language # governing permissions and limitations under the License. -import os import glob -import uuid +import os import shutil -from fuzzydata.core.generator import generate_workflow +import uuid + from fuzzydata.clients.modin import ModinWorkflow +from fuzzydata.core.generator import generate_workflow from modin.config import Engine diff --git a/modin/experimental/pandas/__init__.py b/modin/experimental/pandas/__init__.py index c2b55bd7a65..0deba579ecb 100644 --- a/modin/experimental/pandas/__init__.py +++ b/modin/experimental/pandas/__init__.py @@ -36,15 +36,17 @@ IsExperimental.put(True) +import warnings + from modin.pandas import * # noqa F401, F403 + from .io import ( # noqa F401 - read_sql, read_csv_glob, read_custom_text, read_pickle_distributed, + read_sql, to_pickle_distributed, ) -import warnings setattr(DataFrame, "to_pickle_distributed", to_pickle_distributed) # noqa: F405 diff --git a/modin/experimental/pandas/io.py b/modin/experimental/pandas/io.py index 0b653a26eba..fb3b1df1c42 100644 --- a/modin/experimental/pandas/io.py +++ b/modin/experimental/pandas/io.py @@ -16,17 +16,18 @@ import inspect import pathlib import pickle -from typing import Union, IO, AnyStr, Callable, Optional, Iterator +from typing import IO, AnyStr, Callable, Iterator, Optional, Union import pandas import pandas._libs.lib as lib from pandas._typing import CompressionOptions, StorageOptions -from . import DataFrame from modin.config import IsExperimental from modin.core.storage_formats import BaseQueryCompiler from modin.utils import expanduser_path_arg +from . import DataFrame + def read_sql( sql, diff --git a/modin/experimental/pandas/test/test_io_exp.py b/modin/experimental/pandas/test/test_io_exp.py index 48aaf2e2891..1295fc36816 100644 --- a/modin/experimental/pandas/test/test_io_exp.py +++ b/modin/experimental/pandas/test/test_io_exp.py @@ -11,25 +11,26 @@ # ANY KIND, either express or implied. See the License for the specific language # governing permissions and limitations under the License. -from contextlib import nullcontext import glob import json +from contextlib import nullcontext import numpy as np import pandas -from pandas._testing import ensure_clean import pytest +from pandas._testing import ensure_clean import modin.experimental.pandas as pd -from modin.config import Engine, AsyncReadMode +from modin.config import AsyncReadMode, Engine from modin.pandas.test.utils import ( df_equals, + eval_general, + parse_dates_values_by_id, teardown_test_files, test_data, - eval_general, + time_parsing_csv_path, ) from modin.test.test_utils import warns_that_defaulting_to_pandas -from modin.pandas.test.utils import parse_dates_values_by_id, time_parsing_csv_path from modin.utils import try_cast_to_pandas diff --git a/modin/experimental/spreadsheet/general.py b/modin/experimental/spreadsheet/general.py index de0458a5b13..e9b6444adaf 100644 --- a/modin/experimental/spreadsheet/general.py +++ b/modin/experimental/spreadsheet/general.py @@ -11,8 +11,9 @@ # ANY KIND, either express or implied. See the License for the specific language # governing permissions and limitations under the License. +from modin_spreadsheet import SpreadsheetWidget, show_grid + from .. import pandas as pd -from modin_spreadsheet import show_grid, SpreadsheetWidget def from_dataframe( diff --git a/modin/experimental/spreadsheet/test/test_general.py b/modin/experimental/spreadsheet/test/test_general.py index 081380cc40d..bd9e73405cd 100644 --- a/modin/experimental/spreadsheet/test/test_general.py +++ b/modin/experimental/spreadsheet/test/test_general.py @@ -11,13 +11,14 @@ # ANY KIND, either express or implied. See the License for the specific language # governing permissions and limitations under the License. +import numpy as np import pandas import pytest -import modin.pandas as pd -import modin.experimental.spreadsheet as mss -import numpy as np from modin_spreadsheet import SpreadsheetWidget +import modin.experimental.spreadsheet as mss +import modin.pandas as pd + def get_test_data(): return { diff --git a/modin/experimental/sql/__init__.py b/modin/experimental/sql/__init__.py index f8ca5fe9c70..0aea01598fb 100644 --- a/modin/experimental/sql/__init__.py +++ b/modin/experimental/sql/__init__.py @@ -11,9 +11,8 @@ # ANY KIND, either express or implied. See the License for the specific language # governing permissions and limitations under the License. -import modin.pandas as pd import modin.config as cfg - +import modin.pandas as pd _query_impl = None diff --git a/modin/experimental/sql/hdk/query.py b/modin/experimental/sql/hdk/query.py index 452b6926960..9a3ec8effa2 100644 --- a/modin/experimental/sql/hdk/query.py +++ b/modin/experimental/sql/hdk/query.py @@ -18,11 +18,11 @@ from modin.experimental.core.execution.native.implementations.hdk_on_native.dataframe.utils import ( ColNameCodec, ) -from modin.pandas.utils import from_arrow -from modin.experimental.core.storage_formats.hdk import DFAlgQueryCompiler from modin.experimental.core.execution.native.implementations.hdk_on_native.hdk_worker import ( HdkWorker, ) +from modin.experimental.core.storage_formats.hdk import DFAlgQueryCompiler +from modin.pandas.utils import from_arrow def hdk_query(query: str, **kwargs) -> pd.DataFrame: diff --git a/modin/experimental/sql/test/test_sql.py b/modin/experimental/sql/test/test_sql.py index 160efc8d125..60b74462921 100644 --- a/modin/experimental/sql/test/test_sql.py +++ b/modin/experimental/sql/test/test_sql.py @@ -11,13 +11,14 @@ # ANY KIND, either express or implied. See the License for the specific language # governing permissions and limitations under the License. +import io + import pandas +import pytest + import modin.pandas as pd -from modin.pandas.test.utils import default_to_pandas_ignore_string, df_equals from modin.config import StorageFormat - -import io -import pytest +from modin.pandas.test.utils import default_to_pandas_ignore_string, df_equals pytestmark = pytest.mark.filterwarnings(default_to_pandas_ignore_string) diff --git a/modin/experimental/xgboost/__init__.py b/modin/experimental/xgboost/__init__.py index e8de573f419..a9aa621d808 100644 --- a/modin/experimental/xgboost/__init__.py +++ b/modin/experimental/xgboost/__init__.py @@ -13,6 +13,6 @@ """Module holds public interfaces for Modin XGBoost.""" -from .xgboost import DMatrix, Booster, train +from .xgboost import Booster, DMatrix, train __all__ = ["DMatrix", "Booster", "train"] diff --git a/modin/experimental/xgboost/test/test_default.py b/modin/experimental/xgboost/test/test_default.py index 9504e3680c5..ed38f690b5c 100644 --- a/modin/experimental/xgboost/test/test_default.py +++ b/modin/experimental/xgboost/test/test_default.py @@ -13,10 +13,10 @@ import pytest -from modin.config import Engine import modin.experimental.xgboost as xgb import modin.pandas as pd +from modin.config import Engine @pytest.mark.skipif( diff --git a/modin/experimental/xgboost/test/test_dmatrix.py b/modin/experimental/xgboost/test/test_dmatrix.py index 0e63268c14d..dac547f3139 100644 --- a/modin/experimental/xgboost/test/test_dmatrix.py +++ b/modin/experimental/xgboost/test/test_dmatrix.py @@ -12,18 +12,17 @@ # governing permissions and limitations under the License. import numpy as np -import pytest import pandas -from sklearn.metrics import accuracy_score -from sklearn.datasets import load_breast_cancer +import pytest import xgboost as xgb +from sklearn.datasets import load_breast_cancer +from sklearn.metrics import accuracy_score +import modin.experimental.xgboost as mxgb import modin.pandas as pd from modin.config import Engine -import modin.experimental.xgboost as mxgb from modin.utils import try_cast_to_pandas - if Engine.get() != "Ray": pytest.skip( "Modin' xgboost extension works only with Ray engine.", diff --git a/modin/experimental/xgboost/test/test_xgboost.py b/modin/experimental/xgboost/test/test_xgboost.py index 5da91853337..38684e48494 100644 --- a/modin/experimental/xgboost/test/test_xgboost.py +++ b/modin/experimental/xgboost/test/test_xgboost.py @@ -12,28 +12,26 @@ # governing permissions and limitations under the License. -import pytest +import multiprocessing as mp -import modin -import modin.experimental.xgboost as xgb -import modin.pandas as pd -from modin.config import Engine import numpy as np +import pytest +import ray import xgboost - -from modin.experimental.sklearn.model_selection.train_test_split import train_test_split from sklearn.datasets import ( - load_iris, + load_breast_cancer, load_diabetes, load_digits, + load_iris, load_wine, - load_breast_cancer, ) from sklearn.metrics import accuracy_score, mean_squared_error -import multiprocessing as mp -import ray - +import modin +import modin.experimental.xgboost as xgb +import modin.pandas as pd +from modin.config import Engine +from modin.experimental.sklearn.model_selection.train_test_split import train_test_split if Engine.get() != "Ray": pytest.skip("Implemented only for Ray engine.", allow_module_level=True) diff --git a/modin/experimental/xgboost/utils.py b/modin/experimental/xgboost/utils.py index 61b9a66f57a..a8dbc1fb041 100644 --- a/modin/experimental/xgboost/utils.py +++ b/modin/experimental/xgboost/utils.py @@ -14,6 +14,7 @@ """Module holds classes for work with Rabit all-reduce context.""" import logging + import xgboost as xgb LOGGER = logging.getLogger("[modin.xgboost]") diff --git a/modin/experimental/xgboost/xgboost.py b/modin/experimental/xgboost/xgboost.py index ce940129f54..cdc8723d14a 100644 --- a/modin/experimental/xgboost/xgboost.py +++ b/modin/experimental/xgboost/xgboost.py @@ -18,9 +18,9 @@ import xgboost as xgb +import modin.pandas as pd from modin.config import Engine from modin.distributed.dataframe.pandas import unwrap_partitions -import modin.pandas as pd LOGGER = logging.getLogger("[modin.xgboost]") diff --git a/modin/experimental/xgboost/xgboost_ray.py b/modin/experimental/xgboost/xgboost_ray.py index f23577a17c4..df32cabe6bf 100644 --- a/modin/experimental/xgboost/xgboost_ray.py +++ b/modin/experimental/xgboost/xgboost_ray.py @@ -18,21 +18,22 @@ on remote workers. Other functions create Ray actors, distribute data between them, etc. """ -import time import logging -from typing import Dict, List import math -from collections import defaultdict +import time import warnings +from collections import defaultdict +from typing import Dict, List import numpy as np -import xgboost as xgb +import pandas import ray +import xgboost as xgb from ray.util import get_node_ip_address -import pandas -from modin.distributed.dataframe.pandas import from_partitions from modin.core.execution.ray.common import RayWrapper +from modin.distributed.dataframe.pandas import from_partitions + from .utils import RabitContext, RabitContextManager LOGGER = logging.getLogger("[modin.xgboost]") diff --git a/modin/logging/__init__.py b/modin/logging/__init__.py index c7535666200..a8ae2fd1f2f 100644 --- a/modin/logging/__init__.py +++ b/modin/logging/__init__.py @@ -13,7 +13,7 @@ from .class_logger import ClassLogger # noqa: F401 from .config import get_logger # noqa: F401 -from .logger_decorator import enable_logging, disable_logging # noqa: F401 +from .logger_decorator import disable_logging, enable_logging # noqa: F401 __all__ = [ "ClassLogger", diff --git a/modin/logging/config.py b/modin/logging/config.py index f36614ea1bd..7a383d90eb8 100644 --- a/modin/logging/config.py +++ b/modin/logging/config.py @@ -17,20 +17,21 @@ ``ModinFormatter`` and the associated functions are used for logging configuration. """ -import logging -from logging.handlers import RotatingFileHandler import datetime as dt -import uuid +import logging import platform -import psutil -import pandas import threading import time -from typing import Optional +import uuid +from logging.handlers import RotatingFileHandler from pathlib import Path +from typing import Optional + +import pandas +import psutil import modin -from modin.config import LogMemoryInterval, LogFileSize, LogMode +from modin.config import LogFileSize, LogMemoryInterval, LogMode __LOGGER_CONFIGURED__: bool = False diff --git a/modin/logging/logger_decorator.py b/modin/logging/logger_decorator.py index 90fadef3854..b0f576198e8 100644 --- a/modin/logging/logger_decorator.py +++ b/modin/logging/logger_decorator.py @@ -17,12 +17,13 @@ ``enable_logging`` is used for decorating individual Modin functions or classes. """ -from typing import Any, Optional, Callable, Dict, Union, Type, Tuple -from types import FunctionType, MethodType from functools import wraps from logging import Logger +from types import FunctionType, MethodType +from typing import Any, Callable, Dict, Optional, Tuple, Type, Union from modin.config import LogMode + from .config import get_logger _MODIN_LOGGER_NOWRAP = "__modin_logging_nowrap__" diff --git a/modin/numpy/__init__.py b/modin/numpy/__init__.py index c0e00afa38e..765fc615786 100644 --- a/modin/numpy/__init__.py +++ b/modin/numpy/__init__.py @@ -11,100 +11,80 @@ # ANY KIND, either express or implied. See the License for the specific language # governing permissions and limitations under the License. +from . import linalg from .arr import array - -from .array_creation import ( - zeros_like, - ones_like, - tri, -) - -from .array_shaping import ( - ravel, - shape, - transpose, - hstack, - split, - append, +from .array_creation import ones_like, tri, zeros_like +from .array_shaping import append, hstack, ravel, shape, split, transpose +from .constants import ( + NAN, + NINF, + NZERO, + PINF, + PZERO, + Inf, + Infinity, + NaN, + e, + euler_gamma, + inf, + infty, + nan, + newaxis, + pi, ) - from .logic import ( all, any, + equal, + greater, + greater_equal, + iscomplex, isfinite, isinf, isnan, isnat, isneginf, isposinf, - iscomplex, isreal, isscalar, - logical_not, + less, + less_equal, logical_and, + logical_not, logical_or, logical_xor, - greater, - greater_equal, - less, - less_equal, - equal, not_equal, ) - from .math import ( - absolute, abs, + absolute, add, + amax, + amin, + argmax, + argmin, divide, dot, + exp, float_power, floor_divide, + max, + maximum, + mean, + min, + minimum, + mod, + multiply, power, prod, - multiply, remainder, - mod, + sqrt, subtract, sum, true_divide, - mean, - maximum, - amax, - max, - minimum, - amin, - min, - sqrt, - exp, - argmax, - argmin, var, ) - -from .trigonometry import ( - tanh, -) - -from .constants import ( - Inf, - Infinity, - NAN, - NINF, - NZERO, - NaN, - PINF, - PZERO, - e, - euler_gamma, - inf, - infty, - nan, - newaxis, - pi, -) - -from . import linalg +from .trigonometry import tanh def where(condition, x=None, y=None): diff --git a/modin/numpy/arr.py b/modin/numpy/arr.py index e27115fe762..6bf7b3186c5 100644 --- a/modin/numpy/arr.py +++ b/modin/numpy/arr.py @@ -13,20 +13,17 @@ """Module houses ``array`` class, that is distributed version of ``numpy.array``.""" +from inspect import signature from math import prod + import numpy import pandas -from pandas.core.dtypes.common import is_list_like, is_numeric_dtype, is_bool_dtype from pandas.api.types import is_scalar -from inspect import signature +from pandas.core.dtypes.common import is_bool_dtype, is_list_like, is_numeric_dtype import modin.pandas as pd +from modin.core.dataframe.algebra import Binary, Map, Reduce from modin.error_message import ErrorMessage -from modin.core.dataframe.algebra import ( - Map, - Reduce, - Binary, -) from .utils import try_convert_from_interoperable_type @@ -427,7 +424,9 @@ def __array_ufunc__(self, ufunc, method, *inputs, **kwargs): ) def __array_function__(self, func, types, args, kwargs): - from . import array_creation as creation, array_shaping as shaping, math + from . import array_creation as creation + from . import array_shaping as shaping + from . import math func_name = func.__name__ modin_func = None diff --git a/modin/numpy/array_creation.py b/modin/numpy/array_creation.py index 4898fba5335..803eb723a23 100644 --- a/modin/numpy/array_creation.py +++ b/modin/numpy/array_creation.py @@ -16,6 +16,7 @@ import numpy from modin.error_message import ErrorMessage + from .arr import array diff --git a/modin/numpy/array_shaping.py b/modin/numpy/array_shaping.py index c32c7b1c480..e96acf5efaf 100644 --- a/modin/numpy/array_shaping.py +++ b/modin/numpy/array_shaping.py @@ -16,6 +16,7 @@ import numpy from modin.error_message import ErrorMessage + from .arr import array from .utils import try_convert_from_interoperable_type diff --git a/modin/numpy/constants.py b/modin/numpy/constants.py index 96b91503aa5..6070901e5f6 100644 --- a/modin/numpy/constants.py +++ b/modin/numpy/constants.py @@ -13,14 +13,14 @@ # flake8: noqa from numpy import ( - Inf, - Infinity, NAN, NINF, NZERO, - NaN, PINF, PZERO, + Inf, + Infinity, + NaN, e, euler_gamma, inf, diff --git a/modin/numpy/indexing.py b/modin/numpy/indexing.py index aabe28efa8e..c0de8a4fe44 100644 --- a/modin/numpy/indexing.py +++ b/modin/numpy/indexing.py @@ -29,17 +29,19 @@ https://github.com/ray-project/ray/pull/1955#issuecomment-386781826 """ +import itertools + import numpy as np import pandas -import itertools -from pandas.api.types import is_list_like, is_bool -from pandas.core.dtypes.common import is_integer, is_bool_dtype, is_integer_dtype +from pandas.api.types import is_bool, is_list_like +from pandas.core.dtypes.common import is_bool_dtype, is_integer, is_integer_dtype from pandas.core.indexing import IndexingError + from modin.error_message import ErrorMessage +from modin.pandas.indexing import compute_sliced_len, is_range_like, is_slice, is_tuple +from modin.pandas.utils import is_scalar from .arr import array -from modin.pandas.utils import is_scalar -from modin.pandas.indexing import compute_sliced_len, is_tuple, is_slice, is_range_like def broadcast_item( diff --git a/modin/numpy/linalg.py b/modin/numpy/linalg.py index 4a3818a3e92..d6494dcddd4 100644 --- a/modin/numpy/linalg.py +++ b/modin/numpy/linalg.py @@ -13,9 +13,10 @@ import numpy +from modin.error_message import ErrorMessage + from .arr import array from .utils import try_convert_from_interoperable_type -from modin.error_message import ErrorMessage def norm(x, ord=None, axis=None, keepdims=False): diff --git a/modin/numpy/logic.py b/modin/numpy/logic.py index 66e90dfb901..3969238d7b8 100644 --- a/modin/numpy/logic.py +++ b/modin/numpy/logic.py @@ -13,11 +13,12 @@ import numpy -from .arr import array -from .utils import try_convert_from_interoperable_type from modin.error_message import ErrorMessage from modin.utils import _inherit_docstrings +from .arr import array +from .utils import try_convert_from_interoperable_type + def _dispatch_logic(operator_name): @_inherit_docstrings(getattr(numpy, operator_name)) diff --git a/modin/numpy/math.py b/modin/numpy/math.py index c3345e9a5c0..1c86ccb3d97 100644 --- a/modin/numpy/math.py +++ b/modin/numpy/math.py @@ -13,11 +13,12 @@ import numpy -from .arr import array -from .utils import try_convert_from_interoperable_type from modin.error_message import ErrorMessage from modin.utils import _inherit_docstrings +from .arr import array +from .utils import try_convert_from_interoperable_type + def _dispatch_math(operator_name, arr_method_name=None): # `operator_name` is the name of the method on the numpy API diff --git a/modin/numpy/test/test_array.py b/modin/numpy/test/test_array.py index 212cc2db3c4..9409da57d65 100644 --- a/modin/numpy/test/test_array.py +++ b/modin/numpy/test/test_array.py @@ -11,11 +11,13 @@ # ANY KIND, either express or implied. See the License for the specific language # governing permissions and limitations under the License. +import warnings + import numpy import pytest -import warnings import modin.numpy as np + from .utils import assert_scalar_or_array_equal @@ -87,11 +89,11 @@ def test_conversion(): def test_to_df(): + import pandas + import modin.pandas as pd from modin.pandas.test.utils import df_equals - import pandas - modin_df = pd.DataFrame(np.array([1, 2, 3])) pandas_df = pandas.DataFrame(numpy.array([1, 2, 3])) df_equals(pandas_df, modin_df) @@ -113,11 +115,11 @@ def test_to_df(): def test_to_series(): + import pandas + import modin.pandas as pd from modin.pandas.test.utils import df_equals - import pandas - with pytest.raises(ValueError, match="Data must be 1-dimensional"): pd.Series(np.array([[1, 2, 3], [4, 5, 6]])) modin_series = pd.Series(np.array([1, 2, 3]), index=pd.Index([-1, -2, -3])) diff --git a/modin/numpy/test/test_array_arithmetic.py b/modin/numpy/test/test_array_arithmetic.py index acabfb87749..19e3bcc30ac 100644 --- a/modin/numpy/test/test_array_arithmetic.py +++ b/modin/numpy/test/test_array_arithmetic.py @@ -15,6 +15,7 @@ import pytest import modin.numpy as np + from .utils import assert_scalar_or_array_equal diff --git a/modin/numpy/test/test_array_axis_functions.py b/modin/numpy/test/test_array_axis_functions.py index d3c8aa20672..b4edbab8bab 100644 --- a/modin/numpy/test/test_array_axis_functions.py +++ b/modin/numpy/test/test_array_axis_functions.py @@ -15,6 +15,7 @@ import pytest import modin.numpy as np + from .utils import assert_scalar_or_array_equal diff --git a/modin/numpy/test/test_array_creation.py b/modin/numpy/test/test_array_creation.py index 28f61d1e57d..60e201d589e 100644 --- a/modin/numpy/test/test_array_creation.py +++ b/modin/numpy/test/test_array_creation.py @@ -14,6 +14,7 @@ import numpy import modin.numpy as np + from .utils import assert_scalar_or_array_equal diff --git a/modin/numpy/test/test_array_indexing.py b/modin/numpy/test/test_array_indexing.py index 32bb42ab393..96d34a2d5fe 100644 --- a/modin/numpy/test/test_array_indexing.py +++ b/modin/numpy/test/test_array_indexing.py @@ -16,6 +16,7 @@ from pandas.core.dtypes.common import is_list_like import modin.numpy as np + from .utils import assert_scalar_or_array_equal diff --git a/modin/numpy/test/test_array_linalg.py b/modin/numpy/test/test_array_linalg.py index 57bf9b14d51..78b8884da58 100644 --- a/modin/numpy/test/test_array_linalg.py +++ b/modin/numpy/test/test_array_linalg.py @@ -12,13 +12,14 @@ # governing permissions and limitations under the License. -import pytest import numpy import numpy.linalg as NLA +import pytest -import modin.pandas as pd import modin.numpy as np import modin.numpy.linalg as LA +import modin.pandas as pd + from .utils import assert_scalar_or_array_equal diff --git a/modin/numpy/test/test_array_logic.py b/modin/numpy/test/test_array_logic.py index 1697c1018c2..739b2fcac04 100644 --- a/modin/numpy/test/test_array_logic.py +++ b/modin/numpy/test/test_array_logic.py @@ -11,12 +11,12 @@ # ANY KIND, either express or implied. See the License for the specific language # governing permissions and limitations under the License. -import pytest import numpy +import pytest import modin.numpy as np -from .utils import assert_scalar_or_array_equal +from .utils import assert_scalar_or_array_equal small_arr_c_2d = numpy.array( [ diff --git a/modin/numpy/test/test_array_math.py b/modin/numpy/test/test_array_math.py index b142200feab..2b0e6dc7b1a 100644 --- a/modin/numpy/test/test_array_math.py +++ b/modin/numpy/test/test_array_math.py @@ -15,6 +15,7 @@ import pytest import modin.numpy as np + from .utils import assert_scalar_or_array_equal diff --git a/modin/numpy/test/test_array_shaping.py b/modin/numpy/test/test_array_shaping.py index c954eb98bdc..069e241f0df 100644 --- a/modin/numpy/test/test_array_shaping.py +++ b/modin/numpy/test/test_array_shaping.py @@ -15,6 +15,7 @@ import pytest import modin.numpy as np + from .utils import assert_scalar_or_array_equal diff --git a/modin/numpy/trigonometry.py b/modin/numpy/trigonometry.py index a7f677b582f..82682a79f59 100644 --- a/modin/numpy/trigonometry.py +++ b/modin/numpy/trigonometry.py @@ -13,9 +13,10 @@ import numpy +from modin.error_message import ErrorMessage + from .arr import array from .utils import try_convert_from_interoperable_type -from modin.error_message import ErrorMessage def tanh( diff --git a/modin/numpy/utils.py b/modin/numpy/utils.py index 637f985099d..916fc1d1f09 100644 --- a/modin/numpy/utils.py +++ b/modin/numpy/utils.py @@ -13,8 +13,8 @@ """Collection of array utility functions for internal use.""" -import modin.pandas as pd import modin.numpy as np +import modin.pandas as pd _INTEROPERABLE_TYPES = (pd.DataFrame, pd.Series) diff --git a/modin/pandas/__init__.py b/modin/pandas/__init__.py index 30cde4ba4d1..723024c48ff 100644 --- a/modin/pandas/__init__.py +++ b/modin/pandas/__init__.py @@ -11,8 +11,9 @@ # ANY KIND, either express or implied. See the License for the specific language # governing permissions and limitations under the License. -import pandas import warnings + +import pandas from packaging import version __pandas_version__ = "2.1" @@ -91,6 +92,7 @@ Float64Dtype, from_dummies, ) + import os from modin.config import Parameter @@ -99,7 +101,7 @@ def _update_engine(publisher: Parameter): - from modin.config import Engine, StorageFormat, CpuCount + from modin.config import CpuCount, Engine, StorageFormat from modin.config.envvars import IsExperimental from modin.config.pubsub import ValueSource @@ -161,62 +163,62 @@ def _update_engine(publisher: Parameter): _is_first_update[publisher.get()] = False +from modin.utils import show_versions + from .. import __version__ from .dataframe import DataFrame -from .io import ( - read_csv, - read_parquet, - read_json, - read_html, - read_clipboard, - read_excel, - read_hdf, - read_feather, - read_stata, - read_sas, - read_pickle, - read_sql, - read_gbq, - read_table, - read_fwf, - read_sql_table, - read_sql_query, - read_spss, - ExcelFile, - to_pickle, - HDFStore, - json_normalize, - read_orc, - read_xml, -) -from .series import Series from .general import ( concat, + crosstab, + cut, + get_dummies, isna, isnull, + lreshape, + melt, merge, merge_asof, merge_ordered, - notnull, notna, + notnull, pivot, - to_numeric, + pivot_table, qcut, to_datetime, + to_numeric, + to_timedelta, unique, value_counts, - get_dummies, - melt, - crosstab, - lreshape, wide_to_long, - to_timedelta, - pivot_table, - cut, ) - +from .io import ( + ExcelFile, + HDFStore, + json_normalize, + read_clipboard, + read_csv, + read_excel, + read_feather, + read_fwf, + read_gbq, + read_hdf, + read_html, + read_json, + read_orc, + read_parquet, + read_pickle, + read_sas, + read_spss, + read_sql, + read_sql_query, + read_sql_table, + read_stata, + read_table, + read_xml, + to_pickle, +) from .plotting import Plotting as plotting -from modin.utils import show_versions +from .series import Series __all__ = [ # noqa: F405 "DataFrame", diff --git a/modin/pandas/accessor.py b/modin/pandas/accessor.py index 4b7fe1d76fc..611175166cf 100644 --- a/modin/pandas/accessor.py +++ b/modin/pandas/accessor.py @@ -26,8 +26,8 @@ from modin import pandas as pd from modin.error_message import ErrorMessage -from modin.utils import _inherit_docstrings from modin.logging import ClassLogger +from modin.utils import _inherit_docstrings class BaseSparseAccessor(ClassLogger): diff --git a/modin/pandas/base.py b/modin/pandas/base.py index 13fd3310261..4047434fec4 100644 --- a/modin/pandas/base.py +++ b/modin/pandas/base.py @@ -12,57 +12,59 @@ # governing permissions and limitations under the License. """Implement DataFrame/Series public API as pandas does.""" from __future__ import annotations + +import pickle as pkl +import re +import warnings +from typing import Any, Hashable, Optional, Sequence, Union + import numpy as np import pandas +import pandas.core.generic +import pandas.core.resample +import pandas.core.window.rolling +from pandas._libs import lib +from pandas._libs.tslibs import to_offset +from pandas._typing import ( + Axis, + CompressionOptions, + DtypeBackend, + IndexKeyFunc, + IndexLabel, + Level, + RandomState, + StorageOptions, + TimedeltaConvertibleTypes, + TimestampConvertibleTypes, + npt, +) from pandas.compat import numpy as numpy_compat from pandas.core.common import count_not_none, pipe -from pandas.core.methods.describe import _refine_percentiles from pandas.core.dtypes.common import ( - is_list_like, - is_dict_like, is_bool_dtype, + is_dict_like, + is_dtype_equal, + is_integer, is_integer_dtype, + is_list_like, is_numeric_dtype, - is_dtype_equal, is_object_dtype, - is_integer, ) from pandas.core.indexes.api import ensure_index -import pandas.core.window.rolling -import pandas.core.resample -import pandas.core.generic +from pandas.core.methods.describe import _refine_percentiles from pandas.util._validators import ( - validate_percentile, - validate_bool_kwarg, validate_ascending, + validate_bool_kwarg, + validate_percentile, ) -from pandas._libs import lib -from pandas._libs.tslibs import to_offset -from pandas._typing import ( - IndexKeyFunc, - StorageOptions, - CompressionOptions, - Axis, - IndexLabel, - Level, - TimedeltaConvertibleTypes, - TimestampConvertibleTypes, - RandomState, - DtypeBackend, - npt, -) -import pickle as pkl -import re -from typing import Optional, Union, Sequence, Hashable, Any -import warnings - -from .utils import is_full_grab_slice, _doc_binary_op -from modin.utils import try_cast_to_pandas, _inherit_docstrings, expanduser_path_arg -from modin.error_message import ErrorMessage from modin import pandas as pd +from modin.error_message import ErrorMessage +from modin.logging import ClassLogger, disable_logging from modin.pandas.utils import is_scalar -from modin.logging import disable_logging, ClassLogger +from modin.utils import _inherit_docstrings, expanduser_path_arg, try_cast_to_pandas + +from .utils import _doc_binary_op, is_full_grab_slice # Similar to pandas, sentinel value to use as kwarg in place of None when None has # special meaning and needs to be distinguished from a user explicitly passing None. diff --git a/modin/pandas/dataframe.py b/modin/pandas/dataframe.py index a4a37bbf7b3..74690e6a8f9 100644 --- a/modin/pandas/dataframe.py +++ b/modin/pandas/dataframe.py @@ -14,7 +14,19 @@ """Module houses ``DataFrame`` class, that is distributed version of ``pandas.DataFrame``.""" from __future__ import annotations + +import datetime +import functools +import itertools +import re +import sys +import warnings +from typing import IO, Hashable, Iterator, Optional, Sequence, Union + +import numpy as np import pandas +from pandas._libs import lib +from pandas._typing import CompressionOptions, FilePath, StorageOptions, WriteBuffer from pandas.core.common import apply_if_callable, get_cython_func from pandas.core.computation.eval import _check_engine from pandas.core.dtypes.common import ( @@ -24,48 +36,33 @@ is_numeric_dtype, ) from pandas.core.indexes.frozen import FrozenList -from pandas.util._validators import validate_bool_kwarg from pandas.io.formats.info import DataFrameInfo -from pandas._libs import lib -from pandas._typing import ( - CompressionOptions, - WriteBuffer, - FilePath, - StorageOptions, -) - -import datetime -import re -import itertools -import functools -import numpy as np -import sys -from typing import IO, Optional, Union, Iterator, Hashable, Sequence -import warnings +from pandas.util._validators import validate_bool_kwarg +from modin.config import PersistentPickle +from modin.error_message import ErrorMessage from modin.logging import disable_logging from modin.pandas import Categorical -from modin.error_message import ErrorMessage from modin.utils import ( + MODIN_UNNAMED_SERIES_LABEL, _inherit_docstrings, - to_pandas, + expanduser_path_arg, hashable, - MODIN_UNNAMED_SERIES_LABEL, + to_pandas, try_cast_to_pandas, - expanduser_path_arg, ) -from modin.config import PersistentPickle + +from .accessor import CachedAccessor, SparseFrameAccessor +from .base import _ATTRS_NO_LOOKUP, BasePandasDataset +from .groupby import DataFrameGroupBy +from .iterator import PartitionIterator +from .series import Series from .utils import ( - from_pandas, - from_non_pandas, - cast_function_modin2pandas, SET_DATAFRAME_ATTRIBUTE_WARNING, + cast_function_modin2pandas, + from_non_pandas, + from_pandas, ) -from .iterator import PartitionIterator -from .series import Series -from .base import BasePandasDataset, _ATTRS_NO_LOOKUP -from .groupby import DataFrameGroupBy -from .accessor import CachedAccessor, SparseFrameAccessor @_inherit_docstrings( diff --git a/modin/pandas/general.py b/modin/pandas/general.py index b61115874a2..3fbe8ea2e27 100644 --- a/modin/pandas/general.py +++ b/modin/pandas/general.py @@ -14,23 +14,22 @@ """Implement pandas general API.""" import warnings +from typing import Hashable, Iterable, Mapping, Optional, Union -import pandas import numpy as np - -from typing import Hashable, Iterable, Mapping, Union, Optional -from pandas.core.dtypes.common import is_list_like -from pandas._libs.lib import no_default, NoDefault +import pandas +from pandas._libs.lib import NoDefault, no_default from pandas._typing import DtypeBackend +from pandas.core.dtypes.common import is_list_like +from modin.core.storage_formats.base.query_compiler import BaseQueryCompiler from modin.error_message import ErrorMessage +from modin.logging import enable_logging +from modin.utils import _inherit_docstrings, to_pandas + from .base import BasePandasDataset from .dataframe import DataFrame from .series import Series -from modin.utils import to_pandas -from modin.core.storage_formats.base.query_compiler import BaseQueryCompiler -from modin.utils import _inherit_docstrings -from modin.logging import enable_logging @_inherit_docstrings(pandas.isna, apilink="pandas.isna") diff --git a/modin/pandas/groupby.py b/modin/pandas/groupby.py index 10bbf0bde7b..cdd78c62e2c 100644 --- a/modin/pandas/groupby.py +++ b/modin/pandas/groupby.py @@ -14,35 +14,35 @@ """Implement GroupBy public API as pandas does.""" import warnings +from collections.abc import Iterable +from types import BuiltinFunctionType import numpy as np import pandas -from pandas.core.apply import reconstruct_func -from pandas.errors import SpecificationError +import pandas.core.common as com import pandas.core.groupby -from pandas.core.dtypes.common import is_list_like, is_numeric_dtype, is_integer from pandas._libs import lib -import pandas.core.common as com -from types import BuiltinFunctionType -from collections.abc import Iterable +from pandas.core.apply import reconstruct_func +from pandas.core.dtypes.common import is_integer, is_list_like, is_numeric_dtype +from pandas.errors import SpecificationError +from modin.core.dataframe.algebra.default2pandas.groupby import GroupBy +from modin.core.storage_formats.base.query_compiler import BaseQueryCompiler from modin.error_message import ErrorMessage from modin.logging import ClassLogger, disable_logging +from modin.pandas.utils import cast_function_modin2pandas from modin.utils import ( + MODIN_UNNAMED_SERIES_LABEL, _inherit_docstrings, - try_cast_to_pandas, - wrap_udf_function, hashable, + try_cast_to_pandas, wrap_into_list, - MODIN_UNNAMED_SERIES_LABEL, + wrap_udf_function, ) -from modin.pandas.utils import cast_function_modin2pandas -from modin.core.storage_formats.base.query_compiler import BaseQueryCompiler -from modin.core.dataframe.algebra.default2pandas.groupby import GroupBy + from .series import Series -from .window import RollingGroupby from .utils import is_label - +from .window import RollingGroupby _DEFAULT_BEHAVIOUR = { "__class__", diff --git a/modin/pandas/indexing.py b/modin/pandas/indexing.py index 5b2e6d518b7..f492278e379 100644 --- a/modin/pandas/indexing.py +++ b/modin/pandas/indexing.py @@ -29,12 +29,14 @@ https://github.com/ray-project/ray/pull/1955#issuecomment-386781826 """ +import itertools + import numpy as np import pandas -import itertools -from pandas.api.types import is_list_like, is_bool -from pandas.core.dtypes.common import is_integer, is_bool_dtype, is_integer_dtype +from pandas.api.types import is_bool, is_list_like +from pandas.core.dtypes.common import is_bool_dtype, is_integer, is_integer_dtype from pandas.core.indexing import IndexingError + from modin.error_message import ErrorMessage from modin.logging import ClassLogger diff --git a/modin/pandas/io.py b/modin/pandas/io.py index b451663c66d..4ef37b4e485 100644 --- a/modin/pandas/io.py +++ b/modin/pandas/io.py @@ -21,52 +21,54 @@ from __future__ import annotations -from collections import OrderedDict import csv import inspect -import pandas -from pandas.io.parsers import TextFileReader -from pandas.io.parsers.readers import _c_parser_defaults -from pandas._libs.lib import no_default, NoDefault -from pandas._typing import ( - CompressionOptions, - CSVEngine, - DtypeArg, - ReadCsvBuffer, - FilePath, - StorageOptions, - IntStrT, - ReadBuffer, - IndexLabel, - ConvertersArg, - ParseDatesArg, - XMLParsers, - DtypeBackend, -) import pathlib import pickle +from collections import OrderedDict from typing import ( - Union, IO, + Any, AnyStr, - Sequence, + Callable, Dict, - List, - Optional, - Any, - Literal, Hashable, - Callable, Iterable, - Pattern, Iterator, + List, + Literal, + Optional, + Pattern, + Sequence, + Union, +) + +import pandas +from pandas._libs.lib import NoDefault, no_default +from pandas._typing import ( + CompressionOptions, + ConvertersArg, + CSVEngine, + DtypeArg, + DtypeBackend, + FilePath, + IndexLabel, + IntStrT, + ParseDatesArg, + ReadBuffer, + ReadCsvBuffer, + StorageOptions, + XMLParsers, ) +from pandas.io.parsers import TextFileReader +from pandas.io.parsers.readers import _c_parser_defaults from modin.error_message import ErrorMessage from modin.logging import ClassLogger, enable_logging +from modin.utils import _inherit_docstrings, expanduser_path_arg + from .dataframe import DataFrame from .series import Series -from modin.utils import _inherit_docstrings, expanduser_path_arg def _read(**kwargs): @@ -629,9 +631,10 @@ def read_fwf( """ Read a table of fixed-width formatted lines into DataFrame. """ - from modin.core.execution.dispatching.factories.dispatcher import FactoryDispatcher from pandas.io.parsers.base_parser import parser_defaults + from modin.core.execution.dispatching.factories.dispatcher import FactoryDispatcher + _, _, _, kwargs = inspect.getargvalues(inspect.currentframe()) kwargs.update(kwargs.pop("kwds", {})) target_kwargs = parser_defaults.copy() diff --git a/modin/pandas/plotting.py b/modin/pandas/plotting.py index 3a3aab259b2..fd4a0f53f2e 100644 --- a/modin/pandas/plotting.py +++ b/modin/pandas/plotting.py @@ -15,8 +15,9 @@ from pandas import plotting as pdplot -from modin.utils import instancer, to_pandas from modin.logging import ClassLogger +from modin.utils import instancer, to_pandas + from .dataframe import DataFrame diff --git a/modin/pandas/resample.py b/modin/pandas/resample.py index 432a5017e09..72884e0e420 100644 --- a/modin/pandas/resample.py +++ b/modin/pandas/resample.py @@ -18,12 +18,12 @@ import numpy as np import pandas import pandas.core.resample -from pandas.core.dtypes.common import is_list_like from pandas._libs import lib +from pandas.core.dtypes.common import is_list_like from modin.logging import ClassLogger -from modin.utils import _inherit_docstrings from modin.pandas.utils import cast_function_modin2pandas +from modin.utils import _inherit_docstrings @_inherit_docstrings(pandas.core.resample.Resampler) diff --git a/modin/pandas/series.py b/modin/pandas/series.py index 61d6a2d881c..c478a29e754 100644 --- a/modin/pandas/series.py +++ b/modin/pandas/series.py @@ -14,35 +14,30 @@ """Module houses `Series` class, that is distributed version of `pandas.Series`.""" from __future__ import annotations + +import warnings +from typing import IO, TYPE_CHECKING, Hashable, Optional, Union + import numpy as np import pandas -from pandas.io.formats.info import SeriesInfo +from pandas._libs import lib +from pandas._typing import Axis, IndexKeyFunc from pandas.api.types import is_integer from pandas.core.common import apply_if_callable, is_bool_indexer -from pandas.util._validators import validate_bool_kwarg -from pandas.core.dtypes.common import ( - is_dict_like, - is_list_like, -) +from pandas.core.dtypes.common import is_dict_like, is_list_like from pandas.core.series import _coerce_method -from pandas._libs import lib -from pandas._typing import IndexKeyFunc, Axis -from typing import Union, Optional, Hashable, TYPE_CHECKING, IO -import warnings +from pandas.io.formats.info import SeriesInfo +from pandas.util._validators import validate_bool_kwarg -from modin.logging import disable_logging -from modin.utils import ( - _inherit_docstrings, - to_pandas, - MODIN_UNNAMED_SERIES_LABEL, -) from modin.config import PersistentPickle -from .base import BasePandasDataset, _ATTRS_NO_LOOKUP -from .iterator import PartitionIterator -from .utils import from_pandas, is_scalar, _doc_binary_op, cast_function_modin2pandas -from .accessor import CachedAccessor, SparseAccessor -from .series_utils import CategoryMethods, StringMethods, DatetimeProperties +from modin.logging import disable_logging +from modin.utils import MODIN_UNNAMED_SERIES_LABEL, _inherit_docstrings, to_pandas +from .accessor import CachedAccessor, SparseAccessor +from .base import _ATTRS_NO_LOOKUP, BasePandasDataset +from .iterator import PartitionIterator +from .series_utils import CategoryMethods, DatetimeProperties, StringMethods +from .utils import _doc_binary_op, cast_function_modin2pandas, from_pandas, is_scalar if TYPE_CHECKING: from .dataframe import DataFrame diff --git a/modin/pandas/series_utils.py b/modin/pandas/series_utils.py index fa7308bd165..0042bc3bcca 100644 --- a/modin/pandas/series_utils.py +++ b/modin/pandas/series_utils.py @@ -16,16 +16,18 @@ Accessors: `Series.cat`, `Series.str`, `Series.dt` """ -from typing import TYPE_CHECKING import re +from typing import TYPE_CHECKING import numpy as np import pandas + from modin.logging import ClassLogger from modin.utils import _inherit_docstrings if TYPE_CHECKING: from datetime import tzinfo + from pandas._typing import npt diff --git a/modin/pandas/test/dataframe/test_binary.py b/modin/pandas/test/dataframe/test_binary.py index c5b9e775d6c..a0540a3f39e 100644 --- a/modin/pandas/test/dataframe/test_binary.py +++ b/modin/pandas/test/dataframe/test_binary.py @@ -11,27 +11,27 @@ # ANY KIND, either express or implied. See the License for the specific language # governing permissions and limitations under the License. -import pytest -import pandas -import numpy as np import matplotlib -import modin.pandas as pd +import numpy as np +import pandas +import pytest +import modin.pandas as pd +from modin.config import NPartitions, StorageFormat from modin.core.dataframe.pandas.partitioning.axis_partition import ( PandasDataframeAxisPartition, ) from modin.pandas.test.utils import ( + CustomIntegerForAddition, + NonCommutativeMultiplyInteger, + create_test_dfs, + default_to_pandas_ignore_string, df_equals, - test_data_values, - test_data_keys, eval_general, test_data, - create_test_dfs, - default_to_pandas_ignore_string, - CustomIntegerForAddition, - NonCommutativeMultiplyInteger, + test_data_keys, + test_data_values, ) -from modin.config import NPartitions, StorageFormat from modin.test.test_utils import warns_that_defaulting_to_pandas from modin.utils import get_current_execution diff --git a/modin/pandas/test/dataframe/test_default.py b/modin/pandas/test/dataframe/test_default.py index bee706e15d3..ab200e0375c 100644 --- a/modin/pandas/test/dataframe/test_default.py +++ b/modin/pandas/test/dataframe/test_default.py @@ -11,42 +11,39 @@ # ANY KIND, either express or implied. See the License for the specific language # governing permissions and limitations under the License. -import pytest +import io +import warnings + +import matplotlib import numpy as np import pandas -import matplotlib +import pytest from numpy.testing import assert_array_equal -import io -import warnings import modin.pandas as pd -from modin.utils import ( - to_pandas, - get_current_execution, -) - +from modin.config import Engine, NPartitions, StorageFormat from modin.pandas.test.utils import ( - df_equals, - name_contains, - test_data_values, - test_data_keys, - numeric_dfs, axis_keys, axis_values, bool_arg_keys, bool_arg_values, - eval_general, create_test_dfs, + default_to_pandas_ignore_string, + df_equals, + eval_general, generate_multiindex, - test_data_resample, + modin_df_almost_equals_pandas, + name_contains, + numeric_dfs, test_data, test_data_diff_dtype, - modin_df_almost_equals_pandas, + test_data_keys, test_data_large_categorical_dataframe, - default_to_pandas_ignore_string, + test_data_resample, + test_data_values, ) -from modin.config import NPartitions, StorageFormat, Engine from modin.test.test_utils import warns_that_defaulting_to_pandas +from modin.utils import get_current_execution, to_pandas NPartitions.put(4) diff --git a/modin/pandas/test/dataframe/test_indexing.py b/modin/pandas/test/dataframe/test_indexing.py index 934a2d21138..3fba4be7d62 100644 --- a/modin/pandas/test/dataframe/test_indexing.py +++ b/modin/pandas/test/dataframe/test_indexing.py @@ -11,38 +11,39 @@ # ANY KIND, either express or implied. See the License for the specific language # governing permissions and limitations under the License. -import pytest +import sys + +import matplotlib import numpy as np import pandas -from pandas.testing import assert_index_equal +import pytest from pandas._testing import ensure_clean -import matplotlib -import modin.pandas as pd -import sys +from pandas.testing import assert_index_equal +import modin.pandas as pd +from modin.config import MinPartitionSize, NPartitions, StorageFormat +from modin.pandas.indexing import is_range_like from modin.pandas.test.utils import ( NROWS, - RAND_LOW, RAND_HIGH, - df_equals, + RAND_LOW, arg_keys, - name_contains, - test_data, - test_data_values, - test_data_keys, axis_keys, axis_values, - int_arg_keys, - int_arg_values, create_test_dfs, + default_to_pandas_ignore_string, + df_equals, eval_general, - generate_multiindex, extra_test_parameters, - default_to_pandas_ignore_string, + generate_multiindex, + int_arg_keys, + int_arg_values, + name_contains, + test_data, + test_data_keys, + test_data_values, ) -from modin.config import NPartitions, MinPartitionSize, StorageFormat from modin.utils import get_current_execution -from modin.pandas.indexing import is_range_like NPartitions.put(4) diff --git a/modin/pandas/test/dataframe/test_iter.py b/modin/pandas/test/dataframe/test_iter.py index 6c34e3d1e81..1555977be2e 100644 --- a/modin/pandas/test/dataframe/test_iter.py +++ b/modin/pandas/test/dataframe/test_iter.py @@ -11,28 +11,28 @@ # ANY KIND, either express or implied. See the License for the specific language # governing permissions and limitations under the License. -import pytest +import io +import warnings +import matplotlib import numpy as np import pandas -import matplotlib -import modin.pandas as pd -import io -import warnings +import pytest +import modin.pandas as pd +from modin.config import NPartitions from modin.pandas.test.utils import ( - random_state, - RAND_LOW, RAND_HIGH, - df_equals, - test_data_values, - test_data_keys, - test_data, + RAND_LOW, create_test_dfs, + df_equals, eval_general, + random_state, + test_data, + test_data_keys, + test_data_values, ) from modin.pandas.utils import SET_DATAFRAME_ATTRIBUTE_WARNING -from modin.config import NPartitions from modin.test.test_utils import warns_that_defaulting_to_pandas NPartitions.put(4) diff --git a/modin/pandas/test/dataframe/test_join_sort.py b/modin/pandas/test/dataframe/test_join_sort.py index 970bf98717f..0a079fff569 100644 --- a/modin/pandas/test/dataframe/test_join_sort.py +++ b/modin/pandas/test/dataframe/test_join_sort.py @@ -11,34 +11,33 @@ # ANY KIND, either express or implied. See the License for the specific language # governing permissions and limitations under the License. -import pytest +import matplotlib import numpy as np import pandas -import matplotlib +import pytest import modin.pandas as pd -from modin.utils import to_pandas - +from modin.config import Engine, NPartitions, StorageFormat from modin.pandas.test.utils import ( - create_test_dfs, - random_state, - df_equals, arg_keys, - test_data_values, - test_data_keys, axis_keys, axis_values, bool_arg_keys, bool_arg_values, - test_data, - generate_multiindex, + create_test_dfs, + default_to_pandas_ignore_string, + df_equals, eval_general, - rotate_decimal_digits_or_symbols, extra_test_parameters, - default_to_pandas_ignore_string, + generate_multiindex, + random_state, + rotate_decimal_digits_or_symbols, + test_data, + test_data_keys, + test_data_values, ) -from modin.config import NPartitions, Engine, StorageFormat from modin.test.test_utils import warns_that_defaulting_to_pandas +from modin.utils import to_pandas NPartitions.put(4) diff --git a/modin/pandas/test/dataframe/test_map_metadata.py b/modin/pandas/test/dataframe/test_map_metadata.py index 91b9e8fc3c4..8b00a79677a 100644 --- a/modin/pandas/test/dataframe/test_map_metadata.py +++ b/modin/pandas/test/dataframe/test_map_metadata.py @@ -11,46 +11,46 @@ # ANY KIND, either express or implied. See the License for the specific language # governing permissions and limitations under the License. -import pytest +import matplotlib import numpy as np import pandas +import pytest from pandas.testing import assert_index_equal, assert_series_equal -import matplotlib -import modin.pandas as pd -from modin.utils import get_current_execution +import modin.pandas as pd +from modin.config import NPartitions, StorageFormat +from modin.core.dataframe.pandas.metadata import LazyProxyCategoricalDtype +from modin.core.storage_formats.pandas.utils import split_result_of_axis_func_pandas from modin.pandas.test.utils import ( - random_state, - RAND_LOW, RAND_HIGH, + RAND_LOW, + arg_keys, + axis_keys, + axis_values, + bool_arg_keys, + bool_arg_values, + create_test_dfs, + default_to_pandas_ignore_string, df_equals, df_is_empty, - arg_keys, + eval_general, + indices_keys, + indices_values, + int_arg_keys, + int_arg_values, name_contains, + numeric_dfs, + random_state, test_data, - test_data_values, test_data_keys, - test_data_with_duplicates_values, + test_data_values, test_data_with_duplicates_keys, - numeric_dfs, + test_data_with_duplicates_values, test_func_keys, test_func_values, - indices_keys, - indices_values, - axis_keys, - axis_values, - bool_arg_keys, - bool_arg_values, - int_arg_keys, - int_arg_values, - eval_general, - create_test_dfs, - default_to_pandas_ignore_string, ) -from modin.config import NPartitions, StorageFormat from modin.test.test_utils import warns_that_defaulting_to_pandas -from modin.core.dataframe.pandas.metadata import LazyProxyCategoricalDtype -from modin.core.storage_formats.pandas.utils import split_result_of_axis_func_pandas +from modin.utils import get_current_execution NPartitions.put(4) @@ -601,6 +601,7 @@ def _get_lazy_proxy(): return df.dtypes["a"], original_dtype, df elif StorageFormat.get() == "Hdk": import pyarrow as pa + from modin.pandas.utils import from_arrow at = pa.concat_tables( diff --git a/modin/pandas/test/dataframe/test_pickle.py b/modin/pandas/test/dataframe/test_pickle.py index 755c1775a8f..a3ee843daa3 100644 --- a/modin/pandas/test/dataframe/test_pickle.py +++ b/modin/pandas/test/dataframe/test_pickle.py @@ -11,13 +11,13 @@ # ANY KIND, either express or implied. See the License for the specific language # governing permissions and limitations under the License. -import pytest import pickle + import numpy as np +import pytest import modin.pandas as pd from modin.config import PersistentPickle - from modin.pandas.test.utils import df_equals diff --git a/modin/pandas/test/dataframe/test_reduce.py b/modin/pandas/test/dataframe/test_reduce.py index 8e1176acfca..ceda40e4161 100644 --- a/modin/pandas/test/dataframe/test_reduce.py +++ b/modin/pandas/test/dataframe/test_reduce.py @@ -11,35 +11,34 @@ # ANY KIND, either express or implied. See the License for the specific language # governing permissions and limitations under the License. -import pytest +import matplotlib import numpy as np import pandas -import matplotlib +import pytest from pandas._testing import assert_series_equal import modin.pandas as pd - +from modin.config import NPartitions, StorageFormat from modin.pandas.test.utils import ( - df_equals, arg_keys, - test_data, - test_data_values, - test_data_keys, + assert_dtypes_equal, axis_keys, axis_values, bool_arg_keys, bool_arg_values, + create_test_dfs, + default_to_pandas_ignore_string, + df_equals, + df_equals_with_non_stable_indices, + eval_general, int_arg_keys, int_arg_values, - eval_general, - create_test_dfs, + test_data, test_data_diff_dtype, - df_equals_with_non_stable_indices, + test_data_keys, test_data_large_categorical_dataframe, - default_to_pandas_ignore_string, - assert_dtypes_equal, + test_data_values, ) -from modin.config import NPartitions, StorageFormat NPartitions.put(4) diff --git a/modin/pandas/test/dataframe/test_udf.py b/modin/pandas/test/dataframe/test_udf.py index 652ea263c3f..8f34786f290 100644 --- a/modin/pandas/test/dataframe/test_udf.py +++ b/modin/pandas/test/dataframe/test_udf.py @@ -11,37 +11,36 @@ # ANY KIND, either express or implied. See the License for the specific language # governing permissions and limitations under the License. -import pytest +import matplotlib import numpy as np import pandas -import matplotlib -from modin.config import MinPartitionSize -import modin.pandas as pd - -from pandas.core.dtypes.common import is_list_like +import pytest from pandas._libs.lib import no_default +from pandas.core.dtypes.common import is_list_like + +import modin.pandas as pd +from modin.config import MinPartitionSize, NPartitions from modin.pandas.test.utils import ( - random_state, - df_equals, - test_data_values, - test_data_keys, - query_func_keys, - query_func_values, - agg_func_keys, - agg_func_values, agg_func_except_keys, agg_func_except_values, - eval_general, - create_test_dfs, - udf_func_values, - udf_func_keys, - test_data, + agg_func_keys, + agg_func_values, + arg_keys, bool_arg_keys, bool_arg_values, - arg_keys, + create_test_dfs, default_to_pandas_ignore_string, + df_equals, + eval_general, + query_func_keys, + query_func_values, + random_state, + test_data, + test_data_keys, + test_data_values, + udf_func_keys, + udf_func_values, ) -from modin.config import NPartitions from modin.test.test_utils import warns_that_defaulting_to_pandas from modin.utils import get_current_execution diff --git a/modin/pandas/test/dataframe/test_window.py b/modin/pandas/test/dataframe/test_window.py index 195b39032be..a5b7830ad56 100644 --- a/modin/pandas/test/dataframe/test_window.py +++ b/modin/pandas/test/dataframe/test_window.py @@ -11,37 +11,37 @@ # ANY KIND, either express or implied. See the License for the specific language # governing permissions and limitations under the License. -import pytest +import matplotlib import numpy as np import pandas -import matplotlib -import modin.pandas as pd +import pytest +import modin.pandas as pd +from modin.config import NPartitions, StorageFormat from modin.pandas.test.utils import ( - random_state, - df_equals, arg_keys, - name_contains, - test_data_values, - test_data_keys, - test_data_with_duplicates_values, - test_data_with_duplicates_keys, - no_numeric_dfs, - quantiles_keys, - quantiles_values, axis_keys, axis_values, bool_arg_keys, bool_arg_values, + create_test_dfs, + default_to_pandas_ignore_string, + df_equals, + eval_general, int_arg_keys, int_arg_values, + name_contains, + no_numeric_dfs, + quantiles_keys, + quantiles_values, + random_state, test_data, - eval_general, - create_test_dfs, test_data_diff_dtype, - default_to_pandas_ignore_string, + test_data_keys, + test_data_values, + test_data_with_duplicates_keys, + test_data_with_duplicates_values, ) -from modin.config import NPartitions, StorageFormat NPartitions.put(4) diff --git a/modin/pandas/test/internals/test_benchmark_mode.py b/modin/pandas/test/internals/test_benchmark_mode.py index 8d67503ff33..40e341aeb61 100644 --- a/modin/pandas/test/internals/test_benchmark_mode.py +++ b/modin/pandas/test/internals/test_benchmark_mode.py @@ -18,7 +18,6 @@ import modin.pandas as pd from modin.config import Engine - engine = Engine.get() # We have to explicitly mock subclass implementations of wait_partitions. diff --git a/modin/pandas/test/test_api.py b/modin/pandas/test/test_api.py index 37984eb3d10..4ace25c0b0e 100644 --- a/modin/pandas/test/test_api.py +++ b/modin/pandas/test/test_api.py @@ -11,10 +11,11 @@ # ANY KIND, either express or implied. See the License for the specific language # governing permissions and limitations under the License. -import pandas import inspect -import pytest + import numpy as np +import pandas +import pytest import modin.pandas as pd diff --git a/modin/pandas/test/test_concat.py b/modin/pandas/test/test_concat.py index ba4e977f382..c07860a1b60 100644 --- a/modin/pandas/test/test_concat.py +++ b/modin/pandas/test/test_concat.py @@ -12,21 +12,22 @@ # governing permissions and limitations under the License. import numpy as np -import pytest import pandas +import pytest import modin.pandas as pd +from modin.config import NPartitions, StorageFormat from modin.pandas.utils import from_pandas +from modin.utils import get_current_execution + from .utils import ( + create_test_dfs, + default_to_pandas_ignore_string, df_equals, generate_dfs, generate_multiindex_dfs, generate_none_dfs, - create_test_dfs, - default_to_pandas_ignore_string, ) -from modin.config import NPartitions, StorageFormat -from modin.utils import get_current_execution NPartitions.put(4) diff --git a/modin/pandas/test/test_expanding.py b/modin/pandas/test/test_expanding.py index e4017d8b1bd..2555aa38772 100644 --- a/modin/pandas/test/test_expanding.py +++ b/modin/pandas/test/test_expanding.py @@ -11,21 +11,22 @@ # ANY KIND, either express or implied. See the License for the specific language # governing permissions and limitations under the License. -import pytest import numpy as np import pandas +import pytest + import modin.pandas as pd +from modin.config import NPartitions +from modin.test.test_utils import warns_that_defaulting_to_pandas from .utils import ( + create_test_dfs, df_equals, + eval_general, test_data, - test_data_values, test_data_keys, - eval_general, - create_test_dfs, + test_data_values, ) -from modin.test.test_utils import warns_that_defaulting_to_pandas -from modin.config import NPartitions NPartitions.put(4) diff --git a/modin/pandas/test/test_general.py b/modin/pandas/test/test_general.py index c6c05d1655f..467eae4f768 100644 --- a/modin/pandas/test/test_general.py +++ b/modin/pandas/test/test_general.py @@ -12,29 +12,30 @@ # governing permissions and limitations under the License. import contextlib + +import numpy as np import pandas import pytest -import modin.pandas as pd -import numpy as np from numpy.testing import assert_array_equal -from modin.utils import get_current_execution, to_pandas -from modin.test.test_utils import warns_that_defaulting_to_pandas -from modin.config import StorageFormat from pandas.testing import assert_frame_equal +import modin.pandas as pd +from modin.config import StorageFormat +from modin.test.test_utils import warns_that_defaulting_to_pandas +from modin.utils import get_current_execution, to_pandas + from .utils import ( + bool_arg_keys, + bool_arg_values, create_test_dfs, - test_data_values, - test_data_keys, + default_to_pandas_ignore_string, df_equals, - sort_index_for_equal_values, eval_general, - bool_arg_values, - bool_arg_keys, - default_to_pandas_ignore_string, + sort_index_for_equal_values, + test_data_keys, + test_data_values, ) - if StorageFormat.get() == "Hdk": pytestmark = pytest.mark.filterwarnings(default_to_pandas_ignore_string) diff --git a/modin/pandas/test/test_groupby.py b/modin/pandas/test/test_groupby.py index a6111a20b0f..41a1d41e171 100644 --- a/modin/pandas/test/test_groupby.py +++ b/modin/pandas/test/test_groupby.py @@ -11,45 +11,45 @@ # ANY KIND, either express or implied. See the License for the specific language # governing permissions and limitations under the License. -import pytest +import datetime import itertools -import pandas -import numpy as np from unittest import mock -import datetime -from modin.config import StorageFormat +import numpy as np +import pandas +import pytest + +import modin.pandas as pd +from modin.config import NPartitions, StorageFormat from modin.config.envvars import ExperimentalGroupbyImpl, IsRayCluster +from modin.core.dataframe.algebra.default2pandas.groupby import GroupBy from modin.core.dataframe.pandas.partitioning.axis_partition import ( PandasDataframeAxisPartition, ) -import modin.pandas as pd +from modin.pandas.utils import from_pandas, is_scalar +from modin.test.test_utils import warns_that_defaulting_to_pandas from modin.utils import ( - try_cast_to_pandas, + MODIN_UNNAMED_SERIES_LABEL, get_current_execution, hashable, - MODIN_UNNAMED_SERIES_LABEL, + try_cast_to_pandas, ) -from modin.core.dataframe.algebra.default2pandas.groupby import GroupBy -from modin.pandas.utils import from_pandas, is_scalar + from .utils import ( - df_equals, check_df_columns_have_nans, create_test_dfs, + default_to_pandas_ignore_string, + df_equals, + dict_equals, eval_general, + generate_multiindex, + modin_df_almost_equals_pandas, test_data, test_data_values, - modin_df_almost_equals_pandas, - try_modin_df_almost_equals_compare, - generate_multiindex, test_groupby_data, - dict_equals, + try_modin_df_almost_equals_compare, value_equals, - default_to_pandas_ignore_string, ) -from modin.config import NPartitions -from modin.test.test_utils import warns_that_defaulting_to_pandas - NPartitions.put(4) diff --git a/modin/pandas/test/test_io.py b/modin/pandas/test/test_io.py index 6e93954a358..b6f37f59f2f 100644 --- a/modin/pandas/test/test_io.py +++ b/modin/pandas/test/test_io.py @@ -11,72 +11,71 @@ # ANY KIND, either express or implied. See the License for the specific language # governing permissions and limitations under the License. -import unittest.mock as mock -import inspect import contextlib -import pytest +import csv +import inspect +import os +import sys +import unittest.mock as mock +from collections import OrderedDict, defaultdict +from io import BytesIO, StringIO +from pathlib import Path +from typing import Dict + +import fastparquet import numpy as np -from packaging import version import pandas -from pandas.errors import ParserWarning import pandas._libs.lib as lib +import pyarrow as pa +import pyarrow.dataset +import pytest +import sqlalchemy as sa +from packaging import version from pandas._testing import ensure_clean -from pathlib import Path -from collections import OrderedDict, defaultdict -from modin.config.envvars import MinPartitionSize -from modin.db_conn import ( - ModinDatabaseConnection, - UnsupportedDatabaseException, -) +from pandas.errors import ParserWarning +from scipy import sparse + from modin.config import ( - TestDatasetSize, + AsyncReadMode, Engine, - StorageFormat, IsExperimental, + ReadSqlEngine, + StorageFormat, + TestDatasetSize, TestReadFromPostgres, TestReadFromSqlServer, - ReadSqlEngine, - AsyncReadMode, ) -from modin.utils import to_pandas +from modin.config.envvars import MinPartitionSize +from modin.db_conn import ModinDatabaseConnection, UnsupportedDatabaseException from modin.pandas.utils import from_arrow from modin.test.test_utils import warns_that_defaulting_to_pandas -import pyarrow as pa -import pyarrow.dataset -import fastparquet -import os -from io import BytesIO, StringIO -from scipy import sparse -import sys -import sqlalchemy as sa -import csv -from typing import Dict +from modin.utils import to_pandas from .utils import ( + COMP_TO_EXT, check_file_leaks, + create_test_dfs, + default_to_pandas_ignore_string, df_equals, - json_short_string, - json_short_bytes, - json_long_string, - json_long_bytes, - get_unique_filename, - io_ops_bad_exc, - eval_io_from_str, dummy_decorator, - create_test_dfs, - COMP_TO_EXT, + eval_general, + eval_io_from_str, generate_dataframe, - default_to_pandas_ignore_string, + get_unique_filename, + io_ops_bad_exc, + json_long_bytes, + json_long_string, + json_short_bytes, + json_short_string, parse_dates_values_by_id, - time_parsing_csv_path, - test_data as utils_test_data, - eval_general, ) +from .utils import test_data as utils_test_data +from .utils import time_parsing_csv_path if StorageFormat.get() == "Hdk": from modin.experimental.core.execution.native.implementations.hdk_on_native.test.utils import ( - eval_io, align_datetime_dtypes, + eval_io, ) else: from .utils import eval_io @@ -1947,8 +1946,7 @@ def test_read_parquet_s3(self, path_type, engine): ) def test_read_parquet_without_metadata(self, tmp_path, engine, filters): """Test that Modin can read parquet files not written by pandas.""" - from pyarrow import csv - from pyarrow import parquet + from pyarrow import csv, parquet parquet_fname = get_unique_filename(extension="parquet", data_dir=tmp_path) csv_fname = get_unique_filename(extension="parquet", data_dir=tmp_path) diff --git a/modin/pandas/test/test_reshape.py b/modin/pandas/test/test_reshape.py index 5934cc81e71..0e9e6c24713 100644 --- a/modin/pandas/test/test_reshape.py +++ b/modin/pandas/test/test_reshape.py @@ -11,12 +11,13 @@ # ANY KIND, either express or implied. See the License for the specific language # governing permissions and limitations under the License. +import numpy as np import pandas import pytest -import numpy as np -import modin.pandas as pd +import modin.pandas as pd from modin.test.test_utils import warns_that_defaulting_to_pandas + from .utils import df_equals, test_data_values diff --git a/modin/pandas/test/test_rolling.py b/modin/pandas/test/test_rolling.py index 22939e1af03..a3181cc5b53 100644 --- a/modin/pandas/test/test_rolling.py +++ b/modin/pandas/test/test_rolling.py @@ -11,20 +11,21 @@ # ANY KIND, either express or implied. See the License for the specific language # governing permissions and limitations under the License. -import pytest import numpy as np import pandas +import pytest + import modin.pandas as pd +from modin.config import NPartitions from .utils import ( - df_equals, - test_data_values, - test_data_keys, - eval_general, create_test_dfs, default_to_pandas_ignore_string, + df_equals, + eval_general, + test_data_keys, + test_data_values, ) -from modin.config import NPartitions NPartitions.put(4) diff --git a/modin/pandas/test/test_series.py b/modin/pandas/test/test_series.py index 8abced0eb1e..1749cd4d6a2 100644 --- a/modin/pandas/test/test_series.py +++ b/modin/pandas/test/test_series.py @@ -13,76 +13,76 @@ from __future__ import annotations -import pytest +import json import unittest.mock as mock + +import matplotlib import numpy as np -import json import pandas -from pandas._testing import assert_series_equal -from pandas.errors import SpecificationError -from pandas.core.indexing import IndexingError import pandas._libs.lib as lib -import matplotlib -import modin.pandas as pd +import pytest from numpy.testing import assert_array_equal +from pandas._testing import assert_series_equal +from pandas.core.indexing import IndexingError +from pandas.errors import SpecificationError -from modin.utils import get_current_execution, try_cast_to_pandas +import modin.pandas as pd +from modin.config import NPartitions, StorageFormat from modin.test.test_utils import warns_that_defaulting_to_pandas +from modin.utils import get_current_execution, to_pandas, try_cast_to_pandas -from modin.utils import to_pandas from .utils import ( - random_state, - RAND_LOW, RAND_HIGH, - df_equals, - arg_keys, - name_contains, - test_data, - test_data_values, - test_data_keys, - test_data_with_duplicates_values, - test_data_with_duplicates_keys, - test_string_data_values, - test_string_data_keys, - test_string_list_data_values, - test_string_list_data_keys, - string_sep_values, - string_sep_keys, - string_na_rep_values, - string_na_rep_keys, - numeric_dfs, - no_numeric_dfs, - agg_func_keys, - agg_func_values, + RAND_LOW, + CustomIntegerForAddition, + NonCommutativeMultiplyInteger, agg_func_except_keys, agg_func_except_values, - numeric_agg_funcs, - quantiles_keys, - quantiles_values, + agg_func_keys, + agg_func_values, + arg_keys, + assert_dtypes_equal, axis_keys, axis_values, bool_arg_keys, bool_arg_values, - int_arg_keys, - int_arg_values, - encoding_types, categories_equals, + default_to_pandas_ignore_string, + df_equals, + df_equals_with_non_stable_indices, + encoding_types, eval_general, - test_data_small_values, - test_data_small_keys, - test_data_categorical_values, - test_data_categorical_keys, generate_multiindex, + int_arg_keys, + int_arg_values, + name_contains, + no_numeric_dfs, + numeric_agg_funcs, + numeric_dfs, + quantiles_keys, + quantiles_values, + random_state, + string_na_rep_keys, + string_na_rep_values, + string_sep_keys, + string_sep_values, + test_data, + test_data_categorical_keys, + test_data_categorical_values, test_data_diff_dtype, - df_equals_with_non_stable_indices, + test_data_keys, test_data_large_categorical_series_keys, test_data_large_categorical_series_values, - default_to_pandas_ignore_string, - CustomIntegerForAddition, - NonCommutativeMultiplyInteger, - assert_dtypes_equal, + test_data_small_keys, + test_data_small_values, + test_data_values, + test_data_with_duplicates_keys, + test_data_with_duplicates_values, + test_string_data_keys, + test_string_data_values, + test_string_list_data_keys, + test_string_list_data_values, ) -from modin.config import NPartitions, StorageFormat # Our configuration in pytest.ini requires that we explicitly catch all # instances of defaulting to pandas, but some test modules, like this one, diff --git a/modin/pandas/test/utils.py b/modin/pandas/test/utils.py index 86e34db2f01..91b69e7017f 100644 --- a/modin/pandas/test/utils.py +++ b/modin/pandas/test/utils.py @@ -11,41 +11,41 @@ # ANY KIND, either express or implied. See the License for the specific language # governing permissions and limitations under the License. +import csv +import functools +import itertools +import math +import os import re +from io import BytesIO from pathlib import Path +from string import ascii_letters from typing import Union -import pytest + import numpy as np -import math import pandas -import itertools -from pandas.testing import ( - assert_series_equal, - assert_frame_equal, - assert_index_equal, - assert_extension_array_equal, -) +import psutil +import pytest from pandas.core.dtypes.common import ( + is_bool_dtype, + is_datetime64_any_dtype, is_list_like, is_numeric_dtype, is_object_dtype, + is_period_dtype, is_string_dtype, - is_bool_dtype, - is_datetime64_any_dtype, is_timedelta64_dtype, - is_period_dtype, +) +from pandas.testing import ( + assert_extension_array_equal, + assert_frame_equal, + assert_index_equal, + assert_series_equal, ) -from modin.config import MinPartitionSize, NPartitions import modin.pandas as pd +from modin.config import MinPartitionSize, NPartitions, TestDatasetSize, TrackFileLeaks from modin.utils import to_pandas, try_cast_to_pandas -from modin.config import TestDatasetSize, TrackFileLeaks -from io import BytesIO -import os -from string import ascii_letters -import csv -import psutil -import functools # Flag activated on command line with "--extra-test-parameters" option. # Used in some tests to perform additional parameter combinations. diff --git a/modin/pandas/utils.py b/modin/pandas/utils.py index f2a4d977283..0df9906e482 100644 --- a/modin/pandas/utils.py +++ b/modin/pandas/utils.py @@ -13,17 +13,12 @@ """Implement utils for pandas component.""" -from typing import Iterator, Tuple, Optional +from typing import Iterator, Optional, Tuple -from pandas.util._decorators import doc -from pandas._typing import ( - AggFuncType, - AggFuncTypeBase, - AggFuncTypeDict, - IndexLabel, -) -import pandas import numpy as np +import pandas +from pandas._typing import AggFuncType, AggFuncTypeBase, AggFuncTypeDict, IndexLabel +from pandas.util._decorators import doc from modin.utils import hashable @@ -91,6 +86,7 @@ def from_pandas(df): A new Modin DataFrame object. """ from modin.core.execution.dispatching.factories.dispatcher import FactoryDispatcher + from .dataframe import DataFrame return DataFrame(query_compiler=FactoryDispatcher.from_pandas(df)) @@ -111,6 +107,7 @@ def from_arrow(at): A new Modin DataFrame object. """ from modin.core.execution.dispatching.factories.dispatcher import FactoryDispatcher + from .dataframe import DataFrame return DataFrame(query_compiler=FactoryDispatcher.from_arrow(at)) @@ -133,6 +130,7 @@ def from_dataframe(df): A new Modin DataFrame object. """ from modin.core.execution.dispatching.factories.dispatcher import FactoryDispatcher + from .dataframe import DataFrame return DataFrame(query_compiler=FactoryDispatcher.from_dataframe(df)) @@ -182,6 +180,7 @@ def is_scalar(obj): True if given object is scalar and False otherwise. """ from pandas.api.types import is_scalar as pandas_is_scalar + from .base import BasePandasDataset return not isinstance(obj, BasePandasDataset) and pandas_is_scalar(obj) diff --git a/modin/pandas/window.py b/modin/pandas/window.py index 5abdbfe331a..c0a2bf7ccc9 100644 --- a/modin/pandas/window.py +++ b/modin/pandas/window.py @@ -14,13 +14,14 @@ """Implement Window and Rolling public API.""" from typing import Optional + import pandas.core.window.rolling from pandas.core.dtypes.common import is_list_like +from modin.error_message import ErrorMessage from modin.logging import ClassLogger -from modin.utils import _inherit_docstrings from modin.pandas.utils import cast_function_modin2pandas -from modin.error_message import ErrorMessage +from modin.utils import _inherit_docstrings @_inherit_docstrings(pandas.core.window.rolling.Window) diff --git a/modin/test/interchange/dataframe_protocol/base/test_sanity.py b/modin/test/interchange/dataframe_protocol/base/test_sanity.py index 8f4f73457f0..fff242e0b26 100644 --- a/modin/test/interchange/dataframe_protocol/base/test_sanity.py +++ b/modin/test/interchange/dataframe_protocol/base/test_sanity.py @@ -14,8 +14,8 @@ """Basic sanity checks for the DataFrame exchange protocol.""" import pytest -import modin.pandas as pd +import modin.pandas as pd from modin.pandas.test.utils import default_to_pandas_ignore_string diff --git a/modin/test/interchange/dataframe_protocol/base/test_utils.py b/modin/test/interchange/dataframe_protocol/base/test_utils.py index 5fdc803e331..5aa9c29e910 100644 --- a/modin/test/interchange/dataframe_protocol/base/test_utils.py +++ b/modin/test/interchange/dataframe_protocol/base/test_utils.py @@ -13,9 +13,9 @@ """Tests for common utility functions of the DataFrame exchange protocol.""" -import pytest -import pandas import numpy as np +import pandas +import pytest from modin.core.dataframe.base.interchange.dataframe_protocol.utils import ( pandas_dtype_to_arrow_c, diff --git a/modin/test/interchange/dataframe_protocol/hdk/test_protocol.py b/modin/test/interchange/dataframe_protocol/hdk/test_protocol.py index 2a116c7143b..756b2a9f0c3 100644 --- a/modin/test/interchange/dataframe_protocol/hdk/test_protocol.py +++ b/modin/test/interchange/dataframe_protocol/hdk/test_protocol.py @@ -13,21 +13,22 @@ """Dataframe exchange protocol tests that are specific for HDK implementation.""" -import pytest -import pyarrow as pa -import pandas import numpy as np +import pandas +import pyarrow as pa +import pytest import modin.pandas as pd from modin.core.dataframe.pandas.interchange.dataframe_protocol.from_dataframe import ( - primitive_column_to_ndarray, buffer_to_ndarray, + primitive_column_to_ndarray, set_nulls, ) -from modin.pandas.utils import from_arrow, from_dataframe from modin.pandas.test.utils import df_equals +from modin.pandas.utils import from_arrow, from_dataframe from modin.test.test_utils import warns_that_defaulting_to_pandas -from .utils import get_data_of_all_types, split_df_into_chunks, export_frame + +from .utils import export_frame, get_data_of_all_types, split_df_into_chunks @pytest.mark.parametrize("data_has_nulls", [True, False]) diff --git a/modin/test/interchange/dataframe_protocol/hdk/utils.py b/modin/test/interchange/dataframe_protocol/hdk/utils.py index 43d1ba6e5bb..401b6e2f13b 100644 --- a/modin/test/interchange/dataframe_protocol/hdk/utils.py +++ b/modin/test/interchange/dataframe_protocol/hdk/utils.py @@ -13,10 +13,11 @@ """Utility function for testing HdkOnNative implementation for DataFrame exchange protocol.""" -import pandas -import numpy as np from typing import Dict +import numpy as np +import pandas + from modin.core.dataframe.pandas.interchange.dataframe_protocol.from_dataframe import ( from_dataframe_to_pandas, protocol_df_chunk_to_pandas, diff --git a/modin/test/interchange/dataframe_protocol/pandas/test_protocol.py b/modin/test/interchange/dataframe_protocol/pandas/test_protocol.py index 00d7e9f07d7..9039cbf9af0 100644 --- a/modin/test/interchange/dataframe_protocol/pandas/test_protocol.py +++ b/modin/test/interchange/dataframe_protocol/pandas/test_protocol.py @@ -16,8 +16,8 @@ import pandas import modin.pandas as pd -from modin.pandas.utils import from_dataframe from modin.pandas.test.utils import df_equals, test_data +from modin.pandas.utils import from_dataframe from modin.test.test_utils import warns_that_defaulting_to_pandas diff --git a/modin/test/interchange/dataframe_protocol/test_general.py b/modin/test/interchange/dataframe_protocol/test_general.py index b4373835431..ae7d018c96f 100644 --- a/modin/test/interchange/dataframe_protocol/test_general.py +++ b/modin/test/interchange/dataframe_protocol/test_general.py @@ -13,9 +13,10 @@ """Dataframe exchange protocol tests that are common for every implementation.""" -import pytest -import math import ctypes +import math + +import pytest import modin.pandas as pd diff --git a/modin/test/storage_formats/base/test_internals.py b/modin/test/storage_formats/base/test_internals.py index 9da163a8b7a..3b94e0a3e76 100644 --- a/modin/test/storage_formats/base/test_internals.py +++ b/modin/test/storage_formats/base/test_internals.py @@ -14,13 +14,9 @@ import pandas import pytest -from modin.pandas.test.utils import ( - test_data_values, - create_test_dfs, - df_equals, -) -from modin.config import NPartitions import modin.pandas as pd +from modin.config import NPartitions +from modin.pandas.test.utils import create_test_dfs, df_equals, test_data_values NPartitions.put(4) diff --git a/modin/test/storage_formats/hdk/test_internals.py b/modin/test/storage_formats/hdk/test_internals.py index 467db955048..da74b16cb8e 100644 --- a/modin/test/storage_formats/hdk/test_internals.py +++ b/modin/test/storage_formats/hdk/test_internals.py @@ -11,9 +11,10 @@ # ANY KIND, either express or implied. See the License for the specific language # governing permissions and limitations under the License. -import pytest -import sys import subprocess +import sys + +import pytest @pytest.mark.parametrize( diff --git a/modin/test/storage_formats/pandas/test_internals.py b/modin/test/storage_formats/pandas/test_internals.py index 0c9c6a7128f..28258cbaf1a 100644 --- a/modin/test/storage_formats/pandas/test_internals.py +++ b/modin/test/storage_formats/pandas/test_internals.py @@ -11,54 +11,43 @@ # ANY KIND, either express or implied. See the License for the specific language # governing permissions and limitations under the License. -import modin.pandas as pd -from modin.pandas.test.utils import ( - create_test_dfs, - test_data_values, - df_equals, -) -from modin.config import NPartitions, Engine, MinPartitionSize, ExperimentalGroupbyImpl -from modin.distributed.dataframe.pandas import from_partitions -from modin.core.storage_formats.pandas.utils import split_result_of_axis_func_pandas -from modin.utils import try_cast_to_pandas -from modin.core.dataframe.pandas.dataframe.utils import ( - ShuffleSortFunctions, - ColumnInfo, -) +import functools +import sys +import unittest.mock as mock import numpy as np import pandas import pytest -import unittest.mock as mock -import functools -import sys +import modin.pandas as pd +from modin.config import Engine, ExperimentalGroupbyImpl, MinPartitionSize, NPartitions +from modin.core.dataframe.pandas.dataframe.utils import ColumnInfo, ShuffleSortFunctions +from modin.core.storage_formats.pandas.utils import split_result_of_axis_func_pandas +from modin.distributed.dataframe.pandas import from_partitions +from modin.pandas.test.utils import create_test_dfs, df_equals, test_data_values +from modin.utils import try_cast_to_pandas NPartitions.put(4) if Engine.get() == "Ray": - from modin.core.execution.ray.implementations.pandas_on_ray.partitioning import ( - PandasOnRayDataframePartition, - ) + from modin.core.execution.ray.common import RayWrapper from modin.core.execution.ray.implementations.pandas_on_ray.partitioning import ( PandasOnRayDataframeColumnPartition, + PandasOnRayDataframePartition, PandasOnRayDataframeRowPartition, ) - from modin.core.execution.ray.common import RayWrapper block_partition_class = PandasOnRayDataframePartition virtual_column_partition_class = PandasOnRayDataframeColumnPartition virtual_row_partition_class = PandasOnRayDataframeRowPartition put = RayWrapper.put elif Engine.get() == "Dask": + from modin.core.execution.dask.common import DaskWrapper from modin.core.execution.dask.implementations.pandas_on_dask.partitioning import ( PandasOnDaskDataframeColumnPartition, - PandasOnDaskDataframeRowPartition, - ) - from modin.core.execution.dask.implementations.pandas_on_dask.partitioning import ( PandasOnDaskDataframePartition, + PandasOnDaskDataframeRowPartition, ) - from modin.core.execution.dask.common import DaskWrapper # initialize modin dataframe to initialize dask pd.DataFrame() @@ -70,12 +59,12 @@ def put(x): virtual_column_partition_class = PandasOnDaskDataframeColumnPartition virtual_row_partition_class = PandasOnDaskDataframeRowPartition elif Engine.get() == "Python": + from modin.core.execution.python.common import PythonWrapper from modin.core.execution.python.implementations.pandas_on_python.partitioning import ( PandasOnPythonDataframeColumnPartition, - PandasOnPythonDataframeRowPartition, PandasOnPythonDataframePartition, + PandasOnPythonDataframeRowPartition, ) - from modin.core.execution.python.common import PythonWrapper def put(x): return PythonWrapper.put(x, hash=False) diff --git a/modin/test/test_docstring_urls.py b/modin/test/test_docstring_urls.py index c5990059b09..16283ae7871 100644 --- a/modin/test/test_docstring_urls.py +++ b/modin/test/test_docstring_urls.py @@ -11,11 +11,12 @@ # ANY KIND, either express or implied. See the License for the specific language # governing permissions and limitations under the License. -from urllib.request import urlopen -from urllib.error import HTTPError -from concurrent.futures import ThreadPoolExecutor -import pkgutil import importlib +import pkgutil +from concurrent.futures import ThreadPoolExecutor +from urllib.error import HTTPError +from urllib.request import urlopen + import pytest import modin.pandas diff --git a/modin/test/test_envvar_catcher.py b/modin/test/test_envvar_catcher.py index fd187bf1f4e..64ae1f38bc4 100644 --- a/modin/test/test_envvar_catcher.py +++ b/modin/test/test_envvar_catcher.py @@ -12,6 +12,7 @@ # governing permissions and limitations under the License. import os + import pytest diff --git a/modin/test/test_envvar_npartitions.py b/modin/test/test_envvar_npartitions.py index fa23bbef957..445ccb3f9e6 100644 --- a/modin/test/test_envvar_npartitions.py +++ b/modin/test/test_envvar_npartitions.py @@ -11,10 +11,10 @@ # ANY KIND, either express or implied. See the License for the specific language # governing permissions and limitations under the License. -import modin.pandas as pd import numpy as np import pytest +import modin.pandas as pd from modin.config import NPartitions diff --git a/modin/test/test_executions_api.py b/modin/test/test_executions_api.py index 949834ba9e6..b60afd1ad59 100644 --- a/modin/test/test_executions_api.py +++ b/modin/test/test_executions_api.py @@ -13,13 +13,9 @@ import pytest -from modin.core.storage_formats import ( - BaseQueryCompiler, - PandasQueryCompiler, -) +from modin.core.storage_formats import BaseQueryCompiler, PandasQueryCompiler from modin.experimental.core.storage_formats.pyarrow import PyarrowQueryCompiler - BASE_EXECUTION = BaseQueryCompiler EXECUTIONS = [PandasQueryCompiler, PyarrowQueryCompiler] diff --git a/modin/test/test_headers.py b/modin/test/test_headers.py index b3dd62cb266..963b709ff1d 100644 --- a/modin/test/test_headers.py +++ b/modin/test/test_headers.py @@ -12,8 +12,7 @@ # governing permissions and limitations under the License. import os -from os.path import dirname, abspath - +from os.path import abspath, dirname # This is the python file root directory (modin/modin) rootdir = dirname(dirname(abspath(__file__))) diff --git a/modin/test/test_logging.py b/modin/test/test_logging.py index 33298c1e7dd..f569e54b6da 100644 --- a/modin/test/test_logging.py +++ b/modin/test/test_logging.py @@ -11,9 +11,10 @@ # ANY KIND, either express or implied. See the License for the specific language # governing permissions and limitations under the License. -import pytest -import logging import collections +import logging + +import pytest import modin.logging from modin.config import LogMode diff --git a/modin/test/test_partition_api.py b/modin/test/test_partition_api.py index 64bd0ef8167..3118e0a4b83 100644 --- a/modin/test/test_partition_api.py +++ b/modin/test/test_partition_api.py @@ -16,11 +16,11 @@ import pytest import modin.pandas as pd -from modin.distributed.dataframe.pandas import unwrap_partitions, from_partitions from modin.config import Engine, NPartitions -from modin.pandas.test.utils import df_equals, test_data -from modin.pandas.indexing import compute_sliced_len from modin.core.execution.dispatching.factories.dispatcher import FactoryDispatcher +from modin.distributed.dataframe.pandas import from_partitions, unwrap_partitions +from modin.pandas.indexing import compute_sliced_len +from modin.pandas.test.utils import df_equals, test_data PartitionClass = ( FactoryDispatcher.get_factory().io_cls.frame_cls._partition_mgr_cls._partition_class @@ -34,9 +34,10 @@ get_func = RayWrapper.materialize is_future = lambda obj: isinstance(obj, ObjectIDType) # noqa: E731 elif Engine.get() == "Dask": - from modin.core.execution.dask.common import DaskWrapper from distributed import Future + from modin.core.execution.dask.common import DaskWrapper + # Looks like there is a key collision; # https://github.com/dask/distributed/issues/3703#issuecomment-619446739 # recommends to use `hash=False`. Perhaps this should be the default value of `put`. @@ -44,9 +45,10 @@ get_func = DaskWrapper.materialize is_future = lambda obj: isinstance(obj, Future) # noqa: E731 elif Engine.get() == "Unidist": - from modin.core.execution.unidist.common import UnidistWrapper from unidist import is_object_ref + from modin.core.execution.unidist.common import UnidistWrapper + put_func = UnidistWrapper.put get_func = UnidistWrapper.materialize is_future = is_object_ref diff --git a/modin/test/test_utils.py b/modin/test/test_utils.py index 596f12b97b6..3337385d139 100644 --- a/modin/test/test_utils.py +++ b/modin/test/test_utils.py @@ -11,13 +11,14 @@ # ANY KIND, either express or implied. See the License for the specific language # governing permissions and limitations under the License. -import pytest -import modin.utils import json +from textwrap import dedent, indent + import pandas -import modin.pandas as pd +import pytest -from textwrap import dedent, indent +import modin.pandas as pd +import modin.utils from modin.error_message import ErrorMessage from modin.pandas.test.utils import create_test_dfs diff --git a/modin/utils.py b/modin/utils.py index e8fbde43564..6aba77f530f 100644 --- a/modin/utils.py +++ b/modin/utils.py @@ -13,32 +13,41 @@ """Collection of general utility functions, mostly for internal use.""" +import codecs +import functools import importlib import inspect +import json import os -from pathlib import Path -import types -from typing import Any, Callable, List, Mapping, Optional, Union, TypeVar import re import sys -import json -import codecs -import functools - -from typing import Protocol, runtime_checkable - +import types +from pathlib import Path from textwrap import dedent, indent -from packaging import version +from typing import ( + Any, + Callable, + List, + Mapping, + Optional, + Protocol, + TypeVar, + Union, + runtime_checkable, +) -import pandas import numpy as np - -from pandas.util._decorators import Appender # type: ignore -from pandas.util._print_versions import _get_sys_info, _get_dependency_info # type: ignore[attr-defined] +import pandas +from packaging import version from pandas._typing import JSONSerializable +from pandas.util._decorators import Appender # type: ignore +from pandas.util._print_versions import ( # type: ignore[attr-defined] + _get_dependency_info, + _get_sys_info, +) -from modin.config import Engine, StorageFormat, IsExperimental, ExperimentalNumPyAPI from modin._version import get_versions +from modin.config import Engine, ExperimentalNumPyAPI, IsExperimental, StorageFormat T = TypeVar("T") """Generic type parameter""" diff --git a/requirements-dev.txt b/requirements-dev.txt index 365017efb15..25203256160 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -60,3 +60,4 @@ flake8-no-implicit-concat>=0.3.4 flake8-print>=5.0.0 mypy>=1.0.0 pandas-stubs>=2.0.0 +isort>=5.12 diff --git a/scripts/doc_checker.py b/scripts/doc_checker.py index 413b4b71a7b..0979274d973 100644 --- a/scripts/doc_checker.py +++ b/scripts/doc_checker.py @@ -19,21 +19,21 @@ """ import argparse -import pathlib -import subprocess -import os -import re import ast -from typing import List -import sys +import functools import inspect -import shutil import logging -import functools -from numpydoc.validate import Docstring -from numpydoc.docscrape import NumpyDocString - +import os +import pathlib +import re +import shutil +import subprocess +import sys import types +from typing import List + +from numpydoc.docscrape import NumpyDocString +from numpydoc.validate import Docstring # fake cuDF-related modules if they're missing for mod_name in ("cudf", "cupy"): @@ -497,10 +497,12 @@ def pydocstyle_validate( def monkeypatching(): """Monkeypatch not installed modules and decorators which change __doc__ attribute.""" - import ray + from unittest.mock import Mock + import pandas.util + import ray + import modin.utils - from unittest.mock import Mock def monkeypatch(*args, **kwargs): if len(args) == 1 and len(kwargs) == 0 and callable(args[0]): diff --git a/scripts/release.py b/scripts/release.py index 455bb56aef3..567d36a0442 100644 --- a/scripts/release.py +++ b/scripts/release.py @@ -1,14 +1,14 @@ -import re -import json +import argparse import atexit import collections -import argparse -from pathlib import Path +import json +import re import sys -from packaging import version +from pathlib import Path -import pygit2 import github +import pygit2 +from packaging import version class GithubUserResolver: diff --git a/scripts/test/examples.py b/scripts/test/examples.py index d502a6ed477..44d46c5648b 100644 --- a/scripts/test/examples.py +++ b/scripts/test/examples.py @@ -15,6 +15,7 @@ # noqa: MD02 """Function examples for docstring testing.""" + class weakdict(dict): # noqa: GL08 __slots__ = ("__weakref__",) @@ -34,7 +35,7 @@ def optional_square(number: int = 5) -> int: # noqa ----- The `optional_square` Modin function from modin/scripts/examples.py. """ - return number ** 2 + return number**2 def optional_square_empty_parameters(number: int = 5) -> int: @@ -42,7 +43,7 @@ def optional_square_empty_parameters(number: int = 5) -> int: Parameters ---------- """ - return number ** 2 + return number**2 def square_summary(number: int) -> int: # noqa: PR01, GL08 @@ -56,4 +57,4 @@ def square_summary(number: int) -> int: # noqa: PR01, GL08 The function that will never be used in modin.pandas.DataFrame same as in pandas or NumPy. """ - return number ** 2 + return number**2 diff --git a/scripts/test/test_doc_checker.py b/scripts/test/test_doc_checker.py index 0df1c2d1692..81edc975dc5 100644 --- a/scripts/test/test_doc_checker.py +++ b/scripts/test/test_doc_checker.py @@ -12,14 +12,15 @@ # governing permissions and limitations under the License. import pytest +from numpydoc.validate import Docstring + from scripts.doc_checker import ( - get_optional_args, - check_optional_args, MODIN_ERROR_CODES, + check_optional_args, check_spelling_words, get_noqa_checks, + get_optional_args, ) -from numpydoc.validate import Docstring @pytest.mark.parametrize( diff --git a/setup.cfg b/setup.cfg index 2a9bbd5f8eb..28eca736e45 100644 --- a/setup.cfg +++ b/setup.cfg @@ -21,6 +21,9 @@ markers = filterwarnings = error:.*defaulting to pandas.*:UserWarning +[isort] +profile = black + [flake8] max-line-length = 88 ignore = E203, E266, E501, W503 diff --git a/setup.py b/setup.py index 2dbb11dfdee..b1b6f39fbf0 100644 --- a/setup.py +++ b/setup.py @@ -1,4 +1,5 @@ -from setuptools import setup, find_packages +from setuptools import find_packages, setup + import versioneer with open("README.md", "r", encoding="utf-8") as fh: diff --git a/stress_tests/kaggle/kaggle10.py b/stress_tests/kaggle/kaggle10.py index bfca06d759a..531ee15f48e 100755 --- a/stress_tests/kaggle/kaggle10.py +++ b/stress_tests/kaggle/kaggle10.py @@ -1,11 +1,13 @@ import matplotlib matplotlib.use("PS") -import numpy as np # linear algebra -import modin.pandas as pd # data processing, CSV file I/O (e.g. pd.read_csv) +import warnings + import matplotlib.pyplot as plt +import numpy as np # linear algebra import seaborn as sns -import warnings + +import modin.pandas as pd # data processing, CSV file I/O (e.g. pd.read_csv) warnings.filterwarnings("ignore") data = pd.read_csv("column_2C_weka.csv") @@ -119,8 +121,8 @@ ridge_predict = lasso.predict(x_test) print("Lasso score: ", lasso.score(x_test, y_test)) print("Lasso coefficients: ", lasso.coef_) -from sklearn.metrics import classification_report, confusion_matrix from sklearn.ensemble import RandomForestClassifier +from sklearn.metrics import classification_report, confusion_matrix x, y = data.loc[:, data.columns != "class"], data.loc[:, "class"] x_train, x_test, y_train, y_test = train_test_split(x, y, test_size=0.3, random_state=1) @@ -132,9 +134,8 @@ print("Classification report: \n", classification_report(y_test, y_pred)) sns.heatmap(cm, annot=True, fmt="d") plt.show() -from sklearn.metrics import roc_curve from sklearn.linear_model import LogisticRegression -from sklearn.metrics import confusion_matrix, classification_report +from sklearn.metrics import classification_report, confusion_matrix, roc_curve data["class_binary"] = [1 if i == "Abnormal" else 0 for i in data.loc[:, "class"]] x, y = ( @@ -176,9 +177,9 @@ df.head(10) df.drop("class_Normal", axis=1, inplace=True) df.head(10) -from sklearn.svm import SVC -from sklearn.preprocessing import StandardScaler from sklearn.pipeline import Pipeline +from sklearn.preprocessing import StandardScaler +from sklearn.svm import SVC steps = [("scalar", StandardScaler()), ("SVM", SVC())] pipeline = Pipeline(steps) @@ -218,8 +219,8 @@ plt.show() data = pd.read_csv("column_2C_weka.csv") data3 = data.drop("class", axis=1) -from sklearn.preprocessing import StandardScaler from sklearn.pipeline import make_pipeline +from sklearn.preprocessing import StandardScaler scalar = StandardScaler() kmeans = KMeans(n_clusters=2) @@ -229,7 +230,7 @@ df = pd.DataFrame({"labels": labels, "class": data["class"]}) ct = pd.crosstab(df["labels"], df["class"]) print(ct) -from scipy.cluster.hierarchy import linkage, dendrogram +from scipy.cluster.hierarchy import dendrogram, linkage merg = linkage(data3.iloc[200:220, :], method="single") dendrogram(merg, leaf_rotation=90, leaf_font_size=6) diff --git a/stress_tests/kaggle/kaggle12.py b/stress_tests/kaggle/kaggle12.py index 69b09953582..abd1ebeb6ac 100755 --- a/stress_tests/kaggle/kaggle12.py +++ b/stress_tests/kaggle/kaggle12.py @@ -1,30 +1,32 @@ import matplotlib matplotlib.use("PS") -import modin.pandas as pd -import numpy as np +from collections import Counter + import matplotlib.pyplot as plt +import numpy as np import seaborn as sns -from collections import Counter +from sklearn.discriminant_analysis import LinearDiscriminantAnalysis from sklearn.ensemble import ( - RandomForestClassifier, AdaBoostClassifier, - GradientBoostingClassifier, ExtraTreesClassifier, + GradientBoostingClassifier, + RandomForestClassifier, VotingClassifier, ) -from sklearn.discriminant_analysis import LinearDiscriminantAnalysis from sklearn.linear_model import LogisticRegression -from sklearn.neighbors import KNeighborsClassifier -from sklearn.tree import DecisionTreeClassifier -from sklearn.neural_network import MLPClassifier -from sklearn.svm import SVC from sklearn.model_selection import ( GridSearchCV, - cross_val_score, StratifiedKFold, + cross_val_score, learning_curve, ) +from sklearn.neighbors import KNeighborsClassifier +from sklearn.neural_network import MLPClassifier +from sklearn.svm import SVC +from sklearn.tree import DecisionTreeClassifier + +import modin.pandas as pd sns.set(style="white", context="notebook", palette="deep") train = pd.read_csv("train.csv") diff --git a/stress_tests/kaggle/kaggle13.py b/stress_tests/kaggle/kaggle13.py index e1cef920435..e8efed2edd5 100755 --- a/stress_tests/kaggle/kaggle13.py +++ b/stress_tests/kaggle/kaggle13.py @@ -2,12 +2,13 @@ import matplotlib matplotlib.use("PS") -import modin.pandas as pd import warnings # current version of seaborn generates a bunch of warnings that we'll ignore +import modin.pandas as pd + warnings.filterwarnings("ignore") -import seaborn as sns import matplotlib.pyplot as plt +import seaborn as sns sns.set(style="white", color_codes=True) iris = pd.read_csv("Iris.csv") # the iris dataset is now a Pandas DataFrame diff --git a/stress_tests/kaggle/kaggle14.py b/stress_tests/kaggle/kaggle14.py index 647871c0b71..53eebda2bba 100755 --- a/stress_tests/kaggle/kaggle14.py +++ b/stress_tests/kaggle/kaggle14.py @@ -1,10 +1,11 @@ import matplotlib matplotlib.use("PS") -import modin.pandas as pd import matplotlib.pyplot as plt import seaborn as sns +import modin.pandas as pd + plt.style.use("fivethirtyeight") import warnings @@ -222,15 +223,15 @@ plt.xticks(fontsize=14) plt.yticks(fontsize=14) plt.show() -from sklearn.linear_model import LogisticRegression # logistic regression +from sklearn import metrics # accuracy measure from sklearn import svm # support vector Machine from sklearn.ensemble import RandomForestClassifier # Random Forest -from sklearn.neighbors import KNeighborsClassifier # KNN +from sklearn.linear_model import LogisticRegression # logistic regression +from sklearn.metrics import confusion_matrix # for confusion matrix +from sklearn.model_selection import train_test_split # training and testing data split from sklearn.naive_bayes import GaussianNB # Naive bayes +from sklearn.neighbors import KNeighborsClassifier # KNN from sklearn.tree import DecisionTreeClassifier # Decision Tree -from sklearn.model_selection import train_test_split # training and testing data split -from sklearn import metrics # accuracy measure -from sklearn.metrics import confusion_matrix # for confusion matrix train, test = train_test_split( data, test_size=0.3, random_state=0, stratify=data["Survived"] @@ -296,8 +297,8 @@ "The accuracy of the Random Forests is", metrics.accuracy_score(prediction7, test_Y) ) from sklearn.model_selection import KFold # for K-fold cross validation -from sklearn.model_selection import cross_val_score # score evaluation from sklearn.model_selection import cross_val_predict # prediction +from sklearn.model_selection import cross_val_score # score evaluation kfold = KFold(n_splits=10, random_state=22) # k=10, split the data into 10 equal parts xyz = [] diff --git a/stress_tests/kaggle/kaggle18.py b/stress_tests/kaggle/kaggle18.py index 0dc422dba84..558ab445795 100755 --- a/stress_tests/kaggle/kaggle18.py +++ b/stress_tests/kaggle/kaggle18.py @@ -2,30 +2,31 @@ import matplotlib matplotlib.use("PS") -import nltk -import string import re +import string + +import matplotlib.pyplot as plt +import nltk import numpy as np import pandas as pd -import matplotlib.pyplot as plt import seaborn as sns sns.set(style="white") -from nltk.tokenize import word_tokenize, sent_tokenize -from nltk.corpus import stopwords -from sklearn.feature_extraction import stop_words +import warnings from collections import Counter -from wordcloud import WordCloud -from sklearn.feature_extraction.text import TfidfVectorizer -from sklearn.feature_extraction.text import CountVectorizer -from sklearn.decomposition import LatentDirichletAllocation -import plotly.offline as py -import plotly.graph_objs as go + import bokeh.plotting as bp +import plotly.graph_objs as go +import plotly.offline as py from bokeh.models import HoverTool # BoxSelectTool from bokeh.models import ColumnDataSource -from bokeh.plotting import show, output_notebook # figure -import warnings +from bokeh.plotting import output_notebook, show # figure +from nltk.corpus import stopwords +from nltk.tokenize import sent_tokenize, word_tokenize +from sklearn.decomposition import LatentDirichletAllocation +from sklearn.feature_extraction import stop_words +from sklearn.feature_extraction.text import CountVectorizer, TfidfVectorizer +from wordcloud import WordCloud warnings.filterwarnings("ignore") import logging diff --git a/stress_tests/kaggle/kaggle19.py b/stress_tests/kaggle/kaggle19.py index 5fdc1a4bffa..39950dec57a 100755 --- a/stress_tests/kaggle/kaggle19.py +++ b/stress_tests/kaggle/kaggle19.py @@ -3,11 +3,12 @@ import matplotlib matplotlib.use("PS") +import warnings + +import matplotlib.pyplot as plt import numpy as np import pandas as pd -import matplotlib.pyplot as plt import seaborn as sns -import warnings warnings.filterwarnings("ignore") train = pd.read_csv("train.csv") diff --git a/stress_tests/kaggle/kaggle20.py b/stress_tests/kaggle/kaggle20.py index 3cef9a4e921..be96a4a8392 100755 --- a/stress_tests/kaggle/kaggle20.py +++ b/stress_tests/kaggle/kaggle20.py @@ -1,11 +1,13 @@ import matplotlib matplotlib.use("PS") +import time + +import matplotlib.pyplot as plt import numpy as np # linear algebra -import modin.pandas as pd # data processing, CSV file I/O (e.g. pd.read_csv) import seaborn as sns # data visualization library -import matplotlib.pyplot as plt -import time + +import modin.pandas as pd # data processing, CSV file I/O (e.g. pd.read_csv) data = pd.read_csv("data.csv") data.head() # head method show only first 5 rows @@ -100,10 +102,10 @@ x_1.head() f, ax = plt.subplots(figsize=(14, 14)) sns.heatmap(x_1.corr(), annot=True, linewidths=0.5, fmt=".1f", ax=ax) -from sklearn.model_selection import train_test_split from sklearn.ensemble import RandomForestClassifier from sklearn.metrics import confusion_matrix # f1_score from sklearn.metrics import accuracy_score +from sklearn.model_selection import train_test_split x_train, x_test, y_train, y_test = train_test_split( x_1, y, test_size=0.3, random_state=42 @@ -114,8 +116,7 @@ print("Accuracy is: ", ac) cm = confusion_matrix(y_test, clf_rf.predict(x_test)) sns.heatmap(cm, annot=True, fmt="d") -from sklearn.feature_selection import SelectKBest -from sklearn.feature_selection import chi2 +from sklearn.feature_selection import SelectKBest, chi2 select_feature = SelectKBest(chi2, k=5).fit(x_train, y_train) print("Score list:", select_feature.scores_) diff --git a/stress_tests/kaggle/kaggle22.py b/stress_tests/kaggle/kaggle22.py index a1966aa3d83..ca3cface955 100755 --- a/stress_tests/kaggle/kaggle22.py +++ b/stress_tests/kaggle/kaggle22.py @@ -1,10 +1,11 @@ import matplotlib matplotlib.use("PS") -import modin.pandas as pd import numpy as np -from sklearn.linear_model import LogisticRegression from sklearn.feature_extraction.text import TfidfVectorizer # CountVectorizer +from sklearn.linear_model import LogisticRegression + +import modin.pandas as pd train = pd.read_csv("train.csv") test = pd.read_csv("test.csv") diff --git a/stress_tests/kaggle/kaggle3.py b/stress_tests/kaggle/kaggle3.py index ad4fa5e53bb..e593a880b70 100755 --- a/stress_tests/kaggle/kaggle3.py +++ b/stress_tests/kaggle/kaggle3.py @@ -2,11 +2,12 @@ import matplotlib matplotlib.use("PS") -import numpy as np # linear algebra -import modin.pandas as pd # data processing, CSV file I/O (e.g. pd.read_csv) import matplotlib.pyplot as plt +import numpy as np # linear algebra import seaborn as sns # visualization tool +import modin.pandas as pd # data processing, CSV file I/O (e.g. pd.read_csv) + data = pd.read_csv("pokemon.csv") data.info() data.corr() @@ -85,7 +86,7 @@ def tuble_ex(): - """ return defined t tuble""" + """return defined t tuble""" t = (1, 2, 3) return t @@ -117,10 +118,10 @@ def f(): def square(): - """ return square of value """ + """return square of value""" def add(): - """ add two local variable """ + """add two local variable""" x = 2 y = 3 z = x + y @@ -152,7 +153,7 @@ def f(*args): def f(**kwargs): - """ print key and value of dictionary""" + """print key and value of dictionary""" for ( key, value, @@ -164,7 +165,7 @@ def f(**kwargs): f(country="spain", capital="madrid", population=123456) number_list = [1, 2, 3] -y = map(lambda x: x ** 2, number_list) +y = map(lambda x: x**2, number_list) print(list(y)) name = "ronaldo" it = iter(name) @@ -185,7 +186,7 @@ def f(**kwargs): num2 = [i + 1 for i in num1] print(num2) num1 = [5, 10, 15] -num2 = [i ** 2 if i == 10 else i - 5 if i < 7 else i + 5 for i in num1] +num2 = [i**2 if i == 10 else i - 5 if i < 7 else i + 5 for i in num1] print(num2) threshold = sum(data.Speed) / len(data.Speed) data["speed_level"] = ["high" if i > threshold else "low" for i in data.Speed] @@ -223,8 +224,8 @@ def f(**kwargs): data.info() data["Type 2"].value_counts(dropna=False) data1 = ( - data -) # also we will use data to fill missing value so I assign it to data1 variable + data # also we will use data to fill missing value so I assign it to data1 variable +) data1["Type 2"].dropna( inplace=True ) # inplace = True means we do not assign it to new variable. Changes automatically assigned to data diff --git a/stress_tests/kaggle/kaggle4.py b/stress_tests/kaggle/kaggle4.py index d55b9c7dd6d..236cc4cdf4b 100755 --- a/stress_tests/kaggle/kaggle4.py +++ b/stress_tests/kaggle/kaggle4.py @@ -1,11 +1,12 @@ import matplotlib matplotlib.use("PS") -import numpy as np # linear algebra -import modin.pandas as pd # data processing, CSV file I/O (e.g. pd.read_csv) import matplotlib.pyplot as plt # Matlab-style plotting +import numpy as np # linear algebra import seaborn as sns +import modin.pandas as pd # data processing, CSV file I/O (e.g. pd.read_csv) + color = sns.color_palette() sns.set_style("darkgrid") import warnings @@ -201,16 +202,17 @@ def ignore_warn(*args, **kwargs): print(all_data.shape) train = all_data[:ntrain] test = all_data[ntrain:] -from sklearn.linear_model import ElasticNet, Lasso # BayesianRidge, LassoLarsIC +import lightgbm as lgb +import xgboost as xgb +from sklearn.base import BaseEstimator, RegressorMixin, TransformerMixin, clone from sklearn.ensemble import GradientBoostingRegressor # RandomForestRegressor from sklearn.kernel_ridge import KernelRidge +from sklearn.linear_model import ElasticNet # BayesianRidge, LassoLarsIC +from sklearn.linear_model import Lasso +from sklearn.metrics import mean_squared_error +from sklearn.model_selection import KFold, cross_val_score # train_test_split from sklearn.pipeline import make_pipeline from sklearn.preprocessing import RobustScaler -from sklearn.base import BaseEstimator, TransformerMixin, RegressorMixin, clone -from sklearn.model_selection import KFold, cross_val_score # train_test_split -from sklearn.metrics import mean_squared_error -import xgboost as xgb -import lightgbm as lgb n_folds = 5 diff --git a/stress_tests/kaggle/kaggle5.py b/stress_tests/kaggle/kaggle5.py index 67dee1c27c8..f185c367d68 100755 --- a/stress_tests/kaggle/kaggle5.py +++ b/stress_tests/kaggle/kaggle5.py @@ -1,19 +1,18 @@ import matplotlib matplotlib.use("PS") -import modin.pandas as pd +import matplotlib.pyplot as plt import numpy as np import seaborn as sns -import matplotlib.pyplot as plt -from sklearn.linear_model import LogisticRegression -from sklearn.svm import SVC, LinearSVC from sklearn.ensemble import RandomForestClassifier -from sklearn.neighbors import KNeighborsClassifier +from sklearn.linear_model import LogisticRegression, Perceptron, SGDClassifier from sklearn.naive_bayes import GaussianNB -from sklearn.linear_model import Perceptron -from sklearn.linear_model import SGDClassifier +from sklearn.neighbors import KNeighborsClassifier +from sklearn.svm import SVC, LinearSVC from sklearn.tree import DecisionTreeClassifier +import modin.pandas as pd + train_df = pd.read_csv("train.csv") test_df = pd.read_csv("test.csv") combine = [train_df, test_df] diff --git a/stress_tests/kaggle/kaggle6.py b/stress_tests/kaggle/kaggle6.py index e56dd46fe1c..e3336e558fc 100755 --- a/stress_tests/kaggle/kaggle6.py +++ b/stress_tests/kaggle/kaggle6.py @@ -1,21 +1,22 @@ import matplotlib matplotlib.use("PS") -import pandas as pd -import numpy as np import matplotlib.pyplot as plt +import numpy as np +import pandas as pd import seaborn as sns np.random.seed(2) -from sklearn.model_selection import train_test_split -from sklearn.metrics import confusion_matrix import itertools -from keras.utils.np_utils import to_categorical # convert to one-hot-encoding + +from keras.callbacks import ReduceLROnPlateau +from keras.layers import Conv2D, Dense, Dropout, Flatten, MaxPool2D from keras.models import Sequential -from keras.layers import Dense, Dropout, Flatten, Conv2D, MaxPool2D from keras.optimizers import RMSprop from keras.preprocessing.image import ImageDataGenerator -from keras.callbacks import ReduceLROnPlateau +from keras.utils.np_utils import to_categorical # convert to one-hot-encoding +from sklearn.metrics import confusion_matrix +from sklearn.model_selection import train_test_split sns.set(style="white", context="notebook", palette="deep") train = pd.read_csv("train.csv") @@ -140,7 +141,7 @@ def plot_confusion_matrix( def display_errors(errors_index, img_errors, pred_errors, obs_errors): - """ This function shows 6 images with their predicted and real labels""" + """This function shows 6 images with their predicted and real labels""" n = 0 nrows = 2 ncols = 3 diff --git a/stress_tests/kaggle/kaggle7.py b/stress_tests/kaggle/kaggle7.py index 3806e154266..de97e7ae5f0 100755 --- a/stress_tests/kaggle/kaggle7.py +++ b/stress_tests/kaggle/kaggle7.py @@ -1,10 +1,12 @@ import matplotlib matplotlib.use("PS") +import warnings + import numpy as np -import modin.pandas as pd from sklearn.preprocessing import LabelEncoder -import warnings + +import modin.pandas as pd warnings.filterwarnings("ignore") import matplotlib.pyplot as plt @@ -203,7 +205,7 @@ def corr_func(x, y, **kwargs): app_test_domain["DAYS_EMPLOYED_PERCENT"] = ( app_test_domain["DAYS_EMPLOYED"] / app_test_domain["DAYS_BIRTH"] ) -from sklearn.preprocessing import MinMaxScaler, Imputer +from sklearn.preprocessing import Imputer, MinMaxScaler if "TARGET" in app_train.columns: train = app_train.drop(columns=["TARGET"]) @@ -304,11 +306,12 @@ def plot_feature_importances(df): feature_importances_sorted = plot_feature_importances(feature_importances) feature_importances_domain_sorted = plot_feature_importances(feature_importances_domain) -from sklearn.model_selection import KFold -from sklearn.metrics import roc_auc_score -import lightgbm as lgb import gc +import lightgbm as lgb +from sklearn.metrics import roc_auc_score +from sklearn.model_selection import KFold + def model(features, test_features, encoding="ohe", n_folds=5): test_ids = test_features["SK_ID_CURR"] diff --git a/stress_tests/kaggle/kaggle8.py b/stress_tests/kaggle/kaggle8.py index ff5a3d0bc04..065ccc46037 100755 --- a/stress_tests/kaggle/kaggle8.py +++ b/stress_tests/kaggle/kaggle8.py @@ -1,6 +1,7 @@ -import modin.pandas as pd from sklearn.ensemble import RandomForestRegressor +import modin.pandas as pd + train = pd.read_csv("train.csv") train_y = train.SalePrice predictor_cols = ["LotArea", "OverallQual", "YearBuilt", "TotRmsAbvGrd"] diff --git a/stress_tests/kaggle/kaggle9.py b/stress_tests/kaggle/kaggle9.py index 21e83d8634b..d8692f4a5d7 100755 --- a/stress_tests/kaggle/kaggle9.py +++ b/stress_tests/kaggle/kaggle9.py @@ -1,12 +1,13 @@ import matplotlib matplotlib.use("PS") -import modin.pandas as pd -import numpy as np import matplotlib import matplotlib.pyplot as plt +import numpy as np from scipy.stats import skew +import modin.pandas as pd + train = pd.read_csv("train.csv") test = pd.read_csv("test.csv") train.head() @@ -34,7 +35,8 @@ X_train = all_data[: train.shape[0]] X_test = all_data[train.shape[0] :] y = train.SalePrice -from sklearn.linear_model import Ridge, LassoCV # RidgeCV, ElasticNet, LassoLarsCV +from sklearn.linear_model import LassoCV # RidgeCV, ElasticNet, LassoLarsCV +from sklearn.linear_model import Ridge from sklearn.model_selection import cross_val_score @@ -92,8 +94,8 @@ def rmse_cv(model): from keras.layers import Dense from keras.models import Sequential from keras.regularizers import l1 -from sklearn.preprocessing import StandardScaler from sklearn.model_selection import train_test_split +from sklearn.preprocessing import StandardScaler X_train = StandardScaler().fit_transform(X_train) X_tr, X_val, y_tr, y_val = train_test_split(X_train, y, random_state=3) diff --git a/stress_tests/test_kaggle_ipynb.py b/stress_tests/test_kaggle_ipynb.py index ff0bcd1bc0c..0d307b44650 100644 --- a/stress_tests/test_kaggle_ipynb.py +++ b/stress_tests/test_kaggle_ipynb.py @@ -1,19 +1,20 @@ +import logging import os import subprocess -import logging import numpy as np import pytest +import modin.pandas as pd + # import ray # ray.init(address="localhost:6379") -import modin.pandas as pd logger = logging.getLogger(__name__) # Size for synthetic datasets -DF_SIZE = 1 * 2 ** 10 * 2 ** 10 # * 2**10 # 1 GiB dataframes +DF_SIZE = 1 * 2**10 * 2**10 # * 2**10 # 1 GiB dataframes # This file path DIR_PATH = os.path.dirname(os.path.realpath(__file__)) KAGGLE_DIR_PATH = "{}/kaggle".format(DIR_PATH) diff --git a/versioneer.py b/versioneer.py index 47af8ddd556..ca691d15323 100644 --- a/versioneer.py +++ b/versioneer.py @@ -277,10 +277,12 @@ """ from __future__ import print_function + try: import configparser except ImportError: import ConfigParser as configparser + import errno import json import os @@ -1561,6 +1563,7 @@ def run(self): if "cx_Freeze" in sys.modules: # cx_freeze enabled? from cx_Freeze.dist import build_exe as _build_exe + # nczeczulin reports that py2exe won't like the pep440-style string # as FILEVERSION, but it can be used for PRODUCTVERSION, e.g. # setup(console=[{ From 28809907a064c9e414473e76f3aacbd5fd527d6b Mon Sep 17 00:00:00 2001 From: Dmitry Chigarev Date: Sun, 17 Sep 2023 00:53:49 +0200 Subject: [PATCH 004/201] FIX-#6558: Normalize the number of partitions after `.read_parquet()` (#6559) Signed-off-by: Dmitry Chigarev Co-authored-by: Anatoly Myachev --- .../column_stores/column_store_dispatcher.py | 24 ++- .../io/column_stores/parquet_dispatcher.py | 163 +++++++++++------- 2 files changed, 116 insertions(+), 71 deletions(-) diff --git a/modin/core/io/column_stores/column_store_dispatcher.py b/modin/core/io/column_stores/column_store_dispatcher.py index 684843d844d..602aad4c121 100644 --- a/modin/core/io/column_stores/column_store_dispatcher.py +++ b/modin/core/io/column_stores/column_store_dispatcher.py @@ -22,7 +22,7 @@ import numpy as np import pandas -from modin.config import NPartitions +from modin.config import MinPartitionSize, NPartitions from modin.core.io.file_dispatcher import FileDispatcher from modin.core.storage_formats.pandas.utils import compute_chunksize @@ -143,7 +143,7 @@ def build_index(cls, partition_ids): return index, row_lengths @classmethod - def build_columns(cls, columns): + def build_columns(cls, columns, num_row_parts=None): """ Split columns into chunks that should be read by workers. @@ -151,6 +151,10 @@ def build_columns(cls, columns): ---------- columns : list List of columns that should be read from file. + num_row_parts : int, optional + Number of parts the dataset is split into. This parameter is used + to align the column partitioning with it so we won't end up with an + over partitioned frame. Returns ------- @@ -163,11 +167,17 @@ def build_columns(cls, columns): columns_length = len(columns) if columns_length == 0: return [], [] - num_partitions = NPartitions.get() - column_splits = ( - columns_length // num_partitions - if columns_length % num_partitions == 0 - else columns_length // num_partitions + 1 + if num_row_parts is None: + # in column formats we mostly read columns in parallel rather than rows, + # so we try to chunk columns as much as possible + min_block_size = 1 + else: + num_remaining_parts = round(NPartitions.get() / num_row_parts) + min_block_size = min( + columns_length // num_remaining_parts, MinPartitionSize.get() + ) + column_splits = compute_chunksize( + columns_length, NPartitions.get(), max(1, min_block_size) ) col_partitions = [ columns[i : i + column_splits] diff --git a/modin/core/io/column_stores/parquet_dispatcher.py b/modin/core/io/column_stores/parquet_dispatcher.py index f060e1cb6d1..6596ead087d 100644 --- a/modin/core/io/column_stores/parquet_dispatcher.py +++ b/modin/core/io/column_stores/parquet_dispatcher.py @@ -16,6 +16,7 @@ import json import os import re +from typing import TYPE_CHECKING import fsspec import numpy as np @@ -28,9 +29,12 @@ from modin.config import NPartitions from modin.core.io.column_stores.column_store_dispatcher import ColumnStoreDispatcher -from modin.core.storage_formats.pandas.utils import compute_chunksize +from modin.error_message import ErrorMessage from modin.utils import _inherit_docstrings +if TYPE_CHECKING: + from modin.core.storage_formats.pandas.parsers import ParquetFileToRead + class ColumnStoreDataset: """ @@ -349,19 +353,102 @@ def get_dataset(cls, path, engine, storage_options): raise ValueError("engine must be one of 'pyarrow', 'fastparquet'") @classmethod - def call_deploy(cls, dataset, col_partitions, storage_options, **kwargs): + def _determine_partitioning( + cls, dataset: ColumnStoreDataset + ) -> "list[list[ParquetFileToRead]]": + """ + Determine which partition will read certain files/row groups of the dataset. + + Parameters + ---------- + dataset : ColumnStoreDataset + + Returns + ------- + list[list[ParquetFileToRead]] + Each element in the returned list describes a list of files that a partition has to read. + """ + from modin.core.storage_formats.pandas.parsers import ParquetFileToRead + + parquet_files = dataset.files + row_groups_per_file = dataset.row_groups_per_file + num_row_groups = sum(row_groups_per_file) + + if num_row_groups == 0: + return [] + + num_splits = min(NPartitions.get(), num_row_groups) + part_size = num_row_groups // num_splits + # If 'num_splits' does not divide 'num_row_groups' then we can't cover all of + # the row groups using the original 'part_size'. According to the 'reminder' + # there has to be that number of partitions that should read 'part_size + 1' + # number of row groups. + reminder = num_row_groups % num_splits + part_sizes = [part_size] * (num_splits - reminder) + [part_size + 1] * reminder + + partition_files = [] + file_idx = 0 + row_group_idx = 0 + row_groups_left_in_current_file = row_groups_per_file[file_idx] + # this is used for sanity check at the end, verifying that we indeed added all of the row groups + total_row_groups_added = 0 + for size in part_sizes: + row_groups_taken = 0 + part_files = [] + while row_groups_taken != size: + if row_groups_left_in_current_file < 1: + file_idx += 1 + row_group_idx = 0 + row_groups_left_in_current_file = row_groups_per_file[file_idx] + + to_take = min(size - row_groups_taken, row_groups_left_in_current_file) + part_files.append( + ParquetFileToRead( + parquet_files[file_idx], + row_group_start=row_group_idx, + row_group_end=row_group_idx + to_take, + ) + ) + row_groups_left_in_current_file -= to_take + row_groups_taken += to_take + row_group_idx += to_take + + total_row_groups_added += row_groups_taken + partition_files.append(part_files) + + sanity_check = ( + len(partition_files) == num_splits + and total_row_groups_added == num_row_groups + ) + ErrorMessage.catch_bugs_and_request_email( + failure_condition=not sanity_check, + extra_log="row groups added does not match total num of row groups across parquet files", + ) + return partition_files + + @classmethod + def call_deploy( + cls, + partition_files: "list[list[ParquetFileToRead]]", + col_partitions: "list[list[str]]", + storage_options: dict, + engine: str, + **kwargs, + ): """ Deploy remote tasks to the workers with passed parameters. Parameters ---------- - dataset : Dataset - Dataset object of Parquet file/files. - col_partitions : list + partition_files : list[list[ParquetFileToRead]] + List of arrays with files that should be read by each partition. + col_partitions : list[list[str]] List of arrays with columns names that should be read by each partition. storage_options : dict Parameters for specific storage engine. + engine : {"auto", "pyarrow", "fastparquet"} + Parquet library to use for reading. **kwargs : dict Parameters of deploying read_* function. @@ -370,67 +457,11 @@ def call_deploy(cls, dataset, col_partitions, storage_options, **kwargs): List Array with references to the task deploy result for each partition. """ - from modin.core.storage_formats.pandas.parsers import ParquetFileToRead - # If we don't have any columns to read, we should just return an empty # set of references. if len(col_partitions) == 0: return [] - row_groups_per_file = dataset.row_groups_per_file - num_row_groups = sum(row_groups_per_file) - parquet_files = dataset.files - - # step determines how many row groups are going to be in a partition - step = compute_chunksize( - num_row_groups, - NPartitions.get(), - min_block_size=1, - ) - current_partition_size = 0 - file_index = 0 - partition_files = [] # 2D array - each element contains list of chunks to read - row_groups_used_in_current_file = 0 - total_row_groups_added = 0 - # On each iteration, we add a chunk of one file. That will - # take us either to the end of a partition, or to the end - # of a file. - while total_row_groups_added < num_row_groups: - if current_partition_size == 0: - partition_files.append([]) - partition_file = partition_files[-1] - file_path = parquet_files[file_index] - row_group_start = row_groups_used_in_current_file - row_groups_left_in_file = ( - row_groups_per_file[file_index] - row_groups_used_in_current_file - ) - row_groups_left_for_this_partition = step - current_partition_size - if row_groups_left_for_this_partition <= row_groups_left_in_file: - # File has at least what we need to finish partition - # So finish this partition and start a new one. - num_row_groups_to_add = row_groups_left_for_this_partition - current_partition_size = 0 - else: - # File doesn't have enough to complete this partition. Add - # it into current partition and go to next file. - num_row_groups_to_add = row_groups_left_in_file - current_partition_size += num_row_groups_to_add - if num_row_groups_to_add == row_groups_left_in_file: - file_index += 1 - row_groups_used_in_current_file = 0 - else: - row_groups_used_in_current_file += num_row_groups_to_add - partition_file.append( - ParquetFileToRead( - file_path, row_group_start, row_group_start + num_row_groups_to_add - ) - ) - total_row_groups_added += num_row_groups_to_add - - assert ( - total_row_groups_added == num_row_groups - ), "row groups added does not match total num of row groups across parquet files" - all_partitions = [] for files_to_read in partition_files: all_partitions.append( @@ -440,7 +471,7 @@ def call_deploy(cls, dataset, col_partitions, storage_options, **kwargs): f_kwargs={ "files_for_parser": files_to_read, "columns": cols, - "engine": dataset.engine, + "engine": engine, "storage_options": storage_options, **kwargs, }, @@ -625,9 +656,13 @@ def build_query_compiler(cls, dataset, columns, index_columns, **kwargs): storage_options = kwargs.pop("storage_options", {}) or {} filters = kwargs.get("filters", None) - col_partitions, column_widths = cls.build_columns(columns) + partition_files = cls._determine_partitioning(dataset) + col_partitions, column_widths = cls.build_columns( + columns, + num_row_parts=len(partition_files), + ) partition_ids = cls.call_deploy( - dataset, col_partitions, storage_options, **kwargs + partition_files, col_partitions, storage_options, dataset.engine, **kwargs ) index, sync_index = cls.build_index( dataset, partition_ids, index_columns, filters From d05d382d543316efe8f0fa70f9526f50b2d8ab6d Mon Sep 17 00:00:00 2001 From: Anatoly Myachev Date: Mon, 18 Sep 2023 13:31:54 +0200 Subject: [PATCH 005/201] FIX-#6561: remove `MODIN_OMNISCI_*` env vars in favor of `MODIN_HDK_*` (#6562) Signed-off-by: Anatoly Myachev --- modin/config/envvars.py | 37 +------------------ modin/config/test/test_envvars.py | 20 +--------- .../hdk_on_native/hdk_worker.py | 4 +- 3 files changed, 4 insertions(+), 57 deletions(-) diff --git a/modin/config/envvars.py b/modin/config/envvars.py index c6a344d7374..25e611c6084 100644 --- a/modin/config/envvars.py +++ b/modin/config/envvars.py @@ -297,14 +297,8 @@ class HdkFragmentSize(EnvironmentVariable, type=int): varname = "MODIN_HDK_FRAGMENT_SIZE" -class OmnisciFragmentSize(EnvironmentVariable, type=int): - """How big a fragment in OmniSci should be when creating a table (in rows).""" - - varname = "MODIN_OMNISCI_FRAGMENT_SIZE" - - class DoUseCalcite(EnvironmentVariable, type=bool): - """Whether to use Calcite for OmniSci queries execution.""" + """Whether to use Calcite for HDK queries execution.""" varname = "MODIN_USE_CALCITE" default = True @@ -521,24 +515,6 @@ def get(cls) -> dict: Decode and merge specified command-line options with the default one. - Returns - ------- - dict - Decoded and verified config value. - """ - if cls == OmnisciLaunchParameters or ( - OmnisciLaunchParameters.varname in os.environ - and HdkLaunchParameters.varname not in os.environ - ): - return OmnisciLaunchParameters._get() - else: - return HdkLaunchParameters._get() - - @classmethod - def _get(cls) -> dict: - """ - Get the resulted command-line options. - Returns ------- dict @@ -585,17 +561,6 @@ def _get_default(cls) -> Any: return default -class OmnisciLaunchParameters(HdkLaunchParameters, type=dict): - """ - Additional command line options for the OmniSci engine. - - Please visit OmniSci documentation for the description of available parameters: - https://docs.omnisci.com/installation-and-configuration/config-parameters#configuration-parameters-for-omniscidb - """ - - varname = "MODIN_OMNISCI_LAUNCH_PARAMETERS" - - class MinPartitionSize(EnvironmentVariable, type=int): """ Minimum number of rows/columns in a single pandas partition split. diff --git a/modin/config/test/test_envvars.py b/modin/config/test/test_envvars.py index 4528b8cb1d7..a52b2ed5595 100644 --- a/modin/config/test/test_envvars.py +++ b/modin/config/test/test_envvars.py @@ -78,19 +78,13 @@ def test_hdk_envvar(): # This test is intended to check pyhdk internals. If pyhdk is not available, skip the version check test. pass - os.environ[ - cfg.OmnisciLaunchParameters.varname - ] = "enable_union=2,enable_thrift_logs=3" - del cfg.OmnisciLaunchParameters._value - params = cfg.OmnisciLaunchParameters.get() - assert params["enable_union"] == 2 - assert params["enable_thrift_logs"] == 3 - + os.environ[cfg.HdkLaunchParameters.varname] = "enable_union=2,enable_thrift_logs=3" params = cfg.HdkLaunchParameters.get() assert params["enable_union"] == 2 assert params["enable_thrift_logs"] == 3 os.environ[cfg.HdkLaunchParameters.varname] = "unsupported=X" + del cfg.HdkLaunchParameters._value params = cfg.HdkLaunchParameters.get() assert params["unsupported"] == "X" try: @@ -111,13 +105,3 @@ def test_hdk_envvar(): assert params["enable_union"] == 4 assert params["enable_thrift_logs"] == 5 assert params["enable_lazy_dict_materialization"] == 6 - - params = cfg.OmnisciLaunchParameters.get() - assert params["enable_union"] == 2 - assert params["enable_thrift_logs"] == 3 - - del os.environ[cfg.OmnisciLaunchParameters.varname] - del cfg.OmnisciLaunchParameters._value - params = cfg.OmnisciLaunchParameters.get() - assert params["enable_union"] == 4 - assert params["enable_thrift_logs"] == 5 diff --git a/modin/experimental/core/execution/native/implementations/hdk_on_native/hdk_worker.py b/modin/experimental/core/execution/native/implementations/hdk_on_native/hdk_worker.py index a87e0e2e775..aad566ef8e0 100644 --- a/modin/experimental/core/execution/native/implementations/hdk_on_native/hdk_worker.py +++ b/modin/experimental/core/execution/native/implementations/hdk_on_native/hdk_worker.py @@ -20,7 +20,7 @@ from packaging import version from pyhdk.hdk import HDK, ExecutionResult, QueryNode, RelAlgExecutor -from modin.config import HdkFragmentSize, HdkLaunchParameters, OmnisciFragmentSize +from modin.config import HdkFragmentSize, HdkLaunchParameters from modin.utils import _inherit_docstrings from .base_worker import BaseDbWorker, DbTable @@ -141,8 +141,6 @@ def compute_fragment_size(cls, table): Fragment size to use for import. """ fragment_size = HdkFragmentSize.get() - if fragment_size is None: - fragment_size = OmnisciFragmentSize.get() if fragment_size is None: if cls._preferred_device == "CPU": cpu_count = os.cpu_count() From 860069dc9497d47aad825beaa17d13badec2408e Mon Sep 17 00:00:00 2001 From: Anatoly Myachev Date: Mon, 18 Sep 2023 13:32:48 +0200 Subject: [PATCH 006/201] REFACTOR-#6569: use 'contextlib.nullcontext' instead of custom one (#6570) Signed-off-by: Anatoly Myachev --- modin/core/storage_formats/pandas/parsers.py | 8 +++----- modin/core/storage_formats/pandas/utils.py | 13 ------------- modin/experimental/pandas/test/test_io_exp.py | 6 +++--- modin/pandas/test/test_general.py | 14 ++++---------- modin/pandas/test/test_io.py | 8 +------- 5 files changed, 11 insertions(+), 38 deletions(-) diff --git a/modin/core/storage_formats/pandas/parsers.py b/modin/core/storage_formats/pandas/parsers.py index a4fb48d1360..e131ddc02de 100644 --- a/modin/core/storage_formats/pandas/parsers.py +++ b/modin/core/storage_formats/pandas/parsers.py @@ -39,6 +39,7 @@ parameters are passed into `pandas.read_sql` function without modification. """ +import contextlib import json import os import warnings @@ -55,10 +56,7 @@ from pandas.util._decorators import doc from modin.core.io.file_dispatcher import OpenFile -from modin.core.storage_formats.pandas.utils import ( - _nullcontext, - split_result_of_axis_func_pandas, -) +from modin.core.storage_formats.pandas.utils import split_result_of_axis_func_pandas from modin.db_conn import ModinDatabaseConnection from modin.error_message import ErrorMessage from modin.logging import ClassLogger @@ -888,7 +886,7 @@ def parse(files_for_parser, engine, **kwargs): for file_for_parser in files_for_parser: if isinstance(file_for_parser.path, IOBase): - context = _nullcontext(file_for_parser.path) + context = contextlib.nullcontext(file_for_parser.path) else: context = fsspec.open(file_for_parser.path, **storage_options) with context as f: diff --git a/modin/core/storage_formats/pandas/utils.py b/modin/core/storage_formats/pandas/utils.py index 35570ce1396..b8f267b3150 100644 --- a/modin/core/storage_formats/pandas/utils.py +++ b/modin/core/storage_formats/pandas/utils.py @@ -13,7 +13,6 @@ """Contains utility functions for frame partitioning.""" -import contextlib import re from math import ceil from typing import Hashable, List @@ -24,18 +23,6 @@ from modin.config import MinPartitionSize, NPartitions -@contextlib.contextmanager -def _nullcontext(dummy_value=None): # noqa: PR01 - """ - Act as a replacement for contextlib.nullcontext missing in older Python. - - Notes - ----- - contextlib.nullcontext is only available from Python 3.7. - """ - yield dummy_value - - def compute_chunksize(axis_len, num_splits, min_block_size=None): """ Compute the number of elements (rows/columns) to include in each partition. diff --git a/modin/experimental/pandas/test/test_io_exp.py b/modin/experimental/pandas/test/test_io_exp.py index 1295fc36816..4bfd8c9b7ab 100644 --- a/modin/experimental/pandas/test/test_io_exp.py +++ b/modin/experimental/pandas/test/test_io_exp.py @@ -11,9 +11,9 @@ # ANY KIND, either express or implied. See the License for the specific language # governing permissions and limitations under the License. +import contextlib import glob import json -from contextlib import nullcontext import numpy as np import pandas @@ -140,7 +140,7 @@ def test_read_csv_glob_4373(self): with ( warns_that_defaulting_to_pandas() if Engine.get() == "Dask" - else nullcontext() + else contextlib.nullcontext() ): df.to_csv(filename) @@ -258,7 +258,7 @@ def test_distributed_pickling(filename, compression): with ( warns_that_defaulting_to_pandas() if filename_param == test_default_to_pickle_filename - else nullcontext() + else contextlib.nullcontext() ): df.to_pickle_distributed(filename, compression=compression) pickled_df = pd.read_pickle_distributed(filename, compression=compression) diff --git a/modin/pandas/test/test_general.py b/modin/pandas/test/test_general.py index 467eae4f768..e46d5698c51 100644 --- a/modin/pandas/test/test_general.py +++ b/modin/pandas/test/test_general.py @@ -40,12 +40,6 @@ pytestmark = pytest.mark.filterwarnings(default_to_pandas_ignore_string) -@contextlib.contextmanager -def _nullcontext(): - """Replacement for contextlib.nullcontext missing in older Python.""" - yield - - @pytest.mark.parametrize("data", test_data_values, ids=test_data_keys) @pytest.mark.parametrize("append_na", [True, False]) @pytest.mark.parametrize("op", ["isna", "isnull", "notna", "notnull"]) @@ -86,7 +80,7 @@ def test_merge(): join_types = ["outer", "inner"] for how in join_types: - with warns_that_defaulting_to_pandas() if how == "outer" else _nullcontext(): + with warns_that_defaulting_to_pandas() if how == "outer" else contextlib.nullcontext(): modin_result = pd.merge(modin_df, modin_df2, how=how) pandas_result = pandas.merge(pandas_df, pandas_df2, how=how) df_equals(modin_result, pandas_result) @@ -115,7 +109,7 @@ def test_merge(): if how == "outer": warning_catcher = warns_that_defaulting_to_pandas() else: - warning_catcher = _nullcontext() + warning_catcher = contextlib.nullcontext() with warning_catcher: modin_result = pd.merge( modin_df, modin_df2, how=how, left_on="col1", right_on="col1" @@ -129,7 +123,7 @@ def test_merge(): if how == "outer": warning_catcher = warns_that_defaulting_to_pandas() else: - warning_catcher = _nullcontext() + warning_catcher = contextlib.nullcontext() with warning_catcher: modin_result = pd.merge( modin_df, modin_df2, how=how, left_on="col2", right_on="col2" @@ -896,7 +890,7 @@ def test_default_to_pandas_warning_message(func, regex): def test_empty_dataframe(): df = pd.DataFrame(columns=["a", "b"]) - with warns_that_defaulting_to_pandas() if StorageFormat.get() != "Hdk" else _nullcontext(): + with warns_that_defaulting_to_pandas() if StorageFormat.get() != "Hdk" else contextlib.nullcontext(): df[(df.a == 1) & (df.b == 2)] diff --git a/modin/pandas/test/test_io.py b/modin/pandas/test/test_io.py index b6f37f59f2f..12631db2202 100644 --- a/modin/pandas/test/test_io.py +++ b/modin/pandas/test/test_io.py @@ -115,12 +115,6 @@ } -@contextlib.contextmanager -def _nullcontext(): - """Replacement for contextlib.nullcontext missing in older Python.""" - yield - - def assert_files_eq(path1, path2): with open(path1, "rb") as file1, open(path2, "rb") as file2: file1_content = file1.read() @@ -1317,7 +1311,7 @@ def _check_relative_io(fn_name, unique_filename, path_arg, storage_default=()): pinned_home = {envvar: dirname for envvar in ("HOME", "USERPROFILE", "HOMEPATH")} should_default = Engine.get() == "Python" or StorageFormat.get() in storage_default with mock.patch.dict(os.environ, pinned_home): - with warns_that_defaulting_to_pandas() if should_default else _nullcontext(): + with warns_that_defaulting_to_pandas() if should_default else contextlib.nullcontext(): eval_io( fn_name=fn_name, **{path_arg: f"~/{basename}"}, From 5432da0beb09c3a17766bccea23a44f36e8ae5fc Mon Sep 17 00:00:00 2001 From: Anatoly Myachev Date: Mon, 18 Sep 2023 13:34:29 +0200 Subject: [PATCH 007/201] FIX-#6565: don't implement 'map' function via 'applymap' (#6566) Signed-off-by: Anatoly Myachev --- .../using_modin/using_modin_cluster.rst | 2 +- docs/usage_guide/benchmarking.rst | 24 +++++++++---------- .../storage_formats/base/query_compiler.py | 4 ++-- .../storage_formats/pandas/query_compiler.py | 2 +- .../hdk_on_native/test/utils.py | 4 +--- modin/pandas/dataframe.py | 4 +--- modin/pandas/series.py | 4 ++-- 7 files changed, 20 insertions(+), 24 deletions(-) diff --git a/docs/getting_started/using_modin/using_modin_cluster.rst b/docs/getting_started/using_modin/using_modin_cluster.rst index 384eaff06d5..1dfc39d3182 100644 --- a/docs/getting_started/using_modin/using_modin_cluster.rst +++ b/docs/getting_started/using_modin/using_modin_cluster.rst @@ -76,7 +76,7 @@ in a Jupyter notebook: groupby_result = df.groupby("passenger_count").count() %%time - apply_result = df.applymap(str) + apply_result = df.map(str) Modin performance scales as the number of nodes and cores increases. The following chart shows the performance of the above operations with 2, 4, and 8 nodes, with diff --git a/docs/usage_guide/benchmarking.rst b/docs/usage_guide/benchmarking.rst index 509724cb206..7340a31db97 100644 --- a/docs/usage_guide/benchmarking.rst +++ b/docs/usage_guide/benchmarking.rst @@ -37,17 +37,17 @@ Consider the following ipython script: ray.init() df = pd.DataFrame(list(range(MinPartitionSize.get() * 2))) - %time result = df.applymap(lambda x: time.sleep(0.1) or x) + %time result = df.map(lambda x: time.sleep(0.1) or x) %time print(result) -Modin takes just 2.68 milliseconds for the ``applymap``, and 3.78 seconds to print +Modin takes just 2.68 milliseconds for the ``map``, and 3.78 seconds to print the result. However, if we run this script in pandas by replacing -:code:`import modin.pandas as pd` with :code:`import pandas as pd`, the ``applymap`` +:code:`import modin.pandas as pd` with :code:`import pandas as pd`, the ``map`` takes 6.63 seconds, and printing the result takes just 5.53 milliseconds. -Both pandas and Modin start executing the ``applymap`` as soon as the interpreter -evalutes it. While pandas blocks until the ``applymap`` has finished, Modin just kicks +Both pandas and Modin start executing the ``map`` as soon as the interpreter +evalutes it. While pandas blocks until the ``map`` has finished, Modin just kicks off asynchronous functions in remote ray processes. Printing the function result is fairly fast in pandas and Modin, but before Modin can print the data, it has to wait until all the remote functions complete. @@ -61,7 +61,7 @@ to complete. You can turn on benchmark mode on at any point as follows: from modin.config import BenchmarkMode BenchmarkMode.put(True) -Rerunning the script above with benchmark mode on, the Modin ``applymap`` takes +Rerunning the script above with benchmark mode on, the Modin ``map`` takes 3.59 seconds, and the ``print`` takes 183 milliseconds. These timings better reflect where Modin is spending its execution time. @@ -87,18 +87,18 @@ following script with benchmark mode on: start = time.time() df = pd.DataFrame(list(range(MinPartitionSize.get())), columns=['A']) - result1 = df.applymap(lambda x: time.sleep(0.2) or x + 1) - result2 = df.applymap(lambda x: time.sleep(0.2) or x + 2) + result1 = df.map(lambda x: time.sleep(0.2) or x + 1) + result2 = df.map(lambda x: time.sleep(0.2) or x + 2) result1.to_parquet(BytesIO()) result2.to_parquet(BytesIO()) end = time.time() - print(f'applymap and write to parquet took {end - start} seconds.') + print(f'map and write to parquet took {end - start} seconds.') .. code-block::python -The script does two slow ``applymap`` on a dataframe and then writes each result +The script does two slow ``map`` on a dataframe and then writes each result to a buffer. The whole script takes 13 seconds with benchmark mode on, but -just 7 seconds with benchmark mode off. Because Modin can run the ``applymap`` +just 7 seconds with benchmark mode off. Because Modin can run the ``map`` asynchronously, it can start writing the first result to its buffer while it's still computing the second result. With benchmark mode on, Modin has to execute every function synchronously instead. @@ -148,7 +148,7 @@ be misleading, e.g. here: ray.init() df1 = pd.DataFrame(list(range(10_000)), columns=['A']) - result = df1.applymap(slow_add_one) + result = df1.map(slow_add_one) %time repr(result) # time.sleep(10) %time result.to_parquet(BytesIO()) diff --git a/modin/core/storage_formats/base/query_compiler.py b/modin/core/storage_formats/base/query_compiler.py index 8e802cdec2e..ed16bedd481 100644 --- a/modin/core/storage_formats/base/query_compiler.py +++ b/modin/core/storage_formats/base/query_compiler.py @@ -1460,7 +1460,7 @@ def abs(self): """ return DataFrameDefault.register(pandas.DataFrame.abs)(self) - def applymap(self, func, *args, **kwargs): + def map(self, func, *args, **kwargs): """ Apply passed function elementwise. @@ -1476,7 +1476,7 @@ def applymap(self, func, *args, **kwargs): BaseQueryCompiler Transformed QueryCompiler. """ - return DataFrameDefault.register(pandas.DataFrame.applymap)( + return DataFrameDefault.register(pandas.DataFrame.map)( self, func, *args, **kwargs ) diff --git a/modin/core/storage_formats/pandas/query_compiler.py b/modin/core/storage_formats/pandas/query_compiler.py index 870a80319f4..08cda41c962 100644 --- a/modin/core/storage_formats/pandas/query_compiler.py +++ b/modin/core/storage_formats/pandas/query_compiler.py @@ -1778,7 +1778,7 @@ def isin_func(df, values): ) abs = Map.register(pandas.DataFrame.abs, dtypes="copy") - applymap = Map.register(pandas.DataFrame.applymap) + map = Map.register(pandas.DataFrame.map) conj = Map.register(lambda df, *args, **kwargs: pandas.DataFrame(np.conj(df))) convert_dtypes = Fold.register(pandas.DataFrame.convert_dtypes) invert = Map.register(pandas.DataFrame.__invert__, dtypes="copy") diff --git a/modin/experimental/core/execution/native/implementations/hdk_on_native/test/utils.py b/modin/experimental/core/execution/native/implementations/hdk_on_native/test/utils.py index ec3e99ad954..8ccdaf4718b 100644 --- a/modin/experimental/core/execution/native/implementations/hdk_on_native/test/utils.py +++ b/modin/experimental/core/execution/native/implementations/hdk_on_native/test/utils.py @@ -144,9 +144,7 @@ def convert_to_time(value): if datetime_cols: pandas_df = pandas_df.astype(datetime_cols) if time_cols: - pandas_df[time_cols_list] = pandas_df[time_cols_list].applymap( - convert_to_time - ) + pandas_df[time_cols_list] = pandas_df[time_cols_list].map(convert_to_time) casted_dfs.append(pandas_df) return casted_dfs diff --git a/modin/pandas/dataframe.py b/modin/pandas/dataframe.py index 74690e6a8f9..9ee9a6cf9e1 100644 --- a/modin/pandas/dataframe.py +++ b/modin/pandas/dataframe.py @@ -376,9 +376,7 @@ def map(self, func, na_action: Optional[str] = None, **kwargs): if not callable(func): raise ValueError("'{0}' object is not callable".format(type(func))) return self.__constructor__( - query_compiler=self._query_compiler.applymap( - func, na_action=na_action, **kwargs - ) + query_compiler=self._query_compiler.map(func, na_action=na_action, **kwargs) ) def applymap(self, func, na_action: Optional[str] = None, **kwargs): diff --git a/modin/pandas/series.py b/modin/pandas/series.py index c478a29e754..c8016e777ce 100644 --- a/modin/pandas/series.py +++ b/modin/pandas/series.py @@ -640,7 +640,7 @@ def f(x): # The return_type is only a DataFrame when we have a function # return a Series object. This is a very particular case that # has to be handled by the underlying pandas.Series apply - # function and not our default applymap call. + # function and not our default map call. if return_type == "DataFrame": result = self._query_compiler.apply_on_series(f) else: @@ -1218,7 +1218,7 @@ def arg(s): return mapper.get(s, np.nan) return self.__constructor__( - query_compiler=self._query_compiler.applymap( + query_compiler=self._query_compiler.map( lambda s: arg(s) if pandas.isnull(s) is not True or na_action is None else s From 5febff397d9b731570ecf187631ae12732eefb5a Mon Sep 17 00:00:00 2001 From: Anatoly Myachev Date: Mon, 18 Sep 2023 13:57:20 +0200 Subject: [PATCH 008/201] TEST-#4270: revert disabling `time_groupby_agg_nunique` ASV bench (#6564) Signed-off-by: Anatoly Myachev --- .github/workflows/ci.yml | 5 ----- asv_bench/benchmarks/utils/data_shapes.py | 7 ++++++- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9e7323cdfbb..00fa76d5c05 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -257,11 +257,6 @@ jobs: MODIN_ASV_USE_IMPL=pandas asv run --quick --strict --show-stderr --launch-method=spawn \ -b ^benchmarks -b ^io | tee benchmarks.log - # HDK: ERR_OUT_OF_CPU_MEM: Not enough host memory to execute the query (MODIN#4270) - # just disable test for testing - it works well in a machine with more memory - sed -i 's/def time_groupby_agg_nunique(self, \*args, \*\*kwargs):/# def time_groupby_agg_nunique(self, *args, **kwargs):/g' benchmarks/hdk/benchmarks.py - sed -i 's/execute(self.df.groupby(by=self.groupby_columns).agg("nunique"))/# execute(self.df.groupby(by=self.groupby_columns).agg("nunique"))/g' benchmarks/hdk/benchmarks.py - # Otherwise, ASV considers that the environment has already been created, although ASV command is run for another config, # which requires the creation of a completely new environment. This step will be required after removing the manual environment setup step. rm -f -R .asv/env/ diff --git a/asv_bench/benchmarks/utils/data_shapes.py b/asv_bench/benchmarks/utils/data_shapes.py index bd96f1ed566..989ae80f50f 100644 --- a/asv_bench/benchmarks/utils/data_shapes.py +++ b/asv_bench/benchmarks/utils/data_shapes.py @@ -19,7 +19,12 @@ from .compatibility import ASV_DATASET_SIZE, ASV_USE_STORAGE_FORMAT RAND_LOW = 0 -RAND_HIGH = 1_000_000_000 if ASV_USE_STORAGE_FORMAT == "hdk" else 100 +# use a small number of unique values in Github actions to avoid OOM (mostly related to HDK) +RAND_HIGH = ( + 1_000_000_000 + if ASV_USE_STORAGE_FORMAT == "hdk" and ASV_DATASET_SIZE == "Big" + else 100 +) BINARY_OP_DATA_SIZE = { "big": [ From 4fb08a90496f95aa5be3ddbf67f5a49f2b3fa502 Mon Sep 17 00:00:00 2001 From: Anatoly Myachev Date: Mon, 18 Sep 2023 18:31:47 +0200 Subject: [PATCH 009/201] REFACTOR-#6576: Don't use deprecated 'is_int64_dtype' and 'is_period_dtype' function (#6577) Signed-off-by: Anatoly Myachev --- .../execution/native/implementations/hdk_on_native/expr.py | 5 ++--- modin/pandas/test/utils.py | 3 +-- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/modin/experimental/core/execution/native/implementations/hdk_on_native/expr.py b/modin/experimental/core/execution/native/implementations/hdk_on_native/expr.py index ccd657e5f3b..2b6112f1a8a 100644 --- a/modin/experimental/core/execution/native/implementations/hdk_on_native/expr.py +++ b/modin/experimental/core/execution/native/implementations/hdk_on_native/expr.py @@ -26,7 +26,6 @@ is_datetime64_any_dtype, is_datetime64_dtype, is_float_dtype, - is_int64_dtype, is_integer_dtype, is_list_like, is_numeric_dtype, @@ -69,8 +68,8 @@ def _get_common_dtype(lhs_dtype, rhs_dtype): return _get_dtype(int) if is_datetime64_dtype(lhs_dtype) and is_datetime64_dtype(rhs_dtype): return np.promote_types(lhs_dtype, rhs_dtype) - if (is_datetime64_dtype(lhs_dtype) and is_int64_dtype(rhs_dtype)) or ( - is_datetime64_dtype(rhs_dtype) and is_int64_dtype(lhs_dtype) + if (is_datetime64_dtype(lhs_dtype) and rhs_dtype == np.int64) or ( + is_datetime64_dtype(rhs_dtype) and (lhs_dtype == np.int64) ): return _get_dtype(int) raise NotImplementedError( diff --git a/modin/pandas/test/utils.py b/modin/pandas/test/utils.py index 91b69e7017f..b4d77554949 100644 --- a/modin/pandas/test/utils.py +++ b/modin/pandas/test/utils.py @@ -32,7 +32,6 @@ is_list_like, is_numeric_dtype, is_object_dtype, - is_period_dtype, is_string_dtype, is_timedelta64_dtype, ) @@ -650,7 +649,7 @@ def assert_dtypes_equal(df1, df2): lambda obj: isinstance(obj, pandas.CategoricalDtype), is_datetime64_any_dtype, is_timedelta64_dtype, - is_period_dtype, + lambda obj: isinstance(obj, pandas.PeriodDtype), ) for col in dtypes1.keys(): From 40216fab5c19418f1296eb746eaa39f5416d1667 Mon Sep 17 00:00:00 2001 From: Dmitry Chigarev Date: Mon, 18 Sep 2023 21:47:58 +0200 Subject: [PATCH 010/201] FIX-#6572: Execute simple queries row-wise in pandas backend (#6575) Signed-off-by: Dmitry Chigarev --- .../storage_formats/base/query_compiler.py | 19 ++++++ .../storage_formats/pandas/query_compiler.py | 60 +++++++++++++++++++ modin/pandas/dataframe.py | 19 +++++- .../storage_formats/pandas/test_internals.py | 30 ++++++++++ 4 files changed, 125 insertions(+), 3 deletions(-) diff --git a/modin/core/storage_formats/base/query_compiler.py b/modin/core/storage_formats/base/query_compiler.py index ed16bedd481..17c45b0dac7 100644 --- a/modin/core/storage_formats/base/query_compiler.py +++ b/modin/core/storage_formats/base/query_compiler.py @@ -2271,6 +2271,25 @@ def nsmallest(self, n=5, columns=None, keep="first"): self, n=n, columns=columns, keep=keep ) + @doc_utils.add_refer_to("DataFrame.query") + def rowwise_query(self, expr, **kwargs): + """ + Query columns of the QueryCompiler with a boolean expression row-wise. + + Parameters + ---------- + expr : str + **kwargs : dict + + Returns + ------- + BaseQueryCompiler + New QueryCompiler containing the rows where the boolean expression is satisfied. + """ + raise NotImplementedError( + "Row-wise queries execution is not implemented for the selected backend." + ) + @doc_utils.add_refer_to("DataFrame.eval") def eval(self, expr, **kwargs): """ diff --git a/modin/core/storage_formats/pandas/query_compiler.py b/modin/core/storage_formats/pandas/query_compiler.py index 08cda41c962..d8d00d5bb9f 100644 --- a/modin/core/storage_formats/pandas/query_compiler.py +++ b/modin/core/storage_formats/pandas/query_compiler.py @@ -18,6 +18,7 @@ queries for the ``PandasDataframe``. """ +import ast import hashlib import re import warnings @@ -3186,6 +3187,65 @@ def _list_like_func(self, func, axis, *args, **kwargs): ) return self.__constructor__(new_modin_frame) + def rowwise_query(self, expr, **kwargs): + """ + Query the columns of a ``PandasQueryCompiler`` with a boolean row-wise expression. + + Basically, in row-wise expressions we only allow column names, constants + and other variables captured using the '@' symbol. No function/method + cannot be called inside such expressions. + + Parameters + ---------- + expr : str + Row-wise boolean expression. + **kwargs : dict + Arguments to pass to the ``pandas.DataFrame.query()``. + + Returns + ------- + PandasQueryCompiler + + Raises + ------ + NotImplementedError + In case the passed expression cannot be executed row-wise. + """ + # Walk through the AST and verify it doesn't contain any nodes that + # prevent us from executing the query row-wise (we're basically + # looking for 'ast.Call') + nodes = ast.parse(expr.replace("@", "")).body + is_row_wise_query = True + + while nodes: + node = nodes.pop() + if isinstance(node, ast.Expr): + node = getattr(node, "value", node) + + if isinstance(node, ast.UnaryOp): + nodes.append(node.operand) + elif isinstance(node, ast.BinOp): + nodes.extend([node.left, node.right]) + elif isinstance(node, ast.BoolOp): + nodes.extend(node.values) + elif isinstance(node, ast.Compare): + nodes.extend([node.left] + node.comparators) + elif isinstance(node, (ast.Name, ast.Constant)): + pass + else: + # if we end up here then the expression is no longer simple + # enough to run it row-wise, so exiting + is_row_wise_query = False + break + + if not is_row_wise_query: + raise NotImplementedError("A non row-wise query was passed.") + + def query_builder(df, **modin_internal_kwargs): + return df.query(expr, inplace=False, **kwargs, **modin_internal_kwargs) + + return self.__constructor__(self._modin_frame.filter(1, query_builder)) + def _callable_func(self, func, axis, *args, **kwargs): """ Apply passed function to each row/column. diff --git a/modin/pandas/dataframe.py b/modin/pandas/dataframe.py index 9ee9a6cf9e1..16dabeb4b42 100644 --- a/modin/pandas/dataframe.py +++ b/modin/pandas/dataframe.py @@ -1605,10 +1605,23 @@ def query(self, expr, inplace=False, **kwargs): # noqa: PR01, RT01, D200 Query the columns of a ``DataFrame`` with a boolean expression. """ self._update_var_dicts_in_kwargs(expr, kwargs) + self._validate_eval_query(expr, **kwargs) inplace = validate_bool_kwarg(inplace, "inplace") - new_query_compiler = pandas.DataFrame.query( - self, expr, inplace=False, **kwargs - )._query_compiler + # HACK: this condition kind of breaks the idea of backend agnostic API as all queries + # _should_ work fine for all of the engines using `pandas.DataFrame.query(...)` approach. + # However, at this point we know that we can execute simple queries way more efficiently + # using the QC's API directly in case of pandas backend. Ideally, we have to make it work + # with the 'pandas.query' approach the same as good the direct QC call is. But investigating + # and fixing the root cause of the perf difference appears to be much more complicated + # than putting this hack here. Hopefully, we'll get rid of it soon: + # https://github.com/modin-project/modin/issues/6499 + try: + new_query_compiler = self._query_compiler.rowwise_query(expr, **kwargs) + except NotImplementedError: + # a non row-wise query was passed, falling back to pandas implementation + new_query_compiler = pandas.DataFrame.query( + self, expr, inplace=False, **kwargs + )._query_compiler return self._create_or_update_from_compiler(new_query_compiler, inplace) def rename( diff --git a/modin/test/storage_formats/pandas/test_internals.py b/modin/test/storage_formats/pandas/test_internals.py index 28258cbaf1a..8d4213015ba 100644 --- a/modin/test/storage_formats/pandas/test_internals.py +++ b/modin/test/storage_formats/pandas/test_internals.py @@ -1310,3 +1310,33 @@ def test_skip_set_columns(): # of equality comparison, in this case the new columns should be set unconditionally, # meaning that the '_deferred_column' has to be True assert df._query_compiler._modin_frame._deferred_column + + +def test_query_dispatching(): + """ + Test whether the logic of determining whether the passed query + can be performed row-wise works correctly in ``PandasQueryCompiler.rowwise_query()``. + + The tested method raises a ``NotImpementedError`` if the query cannot be performed row-wise + and raises nothing if it can. + """ + qc = pd.DataFrame( + {"a": [1], "b": [2], "c": [3], "d": [4], "e": [5]} + )._query_compiler + + local_var = 10 # noqa: F841 (unused variable) + + # these queries should be performed row-wise (so no exception) + qc.rowwise_query("a < 1") + qc.rowwise_query("a < b") + qc.rowwise_query("a < (b + @local_var) * c > 10") + + # these queries cannot be performed row-wise (so they must raise an exception) + with pytest.raises(NotImplementedError): + qc.rowwise_query("a < b[0]") + with pytest.raises(NotImplementedError): + qc.rowwise_query("a < b.min()") + with pytest.raises(NotImplementedError): + qc.rowwise_query("a < (b + @local_var + (b - e.min())) * c > 10") + with pytest.raises(NotImplementedError): + qc.rowwise_query("a < b.size") From e5102f50b6a5a2568148799199880523a129c634 Mon Sep 17 00:00:00 2001 From: Mahesh Vashishtha Date: Mon, 18 Sep 2023 17:06:31 -0700 Subject: [PATCH 011/201] PERF-#6583: Remove redundant index reassignment in query() (#6584) Signed-off-by: mvashishtha --- modin/pandas/dataframe.py | 35 ++++++++++++++++++++++++- modin/pandas/test/dataframe/test_udf.py | 35 +++++++++++++++++++++++++ modin/pandas/test/utils.py | 3 +++ 3 files changed, 72 insertions(+), 1 deletion(-) diff --git a/modin/pandas/dataframe.py b/modin/pandas/dataframe.py index 16dabeb4b42..98ef7a46f91 100644 --- a/modin/pandas/dataframe.py +++ b/modin/pandas/dataframe.py @@ -1597,7 +1597,40 @@ def quantile( # methods and fields we need to use pandas.DataFrame.query _AXIS_ORDERS = ["index", "columns"] _get_index_resolvers = pandas.DataFrame._get_index_resolvers - _get_axis_resolvers = pandas.DataFrame._get_axis_resolvers + + def _get_axis_resolvers(self, axis: str) -> dict: + # forked from pandas because we only want to update the index if there's more + # than one level of the index. + # index or columns + axis_index = getattr(self, axis) + d = {} + prefix = axis[0] + + for i, name in enumerate(axis_index.names): + if name is not None: + key = level = name + else: + # prefix with 'i' or 'c' depending on the input axis + # e.g., you must do ilevel_0 for the 0th level of an unnamed + # multiiindex + key = f"{prefix}level_{i}" + level = i + + level_values = axis_index.get_level_values(level) + s = level_values.to_series() + if axis_index.nlevels > 1: + s.index = axis_index + d[key] = s + + # put the index/columns itself in the dict + if axis_index.nlevels > 2: + dindex = axis_index + else: + dindex = axis_index.to_series() + + d[axis] = dindex + return d + _get_cleaned_column_resolvers = pandas.DataFrame._get_cleaned_column_resolvers def query(self, expr, inplace=False, **kwargs): # noqa: PR01, RT01, D200 diff --git a/modin/pandas/test/dataframe/test_udf.py b/modin/pandas/test/dataframe/test_udf.py index 8f34786f290..2bd82aca804 100644 --- a/modin/pandas/test/dataframe/test_udf.py +++ b/modin/pandas/test/dataframe/test_udf.py @@ -446,6 +446,41 @@ def test_query(data, funcs, engine): df_equals(modin_result.dtypes, pandas_result.dtypes) +def test_query_named_index(): + eval_general( + *(df.set_index("col1") for df in create_test_dfs(test_data["int_data"])), + lambda df: df.query("col1 % 2 == 0 | col3 % 2 == 1"), + # work around https://github.com/modin-project/modin/issues/6016 + raising_exceptions=(Exception,), + ) + + +def test_query_named_multiindex(): + eval_general( + *( + df.set_index(["col1", "col3"]) + for df in create_test_dfs(test_data["int_data"]) + ), + lambda df: df.query("col1 % 2 == 1 | col3 % 2 == 1"), + # work around https://github.com/modin-project/modin/issues/6016 + raising_exceptions=(Exception,), + ) + + +def test_query_multiindex_without_names(): + def make_df(without_index): + new_df = without_index.set_index(["col1", "col3"]) + new_df.index.names = [None, None] + return new_df + + eval_general( + *(make_df(df) for df in create_test_dfs(test_data["int_data"])), + lambda df: df.query("ilevel_0 % 2 == 0 | ilevel_1 % 2 == 1 | col4 % 2 == 1"), + # work around https://github.com/modin-project/modin/issues/6016 + raising_exceptions=(Exception,), + ) + + def test_empty_query(): modin_df = pd.DataFrame([1, 2, 3, 4, 5]) diff --git a/modin/pandas/test/utils.py b/modin/pandas/test/utils.py index b4d77554949..ab1251ccf4f 100644 --- a/modin/pandas/test/utils.py +++ b/modin/pandas/test/utils.py @@ -308,6 +308,9 @@ "col3 > col4": "col3 > col4", "col1 == col2": "col1 == col2", "(col2 > col1) and (col1 < col3)": "(col2 > col1) and (col1 < col3)", + # this is how to query for values of an unnamed index per + # https://pandas.pydata.org/docs/user_guide/indexing.html#multiindex-query-syntax + "ilevel_0 % 2 == 1": "ilevel_0 % 2 == 1", } query_func_keys = list(query_func.keys()) query_func_values = list(query_func.values()) From a1a39a052919fe7ab6033d76a570f7ec3739ed4d Mon Sep 17 00:00:00 2001 From: Anatoly Myachev Date: Wed, 20 Sep 2023 09:00:29 +0200 Subject: [PATCH 012/201] TEST-#4348: use `psycopg2-binary` for testing and developing purpose (#6573) Signed-off-by: Anatoly Myachev --- requirements-dev.txt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 25203256160..0f855c5e8df 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -26,7 +26,9 @@ pandas-gbq>=0.15.0 tables>=3.7.0 # pymssql==2.2.8 broken: https://github.com/modin-project/modin/issues/6429 pymssql>=2.1.5,!=2.2.8 -psycopg2>=2.9.3 +# psycopg devs recommend the other way of installation for production +# but this is ok for testing and development +psycopg2-binary>=2.9.3 connectorx>=0.2.6a4 fastparquet>=0.8.1 flask-cors From 88b245b4d72911849fedbb9105dbe98af1cc06a9 Mon Sep 17 00:00:00 2001 From: Anatoly Myachev Date: Wed, 20 Sep 2023 13:07:28 +0200 Subject: [PATCH 013/201] FEAT-#1611: Add some datetime extraction functions for HDK (#6568) Signed-off-by: Alina Signed-off-by: Anatoly Myachev Co-authored-by: Alina --- .../implementations/hdk_on_native/expr.py | 7 +++ .../hdk_on_native/test/test_dataframe.py | 50 ++++++++++++++++++- .../storage_formats/hdk/query_compiler.py | 40 +++++++++++++++ 3 files changed, 96 insertions(+), 1 deletion(-) diff --git a/modin/experimental/core/execution/native/implementations/hdk_on_native/expr.py b/modin/experimental/core/execution/native/implementations/hdk_on_native/expr.py index 2b6112f1a8a..726873e34b3 100644 --- a/modin/experimental/core/execution/native/implementations/hdk_on_native/expr.py +++ b/modin/experimental/core/execution/native/implementations/hdk_on_native/expr.py @@ -1396,4 +1396,11 @@ def build_dt_expr(dt_operation, col_expr): res = OpExpr("PG_EXTRACT", [operation, col_expr], _get_dtype("int32")) + if dt_operation == "isodow": + res = res.sub(LiteralExpr(1)) + elif dt_operation == "microsecond": + res = res.mod(LiteralExpr(1000000)) + elif dt_operation == "nanosecond": + res = res.mod(LiteralExpr(1000)) + return res diff --git a/modin/experimental/core/execution/native/implementations/hdk_on_native/test/test_dataframe.py b/modin/experimental/core/execution/native/implementations/hdk_on_native/test/test_dataframe.py index da7d8eb2c87..c888effb8e2 100644 --- a/modin/experimental/core/execution/native/implementations/hdk_on_native/test/test_dataframe.py +++ b/modin/experimental/core/execution/native/implementations/hdk_on_native/test/test_dataframe.py @@ -1989,7 +1989,7 @@ class TestDateTime: [ "2018-10-26 12:00", "2018-10-26 13:00:15", - "2020-10-26 04:00:15", + "2020-10-26 04:00:15.000000002", "2020-10-26", ], format="mixed", @@ -2020,6 +2020,54 @@ def dt_hour(df, **kwargs): run_and_compare(dt_hour, data=self.datetime_data) + def test_dt_minute(self): + def dt_minute(df, **kwargs): + return df["d"].dt.minute + + run_and_compare(dt_minute, data=self.datetime_data) + + def test_dt_second(self): + def dt_second(df, **kwargs): + return df["d"].dt.second + + run_and_compare(dt_second, data=self.datetime_data) + + def test_dt_microsecond(self): + def dt_microsecond(df, **kwargs): + return df["d"].dt.microsecond + + run_and_compare(dt_microsecond, data=self.datetime_data) + + def test_dt_nanosecond(self): + def dt_nanosecond(df, **kwargs): + return df["d"].dt.nanosecond + + run_and_compare(dt_nanosecond, data=self.datetime_data) + + def test_dt_quarter(self): + def dt_quarter(df, **kwargs): + return df["c"].dt.quarter + + run_and_compare(dt_quarter, data=self.datetime_data) + + def test_dt_dayofweek(self): + def dt_dayofweek(df, **kwargs): + return df["c"].dt.dayofweek + + run_and_compare(dt_dayofweek, data=self.datetime_data) + + def test_dt_weekday(self): + def dt_weekday(df, **kwargs): + return df["c"].dt.weekday + + run_and_compare(dt_weekday, data=self.datetime_data) + + def test_dt_dayofyear(self): + def dt_dayofyear(df, **kwargs): + return df["c"].dt.dayofyear + + run_and_compare(dt_dayofyear, data=self.datetime_data) + @pytest.mark.parametrize("cast", [True, False]) @pytest.mark.parametrize("unit", CalciteSerializer._TIMESTAMP_PRECISION.keys()) def test_dt_serialization(self, cast, unit): diff --git a/modin/experimental/core/storage_formats/hdk/query_compiler.py b/modin/experimental/core/storage_formats/hdk/query_compiler.py index 691d6e50a47..39b4efe025b 100644 --- a/modin/experimental/core/storage_formats/hdk/query_compiler.py +++ b/modin/experimental/core/storage_formats/hdk/query_compiler.py @@ -635,6 +635,46 @@ def dt_hour(self): self._modin_frame.dt_extract("hour"), self._shape_hint ) + def dt_minute(self): + return self.__constructor__( + self._modin_frame.dt_extract("minute"), self._shape_hint + ) + + def dt_second(self): + return self.__constructor__( + self._modin_frame.dt_extract("second"), self._shape_hint + ) + + def dt_microsecond(self): + return self.__constructor__( + self._modin_frame.dt_extract("microsecond"), self._shape_hint + ) + + def dt_nanosecond(self): + return self.__constructor__( + self._modin_frame.dt_extract("nanosecond"), self._shape_hint + ) + + def dt_quarter(self): + return self.__constructor__( + self._modin_frame.dt_extract("quarter"), self._shape_hint + ) + + def dt_dayofweek(self): + return self.__constructor__( + self._modin_frame.dt_extract("isodow"), self._shape_hint + ) + + def dt_weekday(self): + return self.__constructor__( + self._modin_frame.dt_extract("isodow"), self._shape_hint + ) + + def dt_dayofyear(self): + return self.__constructor__( + self._modin_frame.dt_extract("doy"), self._shape_hint + ) + def _bin_op(self, other, op_name, **kwargs): """ Perform a binary operation on a frame. From 23ebe6b35ec36dece4a99d23d41070dbf99206a2 Mon Sep 17 00:00:00 2001 From: Anatoly Myachev Date: Wed, 20 Sep 2023 13:26:22 +0200 Subject: [PATCH 014/201] DOCS-#6416: fix import path for spreadsheet feature (#6581) Signed-off-by: Anatoly Myachev --- docs/usage_guide/advanced_usage/spreadsheets_api.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/usage_guide/advanced_usage/spreadsheets_api.rst b/docs/usage_guide/advanced_usage/spreadsheets_api.rst index 657e61a7797..db5d36daab9 100644 --- a/docs/usage_guide/advanced_usage/spreadsheets_api.rst +++ b/docs/usage_guide/advanced_usage/spreadsheets_api.rst @@ -15,7 +15,7 @@ The following code snippet creates a spreadsheet using the FiveThirtyEight datas .. code-block:: python import modin.pandas as pd - import modin.spreadsheet as mss + import modin.experimental.spreadsheet as mss df = pd.read_csv('https://raw.githubusercontent.com/fivethirtyeight/data/master/college-majors/all-ages.csv') spreadsheet = mss.from_dataframe(df) spreadsheet From 7eeb9b782f97e21e101d92472b72f768d381d618 Mon Sep 17 00:00:00 2001 From: Iaroslav Igoshev Date: Thu, 21 Sep 2023 13:20:35 +0200 Subject: [PATCH 015/201] FIX-#6587: Use different env files for unidist engine for windows and linux (#6588) Signed-off-by: Igoshev, Iaroslav --- .github/workflows/ci.yml | 4 +- ...{env_unidist.yml => env_unidist_linux.yml} | 1 + requirements/env_unidist_win.yml | 61 +++++++++++++++++++ 3 files changed, 64 insertions(+), 2 deletions(-) rename requirements/{env_unidist.yml => env_unidist_linux.yml} (99%) create mode 100644 requirements/env_unidist_win.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 00fa76d5c05..e121396a0fa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -349,7 +349,7 @@ jobs: - uses: actions/checkout@v3 - uses: ./.github/actions/mamba-env with: - environment-file: requirements/env_unidist.yml + environment-file: requirements/env_unidist_linux.yml activate-environment: modin_on_unidist python-version: ${{matrix.python-version}} - name: Install HDF5 @@ -556,7 +556,7 @@ jobs: - uses: actions/checkout@v3 - uses: ./.github/actions/mamba-env with: - environment-file: ${{ matrix.execution.name == 'unidist' && 'requirements/env_unidist.yml' || 'environment-dev.yml' }} + environment-file: ${{ matrix.os == 'ubuntu' && matrix.execution.name == 'unidist' && 'requirements/env_unidist_linux.yml' || matrix.os == 'windows' && matrix.execution.name == 'unidist' && 'requirements/env_unidist_win.yml' || 'environment-dev.yml' }} activate-environment: ${{ matrix.execution.name == 'unidist' && 'modin_on_unidist' || 'modin' }} python-version: ${{matrix.python-version}} - name: Install HDF5 diff --git a/requirements/env_unidist.yml b/requirements/env_unidist_linux.yml similarity index 99% rename from requirements/env_unidist.yml rename to requirements/env_unidist_linux.yml index 4f8995d4861..7589560c2b9 100644 --- a/requirements/env_unidist.yml +++ b/requirements/env_unidist_linux.yml @@ -8,6 +8,7 @@ dependencies: - pandas>=2.1,<2.2 - numpy>=1.22.4 - unidist-mpi>=0.2.1 + - mpich - fsspec>=2022.05.0 - packaging>=21.0 - psutil>=5.8.0 diff --git a/requirements/env_unidist_win.yml b/requirements/env_unidist_win.yml new file mode 100644 index 00000000000..f3b3459dab6 --- /dev/null +++ b/requirements/env_unidist_win.yml @@ -0,0 +1,61 @@ +name: modin_on_unidist +channels: + - conda-forge +dependencies: + - pip + + # required dependencies + - pandas>=2.1,<2.2 + - numpy>=1.22.4 + - unidist-mpi>=0.2.1 + - msmpi + - fsspec>=2022.05.0 + - packaging>=21.0 + - psutil>=5.8.0 + + # optional dependencies + - pyarrow>=7.0.0 + - xarray>=2022.03.0 + - jinja2>=3.1.2 + - scipy>=1.8.1 + - s3fs>=2022.05.0 + - lxml>=4.8.0 + - openpyxl>=3.0.10 + - xlrd>=2.0.1 + - matplotlib>=3.6.1 + - sqlalchemy>=1.4.0,<1.4.46 + - pandas-gbq>=0.15.0 + - pytables>=3.7.0 + # pymssql==2.2.8 broken: https://github.com/modin-project/modin/issues/6429 + - pymssql>=2.1.5,!=2.2.8 + - psycopg2>=2.9.3 + - fastparquet>=0.8.1 + - tqdm>=4.60.0 + # pandas isn't compatible with numexpr=2.8.5: https://github.com/modin-project/modin/issues/6469 + - numexpr<2.8.5 + + # dependencies for making release + - pygithub>=v1.58.0 + - pygit2>=1.9.2 + + # test dependencies + - coverage>=7.1.0 + - moto>=4.1.0 + - pytest>=7.3.2 + - pytest-cov>=4.0.0 + - pytest-xdist>=3.2.0 + + # code linters + - black>=23.1.0 + - flake8>=6.0.0 + - flake8-no-implicit-concat>=0.3.4 + - flake8-print>=5.0.0 + - mypy>=1.0.0 + - pandas-stubs>=2.0.0 + + - pip: + # Fixes breaking ipywidgets changes, but didn't release yet. + - git+https://github.com/modin-project/modin-spreadsheet.git@49ffd89f683f54c311867d602c55443fb11bf2a5 + - connectorx>=0.2.6a4 + # The `numpydoc` version should match the version installed in the `lint-pydocstyle` job of the CI. + - numpydoc==1.1.0 From 885e6ea450f0430af2f5e2409c15eeda7d5b687b Mon Sep 17 00:00:00 2001 From: Dmitry Chigarev Date: Thu, 21 Sep 2023 15:59:39 +0200 Subject: [PATCH 016/201] PERF-#6590: Chunk axes independently in '.from_pandas()' (#6591) Signed-off-by: Dmitry Chigarev --- .../pandas/partitioning/partition_manager.py | 23 ++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/modin/core/dataframe/pandas/partitioning/partition_manager.py b/modin/core/dataframe/pandas/partitioning/partition_manager.py index da5c8ef89d7..a642f9453f1 100644 --- a/modin/core/dataframe/pandas/partitioning/partition_manager.py +++ b/modin/core/dataframe/pandas/partitioning/partition_manager.py @@ -27,7 +27,7 @@ import pandas from pandas._libs.lib import no_default -from modin.config import BenchmarkMode, NPartitions, ProgressBar +from modin.config import BenchmarkMode, Engine, NPartitions, ProgressBar from modin.core.dataframe.pandas.utils import concatenate from modin.core.storage_formats.pandas.utils import compute_chunksize from modin.error_message import ErrorMessage @@ -811,13 +811,30 @@ def update_bar(pbar, f): ) else: pbar = None + + # even a full-axis slice can cost something (https://github.com/pandas-dev/pandas/issues/55202) + # so we try not to do it if unnecessary. + # FIXME: it appears that this optimization doesn't work for Unidist correctly as it + # doesn't explicitly copy the data when putting it into storage (as the rest engines do) + # causing it to eventially share memory with a pandas object that was provided by user. + # Everything works fine if we do this column slicing as pandas then would set some flags + # to perform in COW mode apparently (and so it wouldn't crash our tests). + # @YarShev promised that this will be eventially fixed on Unidist's side, but for now there's + # this hacky condition + if col_chunksize >= len(df.columns) and Engine.get() != "Unidist": + col_parts = [df] + else: + col_parts = [ + df.iloc[:, i : i + col_chunksize] + for i in range(0, len(df.columns), col_chunksize) + ] parts = [ [ update_bar( pbar, - put_func(df.iloc[i : i + row_chunksize, j : j + col_chunksize]), + put_func(col_part.iloc[i : i + row_chunksize]), ) - for j in range(0, len(df.columns), col_chunksize) + for col_part in col_parts ] for i in range(0, len(df), row_chunksize) ] From 513166faea5926101ad14ecc149b6c72d3376866 Mon Sep 17 00:00:00 2001 From: Anatoly Myachev Date: Thu, 21 Sep 2023 17:23:52 +0200 Subject: [PATCH 017/201] TEST-#6593: adapt tests for pandas 2.1.1 (#6592) Signed-off-by: Anatoly Myachev --- .../test/dataframe/test_map_metadata.py | 1 - modin/pandas/test/test_general.py | 1 - modin/pandas/test/test_io.py | 20 ++----------------- 3 files changed, 2 insertions(+), 20 deletions(-) diff --git a/modin/pandas/test/dataframe/test_map_metadata.py b/modin/pandas/test/dataframe/test_map_metadata.py index 8b00a79677a..f37178c5635 100644 --- a/modin/pandas/test/dataframe/test_map_metadata.py +++ b/modin/pandas/test/dataframe/test_map_metadata.py @@ -817,7 +817,6 @@ def comparator(df1, df2): StorageFormat.get() == "Hdk", reason="HDK does not support columns with different types", ) -@pytest.mark.xfail(reason="https://github.com/pandas-dev/pandas/issues/54848") def test_convert_dtypes_multiple_row_partitions(): # Column 0 should have string dtype modin_part1 = pd.DataFrame(["a"]).convert_dtypes() diff --git a/modin/pandas/test/test_general.py b/modin/pandas/test/test_general.py index e46d5698c51..2f6f331f4d4 100644 --- a/modin/pandas/test/test_general.py +++ b/modin/pandas/test/test_general.py @@ -625,7 +625,6 @@ def test_unique(): reason="https://github.com/modin-project/modin/issues/2896", ) @pytest.mark.parametrize("normalize, bins, dropna", [(True, 3, False)]) -@pytest.mark.xfail(reason="https://github.com/pandas-dev/pandas/issues/54857") def test_value_counts(normalize, bins, dropna): # We sort indices for Modin and pandas result because of issue #1650 values = np.array([3, 1, 2, 3, 4, np.nan]) diff --git a/modin/pandas/test/test_io.py b/modin/pandas/test/test_io.py index 12631db2202..c1e6ad2c83c 100644 --- a/modin/pandas/test/test_io.py +++ b/modin/pandas/test/test_io.py @@ -2717,20 +2717,8 @@ def test_fwf_file_colspecs_widths(self, make_fwf_file, kwargs): "usecols", [ ["a"], - pytest.param( - ["a", "b", "d"], - marks=pytest.mark.xfail( - Engine.get() != "Python" and StorageFormat.get() != "Hdk", - reason="https://github.com/pandas-dev/pandas/issues/54868", - ), - ), - pytest.param( - [0, 1, 3], - marks=pytest.mark.xfail( - Engine.get() != "Python" and StorageFormat.get() != "Hdk", - reason="https://github.com/pandas-dev/pandas/issues/54868", - ), - ), + ["a", "b", "d"], + [0, 1, 3], ], ) def test_fwf_file_usecols(self, make_fwf_file, usecols): @@ -2797,10 +2785,6 @@ def test_fwf_file_chunksize(self, make_fwf_file): df_equals(modin_df, pd_df) @pytest.mark.parametrize("nrows", [13, None]) - @pytest.mark.xfail( - Engine.get() != "Python" and StorageFormat.get() != "Hdk", - reason="https://github.com/pandas-dev/pandas/issues/54868", - ) def test_fwf_file_skiprows(self, make_fwf_file, nrows): unique_filename = make_fwf_file() From ea8088af4cadfb76294e458e5095f262ca85fea9 Mon Sep 17 00:00:00 2001 From: Andrey Pavlenko Date: Mon, 25 Sep 2023 01:37:16 +0200 Subject: [PATCH 018/201] FEAT-#6597: Bump pyhdk version to 0.8 (#6598) Signed-off-by: Andrey Pavlenko --- requirements/env_hdk.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/env_hdk.yml b/requirements/env_hdk.yml index 0012718b68b..701f475ac06 100644 --- a/requirements/env_hdk.yml +++ b/requirements/env_hdk.yml @@ -7,7 +7,7 @@ dependencies: # required dependencies - pandas>=2.1,<2.2 - numpy>=1.22.4 - - pyhdk==0.7 + - pyhdk==0.8 - fsspec>=2022.05.0 - packaging>=21.0 - psutil>=5.8.0 From 405c8c3dd988c4912e039c73c97f42bc7d5d5198 Mon Sep 17 00:00:00 2001 From: Anatoly Myachev Date: Tue, 26 Sep 2023 15:05:28 +0200 Subject: [PATCH 019/201] FIX-#6552: avoid `FutureWarning`s in `groupby` unless necessary (#6595) Signed-off-by: Anatoly Myachev --- .../algebra/default2pandas/groupby.py | 9 +- .../interchange/dataframe_protocol/column.py | 2 +- .../pandas/partitioning/axis_partition.py | 10 +- modin/core/execution/dask/common/utils.py | 15 +- .../partitioning/partition.py | 6 +- modin/core/execution/ray/common/utils.py | 18 +- modin/core/execution/unidist/common/utils.py | 3 +- .../partitioning/partition.py | 18 +- .../partitioning/virtual_partition.py | 6 +- modin/core/execution/utils.py | 31 +++ .../storage_formats/base/query_compiler.py | 5 +- .../storage_formats/pandas/query_compiler.py | 2 +- .../hdk_on_native/test/test_dataframe.py | 4 +- modin/pandas/base.py | 34 +-- modin/pandas/groupby.py | 45 +++- .../test/dataframe/test_map_metadata.py | 2 +- modin/pandas/test/test_groupby.py | 231 ++++++++++++++++-- 17 files changed, 376 insertions(+), 65 deletions(-) create mode 100644 modin/core/execution/utils.py diff --git a/modin/core/dataframe/algebra/default2pandas/groupby.py b/modin/core/dataframe/algebra/default2pandas/groupby.py index 59d4d4196aa..8e2e4de062d 100644 --- a/modin/core/dataframe/algebra/default2pandas/groupby.py +++ b/modin/core/dataframe/algebra/default2pandas/groupby.py @@ -13,6 +13,7 @@ """Module houses default GroupBy functions builder class.""" +import warnings from typing import Any import pandas @@ -59,7 +60,9 @@ def is_transformation_kernel(agg_func: Any) -> bool: @classmethod def _call_groupby(cls, df, *args, **kwargs): # noqa: PR01 """Call .groupby() on passed `df`.""" - return df.groupby(*args, **kwargs) + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", category=FutureWarning) + return df.groupby(*args, **kwargs) @classmethod def validate_by(cls, by): @@ -563,7 +566,9 @@ def _call_groupby(cls, df, *args, **kwargs): # noqa: PR01 # In second case surrounding logic will supplement grouping columns, # so we need to drop them after grouping is over; our originally # selected column is always the first, so use it - return df.groupby(*args, **kwargs)[df.columns[0]] + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", category=FutureWarning) + return df.groupby(*args, **kwargs)[df.columns[0]] class GroupByDefault(DefaultMethod): diff --git a/modin/core/dataframe/pandas/interchange/dataframe_protocol/column.py b/modin/core/dataframe/pandas/interchange/dataframe_protocol/column.py index e68460ba2b9..23eea6bf872 100644 --- a/modin/core/dataframe/pandas/interchange/dataframe_protocol/column.py +++ b/modin/core/dataframe/pandas/interchange/dataframe_protocol/column.py @@ -127,7 +127,7 @@ def dtype(self) -> Tuple[DTypeKind, int, str, str]: if self._dtype_cache is not None: return self._dtype_cache - dtype = self._col.dtypes[0] + dtype = self._col.dtypes.iloc[0] if isinstance(dtype, pandas.CategoricalDtype): pandas_series = self._col.to_pandas().squeeze(axis=1) diff --git a/modin/core/dataframe/pandas/partitioning/axis_partition.py b/modin/core/dataframe/pandas/partitioning/axis_partition.py index 70a20918d6b..30ed90c4321 100644 --- a/modin/core/dataframe/pandas/partitioning/axis_partition.py +++ b/modin/core/dataframe/pandas/partitioning/axis_partition.py @@ -13,6 +13,8 @@ """The module defines base interface for an axis partition of a Modin DataFrame.""" +import warnings + import numpy as np import pandas @@ -418,7 +420,9 @@ def deploy_axis_func( A list of pandas DataFrames. """ dataframe = pandas.concat(list(partitions), axis=axis, copy=False) - result = func(dataframe, *f_args, **f_kwargs) + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", category=FutureWarning) + result = func(dataframe, *f_args, **f_kwargs) if num_splits == 1: # If we're not going to split the result, we don't need to specify @@ -497,7 +501,9 @@ def deploy_func_between_two_axis_partitions( for i in range(1, len(other_shape)) ] rt_frame = pandas.concat(combined_axis, axis=axis ^ 1, copy=False) - result = func(lt_frame, rt_frame, *f_args, **f_kwargs) + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", category=FutureWarning) + result = func(lt_frame, rt_frame, *f_args, **f_kwargs) return split_result_of_axis_func_pandas(axis, num_splits, result) @classmethod diff --git a/modin/core/execution/dask/common/utils.py b/modin/core/execution/dask/common/utils.py index cf544ab343c..3eda2a50375 100644 --- a/modin/core/execution/dask/common/utils.py +++ b/modin/core/execution/dask/common/utils.py @@ -23,6 +23,7 @@ Memory, NPartitions, ) +from modin.core.execution.utils import set_env from modin.error_message import ErrorMessage @@ -32,6 +33,14 @@ def initialize_dask(): try: client = default_client() + + def _disable_warnings(): + import warnings + + warnings.simplefilter("ignore", category=FutureWarning) + + client.run(_disable_warnings) + except ValueError: from distributed import Client @@ -47,7 +56,11 @@ def initialize_dask(): num_cpus = CpuCount.get() memory_limit = Memory.get() worker_memory_limit = memory_limit // num_cpus if memory_limit else "auto" - client = Client(n_workers=num_cpus, memory_limit=worker_memory_limit) + + # when the client is initialized, environment variables are inherited + with set_env(PYTHONWARNINGS="ignore::FutureWarning"): + client = Client(n_workers=num_cpus, memory_limit=worker_memory_limit) + if GithubCI.get(): # set these keys to run tests that write to the mock s3 service. this seems # to be the way to pass environment variables to the workers: diff --git a/modin/core/execution/python/implementations/pandas_on_python/partitioning/partition.py b/modin/core/execution/python/implementations/pandas_on_python/partitioning/partition.py index 059f3ae2286..307c8f186b7 100644 --- a/modin/core/execution/python/implementations/pandas_on_python/partitioning/partition.py +++ b/modin/core/execution/python/implementations/pandas_on_python/partitioning/partition.py @@ -13,6 +13,8 @@ """The module defines interface for a partition with pandas storage format and Python engine.""" +import warnings + from modin.core.dataframe.pandas.partitioning.partition import PandasDataframePartition from modin.core.execution.python.common import PythonWrapper @@ -116,7 +118,9 @@ def call_queue_closure(data, call_queue): self._data = call_queue_closure(self._data, self.call_queue) self.call_queue = [] - return self.__constructor__(func(self._data.copy(), *args, **kwargs)) + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", category=FutureWarning) + return self.__constructor__(func(self._data.copy(), *args, **kwargs)) def drain_call_queue(self): """Execute all operations stored in the call queue on the object wrapped by this partition.""" diff --git a/modin/core/execution/ray/common/utils.py b/modin/core/execution/ray/common/utils.py index 9473ec0fa97..3b168c6f044 100644 --- a/modin/core/execution/ray/common/utils.py +++ b/modin/core/execution/ray/common/utils.py @@ -36,6 +36,7 @@ StorageFormat, ValueSource, ) +from modin.core.execution.utils import set_env from modin.error_message import ErrorMessage from .engine_wrapper import RayWrapper @@ -82,7 +83,10 @@ def initialize_ray( # the `pandas` module has been fully imported inside of each process before # any execution begins: # https://github.com/modin-project/modin/pull/4603 - env_vars = {"__MODIN_AUTOIMPORT_PANDAS__": "1"} + env_vars = { + "__MODIN_AUTOIMPORT_PANDAS__": "1", + "PYTHONWARNINGS": "ignore::FutureWarning", + } if GithubCI.get(): # need these to write parquet to the moto service mocking s3. env_vars.update( @@ -143,9 +147,8 @@ def initialize_ray( # time and doesn't enforce us with any overhead that Ray's native `runtime_env` # is usually causing. You can visit this gh-issue for more info: # https://github.com/modin-project/modin/issues/5157#issuecomment-1500225150 - for key, value in env_vars.items(): - os.environ[key] = value - ray.init(**ray_init_kwargs) + with set_env(**env_vars): + ray.init(**ray_init_kwargs) if StorageFormat.get() == "Cudf": from modin.core.execution.ray.implementations.cudf_on_ray.partitioning import ( @@ -163,12 +166,7 @@ def initialize_ray( runtime_env_vars = ray.get_runtime_context().runtime_env.get("env_vars", {}) for varname, varvalue in env_vars.items(): if str(runtime_env_vars.get(varname, "")) != str(varvalue): - if is_cluster or ( - # Here we relax our requirements for a non-cluster case allowing for the `env_vars` - # to be set at least as a process environment variable - not is_cluster - and os.environ.get(varname, "") != str(varvalue) - ): + if is_cluster: ErrorMessage.single_warning( "When using a pre-initialized Ray cluster, please ensure that the runtime env " + f"sets environment variable {varname} to {varvalue}" diff --git a/modin/core/execution/unidist/common/utils.py b/modin/core/execution/unidist/common/utils.py index 7de30d560af..db9648382b5 100644 --- a/modin/core/execution/unidist/common/utils.py +++ b/modin/core/execution/unidist/common/utils.py @@ -45,7 +45,8 @@ def initialize_unidist(): unidist.init() """, ) - + # TODO: allow unidist to inherit env variables on initialization + # with set_env(PYTHONWARNINGS="ignore::FutureWarning"): unidist.init() num_cpus = sum(v["CPU"] for v in unidist.cluster_resources().values()) diff --git a/modin/core/execution/unidist/implementations/pandas_on_unidist/partitioning/partition.py b/modin/core/execution/unidist/implementations/pandas_on_unidist/partitioning/partition.py index 05c0538be4f..000eb59c7f6 100644 --- a/modin/core/execution/unidist/implementations/pandas_on_unidist/partitioning/partition.py +++ b/modin/core/execution/unidist/implementations/pandas_on_unidist/partitioning/partition.py @@ -13,6 +13,8 @@ """Module houses class that wraps data (block partition) and its metadata.""" +import warnings + import unidist from modin.core.dataframe.pandas.partitioning.partition import PandasDataframePartition @@ -351,12 +353,16 @@ def _apply_func(partition, func, *args, **kwargs): # pragma: no cover destructuring it causes a performance penalty. """ try: - result = func(partition, *args, **kwargs) + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", category=FutureWarning) + result = func(partition, *args, **kwargs) # Sometimes Arrow forces us to make a copy of an object before we operate on it. We # don't want the error to propagate to the user, and we want to avoid copying unless # we absolutely have to. except ValueError: - result = func(partition.copy(), *args, **kwargs) + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", category=FutureWarning) + result = func(partition.copy(), *args, **kwargs) return ( result, len(result) if hasattr(result, "__len__") else 0, @@ -393,12 +399,16 @@ def _apply_list_of_funcs(call_queue, partition): # pragma: no cover args = deserialize(f_args) kwargs = deserialize(f_kwargs) try: - partition = func(partition, *args, **kwargs) + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", category=FutureWarning) + partition = func(partition, *args, **kwargs) # Sometimes Arrow forces us to make a copy of an object before we operate on it. We # don't want the error to propagate to the user, and we want to avoid copying unless # we absolutely have to. except ValueError: - partition = func(partition.copy(), *args, **kwargs) + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", category=FutureWarning) + partition = func(partition.copy(), *args, **kwargs) return ( partition, diff --git a/modin/core/execution/unidist/implementations/pandas_on_unidist/partitioning/virtual_partition.py b/modin/core/execution/unidist/implementations/pandas_on_unidist/partitioning/virtual_partition.py index c1abfe9e749..f662c7a8d81 100644 --- a/modin/core/execution/unidist/implementations/pandas_on_unidist/partitioning/virtual_partition.py +++ b/modin/core/execution/unidist/implementations/pandas_on_unidist/partitioning/virtual_partition.py @@ -13,6 +13,8 @@ """Module houses classes responsible for storing a virtual partition and applying a function to it.""" +import warnings + import pandas import unidist @@ -310,7 +312,9 @@ def _deploy_unidist_func( Unidist functions are not detected by codecov (thus pragma: no cover). """ f_args = deserialize(f_args) - result = deployer(axis, f_to_deploy, f_args, f_kwargs, *args, **kwargs) + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", category=FutureWarning) + result = deployer(axis, f_to_deploy, f_args, f_kwargs, *args, **kwargs) if not extract_metadata: return result ip = unidist.get_ip() diff --git a/modin/core/execution/utils.py b/modin/core/execution/utils.py new file mode 100644 index 00000000000..7245da3c094 --- /dev/null +++ b/modin/core/execution/utils.py @@ -0,0 +1,31 @@ +# Licensed to Modin Development Team under one or more contributor license agreements. +# See the NOTICE file distributed with this work for additional information regarding +# copyright ownership. The Modin Development Team licenses this file to you under the +# Apache License, Version 2.0 (the "License"); you may not use this file except in +# compliance with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software distributed under +# the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF +# ANY KIND, either express or implied. See the License for the specific language +# governing permissions and limitations under the License. + +"""General utils for execution module.""" + +import contextlib +import os + + +@contextlib.contextmanager +def set_env(**environ): + """ + Temporarily set the process environment variables. + """ + old_environ = os.environ.copy() + os.environ.update(environ) + try: + yield + finally: + os.environ.clear() + os.environ.update(old_environ) diff --git a/modin/core/storage_formats/base/query_compiler.py b/modin/core/storage_formats/base/query_compiler.py index 17c45b0dac7..ec31f98bd25 100644 --- a/modin/core/storage_formats/base/query_compiler.py +++ b/modin/core/storage_formats/base/query_compiler.py @@ -18,6 +18,7 @@ """ import abc +import warnings from typing import Hashable, List, Optional import numpy as np @@ -164,7 +165,9 @@ def default_to_pandas(self, pandas_op, *args, **kwargs): args = try_cast_to_pandas(args) kwargs = try_cast_to_pandas(kwargs) - result = pandas_op(try_cast_to_pandas(self), *args, **kwargs) + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", category=FutureWarning) + result = pandas_op(try_cast_to_pandas(self), *args, **kwargs) if isinstance(result, (tuple, list)): return [self.__wrap_in_qc(obj) for obj in result] return self.__wrap_in_qc(result) diff --git a/modin/core/storage_formats/pandas/query_compiler.py b/modin/core/storage_formats/pandas/query_compiler.py index d8d00d5bb9f..9feb32f4919 100644 --- a/modin/core/storage_formats/pandas/query_compiler.py +++ b/modin/core/storage_formats/pandas/query_compiler.py @@ -2721,7 +2721,7 @@ def getitem_array(self, key): # here we check for a subset of bool indexers only to simplify the code; # there could (potentially) be more of those, but we assume the most frequent # ones are just of bool dtype - if len(key.dtypes) == 1 and is_bool_dtype(key.dtypes[0]): + if len(key.dtypes) == 1 and is_bool_dtype(key.dtypes.iloc[0]): self.__validate_bool_indexer(key.index) return self.__getitem_bool(key, broadcast=True, dtypes="copy") diff --git a/modin/experimental/core/execution/native/implementations/hdk_on_native/test/test_dataframe.py b/modin/experimental/core/execution/native/implementations/hdk_on_native/test/test_dataframe.py index c888effb8e2..a8d44423b3d 100644 --- a/modin/experimental/core/execution/native/implementations/hdk_on_native/test/test_dataframe.py +++ b/modin/experimental/core/execution/native/implementations/hdk_on_native/test/test_dataframe.py @@ -2779,8 +2779,8 @@ def test_dict(self): at = mdf._query_compiler._modin_frame._partitions[0][0].get() assert len(at.column(0).chunks) == nchunks - mdt = mdf.dtypes[0] - pdt = pdf.dtypes[0] + mdt = mdf.dtypes.iloc[0] + pdt = pdf.dtypes.iloc[0] assert mdt == "category" assert isinstance(mdt, pandas.CategoricalDtype) assert str(mdt) == str(pdt) diff --git a/modin/pandas/base.py b/modin/pandas/base.py index 4047434fec4..5bf30b4fed9 100644 --- a/modin/pandas/base.py +++ b/modin/pandas/base.py @@ -503,23 +503,25 @@ def _default_to_pandas(self, op, *args, **kwargs): args = try_cast_to_pandas(args) kwargs = try_cast_to_pandas(kwargs) pandas_obj = self._to_pandas() - if callable(op): - result = op(pandas_obj, *args, **kwargs) - elif isinstance(op, str): - # The inner `getattr` is ensuring that we are treating this object (whether - # it is a DataFrame, Series, etc.) as a pandas object. The outer `getattr` - # will get the operation (`op`) from the pandas version of the class and run - # it on the object after we have converted it to pandas. - attr = getattr(self._pandas_class, op) - if isinstance(attr, property): - result = getattr(pandas_obj, op) + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", category=FutureWarning) + if callable(op): + result = op(pandas_obj, *args, **kwargs) + elif isinstance(op, str): + # The inner `getattr` is ensuring that we are treating this object (whether + # it is a DataFrame, Series, etc.) as a pandas object. The outer `getattr` + # will get the operation (`op`) from the pandas version of the class and run + # it on the object after we have converted it to pandas. + attr = getattr(self._pandas_class, op) + if isinstance(attr, property): + result = getattr(pandas_obj, op) + else: + result = attr(pandas_obj, *args, **kwargs) else: - result = attr(pandas_obj, *args, **kwargs) - else: - ErrorMessage.catch_bugs_and_request_email( - failure_condition=True, - extra_log="{} is an unsupported operation".format(op), - ) + ErrorMessage.catch_bugs_and_request_email( + failure_condition=True, + extra_log="{} is an unsupported operation".format(op), + ) # SparseDataFrames cannot be serialized by arrow and cause problems for Modin. # For now we will use pandas. if isinstance(result, type(self)) and not isinstance( diff --git a/modin/pandas/groupby.py b/modin/pandas/groupby.py index cdd78c62e2c..b9c4f2109a7 100644 --- a/modin/pandas/groupby.py +++ b/modin/pandas/groupby.py @@ -226,7 +226,13 @@ def ffill(self, limit=None): + "which can be impacted by pandas bug https://github.com/pandas-dev/pandas/issues/43412 " + "on dataframes with duplicated indices" ) - return self.fillna(limit=limit, method="ffill") + with warnings.catch_warnings(): + warnings.filterwarnings( + "ignore", + message=".*fillna with 'method' is deprecated.*", + category=FutureWarning, + ) + return self.fillna(limit=limit, method="ffill") def sem(self, ddof=1, numeric_only=False): return self._wrap_aggregation( @@ -369,7 +375,7 @@ def max(self, numeric_only=False, min_count=-1, engine=None, engine_kwargs=None) ) def idxmax(self, axis=lib.no_default, skipna=True, numeric_only=False): - if axis is lib.no_default: + if axis is not lib.no_default: self._deprecate_axis(axis, "idxmax") # default behaviour for aggregations; for the reference see # `_op_via_apply` func in pandas==2.0.2 @@ -382,7 +388,7 @@ def idxmax(self, axis=lib.no_default, skipna=True, numeric_only=False): ) def idxmin(self, axis=lib.no_default, skipna=True, numeric_only=False): - if axis is lib.no_default: + if axis is not lib.no_default: self._deprecate_axis(axis, "idxmin") # default behaviour for aggregations; for the reference see # `_op_via_apply` func in pandas==2.0.2 @@ -663,6 +669,11 @@ def apply(self, func, *args, **kwargs): def dtypes(self): if self._axis == 1: raise ValueError("Cannot call dtypes on groupby with axis=1") + warnings.warn( + f"{type(self).__name__}.dtypes is deprecated and will be removed in " + + "a future version. Check the dtypes on the base object instead", + FutureWarning, + ) return self._check_index( self._wrap_aggregation( type(self._query_compiler).groupby_dtypes, @@ -825,7 +836,13 @@ def bfill(self, limit=None): + "which can be impacted by pandas bug https://github.com/pandas-dev/pandas/issues/43412 " + "on dataframes with duplicated indices" ) - return self.fillna(limit=limit, method="bfill") + with warnings.catch_warnings(): + warnings.filterwarnings( + "ignore", + message=".*fillna with 'method' is deprecated.*", + category=FutureWarning, + ) + return self.fillna(limit=limit, method="bfill") def prod(self, numeric_only=False, min_count=0): return self._wrap_aggregation( @@ -867,7 +884,15 @@ def aggregate(self, func=None, *args, engine=None, engine_kwargs=None, **kwargs) and isinstance(func, BuiltinFunctionType) and func.__name__ in dir(self) ): - func = func.__name__ + func_name = func.__name__ + warnings.warn( + f"The provided callable {func} is currently using " + + f"{type(self).__name__}.{func_name}. In a future version of pandas, " + + "the provided callable will be used directly. To keep current " + + f"behavior pass the string {func_name} instead.", + category=FutureWarning, + ) + func = func_name do_relabel = None if isinstance(func, dict) or func is None: @@ -1237,9 +1262,17 @@ def fillna( limit=None, downcast=lib.no_default, ): - if axis is lib.no_default: + if axis is not lib.no_default: self._deprecate_axis(axis, "fillna") + if method is not None: + warnings.warn( + f"{type(self).__name__}.fillna with 'method' is deprecated and " + + "will raise in a future version. Use obj.ffill() or obj.bfill() " + + "instead.", + FutureWarning, + ) + # default behaviour for aggregations; for the reference see # `_op_via_apply` func in pandas==2.0.2 if axis is None or axis is lib.no_default: diff --git a/modin/pandas/test/dataframe/test_map_metadata.py b/modin/pandas/test/dataframe/test_map_metadata.py index f37178c5635..dd86c8966cc 100644 --- a/modin/pandas/test/dataframe/test_map_metadata.py +++ b/modin/pandas/test/dataframe/test_map_metadata.py @@ -845,7 +845,7 @@ def test_convert_dtypes_5653(): assert modin_df._query_compiler._modin_frame._partitions.shape == (2, 1) modin_df = modin_df.convert_dtypes() assert len(modin_df.dtypes) == 1 - assert modin_df.dtypes[0] == "string" + assert modin_df.dtypes.iloc[0] == "string" @pytest.mark.parametrize("data", test_data_values, ids=test_data_keys) diff --git a/modin/pandas/test/test_groupby.py b/modin/pandas/test/test_groupby.py index 41a1d41e171..2406cf20497 100644 --- a/modin/pandas/test/test_groupby.py +++ b/modin/pandas/test/test_groupby.py @@ -17,6 +17,7 @@ import numpy as np import pandas +import pandas._libs.lib as lib import pytest import modin.pandas as pd @@ -58,7 +59,49 @@ # have too many such instances. # TODO(https://github.com/modin-project/modin/issues/3655): catch all instances # of defaulting to pandas. -pytestmark = pytest.mark.filterwarnings(default_to_pandas_ignore_string) +pytestmark = [ + pytest.mark.filterwarnings(default_to_pandas_ignore_string), + # TO MAKE SURE ALL FUTUREWARNINGS ARE CONSIDERED + pytest.mark.filterwarnings("error::FutureWarning"), + # IGNORE FUTUREWARNINGS MARKS TO CLEANUP OUTPUT + pytest.mark.filterwarnings( + "ignore:DataFrame.groupby with axis=1 is deprecated:FutureWarning" + ), + pytest.mark.filterwarnings( + "ignore:DataFrameGroupBy.dtypes is deprecated:FutureWarning" + ), + pytest.mark.filterwarnings( + "ignore:DataFrameGroupBy.diff with axis=1 is deprecated:FutureWarning" + ), + pytest.mark.filterwarnings( + "ignore:DataFrameGroupBy.pct_change with axis=1 is deprecated:FutureWarning" + ), + pytest.mark.filterwarnings( + "ignore:The 'fill_method' and 'limit' keywords in (DataFrame|DataFrameGroupBy).pct_change are deprecated:FutureWarning" + ), + pytest.mark.filterwarnings( + "ignore:DataFrameGroupBy.shift with axis=1 is deprecated:FutureWarning" + ), + pytest.mark.filterwarnings( + "ignore:(DataFrameGroupBy|SeriesGroupBy|DataFrame|Series).fillna with 'method' is deprecated:FutureWarning" + ), + # FIXME: these cases inconsistent between modin and pandas + pytest.mark.filterwarnings( + "ignore:A grouping was used that is not in the columns of the DataFrame and so was excluded from the result:FutureWarning" + ), + pytest.mark.filterwarnings( + "ignore:The default of observed=False is deprecated:FutureWarning" + ), + pytest.mark.filterwarnings( + "ignore:.*DataFrame.idxmax with all-NA values, or any-NA and skipna=False, is deprecated:FutureWarning" + ), + pytest.mark.filterwarnings( + "ignore:.*DataFrame.idxmin with all-NA values, or any-NA and skipna=False, is deprecated:FutureWarning" + ), + pytest.mark.filterwarnings( + "ignore:.*In a future version of pandas, the provided callable will be used directly.*:FutureWarning" + ), +] def modin_groupby_equals_pandas(modin_groupby, pandas_groupby): @@ -414,17 +457,17 @@ def maybe_get_columns(df, by): eval_ndim(modin_groupby, pandas_groupby) if not check_df_columns_have_nans(modin_df, by): # cum* functions produce undefined results for columns with NaNs so we run them only when "by" columns contain no NaNs - eval_general(modin_groupby, pandas_groupby, lambda df: df.cumsum(axis=0)) - eval_general(modin_groupby, pandas_groupby, lambda df: df.cummax(axis=0)) - eval_general(modin_groupby, pandas_groupby, lambda df: df.cummin(axis=0)) - eval_general(modin_groupby, pandas_groupby, lambda df: df.cumprod(axis=0)) + eval_general(modin_groupby, pandas_groupby, lambda df: df.cumsum()) + eval_general(modin_groupby, pandas_groupby, lambda df: df.cummax()) + eval_general(modin_groupby, pandas_groupby, lambda df: df.cummin()) + eval_general(modin_groupby, pandas_groupby, lambda df: df.cumprod()) eval_general(modin_groupby, pandas_groupby, lambda df: df.cumcount()) eval_general( modin_groupby, pandas_groupby, lambda df: df.pct_change( - periods=2, fill_method="pad", limit=1, freq=None, axis=1 + periods=2, fill_method="bfill", limit=1, freq=None, axis=1 ), modin_df_almost_equals_pandas, ) @@ -1151,7 +1194,7 @@ def eval_ndim(modin_groupby, pandas_groupby): assert modin_groupby.ndim == pandas_groupby.ndim -def eval_cumsum(modin_groupby, pandas_groupby, axis=0, numeric_only=False): +def eval_cumsum(modin_groupby, pandas_groupby, axis=lib.no_default, numeric_only=False): df_equals( *sort_index_if_experimental_groupby( modin_groupby.cumsum(axis=axis, numeric_only=numeric_only), @@ -1160,7 +1203,7 @@ def eval_cumsum(modin_groupby, pandas_groupby, axis=0, numeric_only=False): ) -def eval_cummax(modin_groupby, pandas_groupby, axis=0, numeric_only=False): +def eval_cummax(modin_groupby, pandas_groupby, axis=lib.no_default, numeric_only=False): df_equals( *sort_index_if_experimental_groupby( modin_groupby.cummax(axis=axis, numeric_only=numeric_only), @@ -1169,7 +1212,7 @@ def eval_cummax(modin_groupby, pandas_groupby, axis=0, numeric_only=False): ) -def eval_cummin(modin_groupby, pandas_groupby, axis=0, numeric_only=False): +def eval_cummin(modin_groupby, pandas_groupby, axis=lib.no_default, numeric_only=False): df_equals( *sort_index_if_experimental_groupby( modin_groupby.cummin(axis=axis, numeric_only=numeric_only), @@ -1250,7 +1293,9 @@ def eval_median(modin_groupby, pandas_groupby, numeric_only=False): ) -def eval_cumprod(modin_groupby, pandas_groupby, axis=0, numeric_only=False): +def eval_cumprod( + modin_groupby, pandas_groupby, axis=lib.no_default, numeric_only=False +): df_equals( *sort_index_if_experimental_groupby( modin_groupby.cumprod(numeric_only=numeric_only), @@ -1587,8 +1632,8 @@ def test_groupby_with_kwarg_dropna(groupby_kwargs, dropna): df_equals(md_grp._default_to_pandas(lambda df: df.sum()), pd_grp.sum()) -@pytest.mark.parametrize("groupby_axis", [0, 1]) -@pytest.mark.parametrize("shift_axis", [0, 1]) +@pytest.mark.parametrize("groupby_axis", [lib.no_default, 1]) +@pytest.mark.parametrize("shift_axis", [lib.no_default, 1]) @pytest.mark.parametrize("groupby_sort", [True, False]) def test_shift_freq(groupby_axis, shift_axis, groupby_sort): pandas_df = pandas.DataFrame( @@ -1751,9 +1796,7 @@ def col3(x): [ "quantile", "mean", - pytest.param( - "sum", marks=pytest.mark.skip("See Modin issue #2255 for details") - ), + "sum", "median", "unique", "cumprod", @@ -2884,3 +2927,161 @@ def test_reshuffling_groupby_on_strings(modify_config): eval_general( modin_df.groupby("col1"), pandas_df.groupby("col1"), lambda grp: grp.mean() ) + + +### TEST GROUPBY WARNINGS ### + + +def test_groupby_axis_1_warning(): + data = { + "col1": [0, 3, 2, 3], + "col2": [4, 1, 6, 7], + } + modin_df, pandas_df = create_test_dfs(data) + + with pytest.warns( + FutureWarning, match="DataFrame.groupby with axis=1 is deprecated" + ): + modin_df.groupby(by="col1", axis=1) + with pytest.warns( + FutureWarning, match="DataFrame.groupby with axis=1 is deprecated" + ): + pandas_df.groupby(by="col1", axis=1) + + +def test_groupby_dtypes_warning(): + data = { + "col1": [0, 3, 2, 3], + "col2": [4, 1, 6, 7], + } + modin_df, pandas_df = create_test_dfs(data) + modin_groupby = modin_df.groupby(by="col1") + pandas_groupby = pandas_df.groupby(by="col1") + + with pytest.warns(FutureWarning, match="DataFrameGroupBy.dtypes is deprecated"): + modin_groupby.dtypes + with pytest.warns(FutureWarning, match="DataFrameGroupBy.dtypes is deprecated"): + pandas_groupby.dtypes + + +def test_groupby_diff_axis_1_warning(): + data = { + "col1": [0, 3, 2, 3], + "col2": [4, 1, 6, 7], + } + modin_df, pandas_df = create_test_dfs(data) + modin_groupby = modin_df.groupby(by="col1") + pandas_groupby = pandas_df.groupby(by="col1") + + with pytest.warns( + FutureWarning, match="DataFrameGroupBy.diff with axis=1 is deprecated" + ): + modin_groupby.diff(axis=1) + with pytest.warns( + FutureWarning, match="DataFrameGroupBy.diff with axis=1 is deprecated" + ): + pandas_groupby.diff(axis=1) + + +def test_groupby_pct_change_axis_1_warning(): + data = { + "col1": [0, 3, 2, 3], + "col2": [4, 1, 6, 7], + } + modin_df, pandas_df = create_test_dfs(data) + modin_groupby = modin_df.groupby(by="col1") + pandas_groupby = pandas_df.groupby(by="col1") + + with pytest.warns( + FutureWarning, match="DataFrameGroupBy.pct_change with axis=1 is deprecated" + ): + modin_groupby.pct_change(axis=1) + with pytest.warns( + FutureWarning, match="DataFrameGroupBy.pct_change with axis=1 is deprecated" + ): + pandas_groupby.pct_change(axis=1) + + +def test_groupby_pct_change_parameters_warning(): + data = { + "col1": [0, 3, 2, 3], + "col2": [4, 1, 6, 7], + } + modin_df, pandas_df = create_test_dfs(data) + modin_groupby = modin_df.groupby(by="col1") + pandas_groupby = pandas_df.groupby(by="col1") + + with pytest.warns( + FutureWarning, + match="The 'fill_method' and 'limit' keywords in (DataFrame|DataFrameGroupBy).pct_change are deprecated", + ): + modin_groupby.pct_change(fill_method="bfill", limit=1) + with pytest.warns( + FutureWarning, + match="The 'fill_method' and 'limit' keywords in (DataFrame|DataFrameGroupBy).pct_change are deprecated", + ): + pandas_groupby.pct_change(fill_method="bfill", limit=1) + + +def test_groupby_shift_axis_1_warning(): + data = { + "col1": [0, 3, 2, 3], + "col2": [4, 1, 6, 7], + } + modin_df, pandas_df = create_test_dfs(data) + modin_groupby = modin_df.groupby(by="col1") + pandas_groupby = pandas_df.groupby(by="col1") + + with pytest.warns( + FutureWarning, + match="DataFrameGroupBy.shift with axis=1 is deprecated", + ): + pandas_groupby.shift(axis=1, fill_value=777) + with pytest.warns( + FutureWarning, + match="DataFrameGroupBy.shift with axis=1 is deprecated", + ): + modin_groupby.shift(axis=1, fill_value=777) + + +def test_groupby_fillna_axis_1_warning(): + data = { + "col1": [0, 3, 2, 3], + "col2": [4, None, 6, None], + } + modin_df, pandas_df = create_test_dfs(data) + modin_groupby = modin_df.groupby(by="col1") + pandas_groupby = pandas_df.groupby(by="col1") + + with pytest.warns( + FutureWarning, + match="DataFrameGroupBy.fillna with 'method' is deprecated", + ): + modin_groupby.fillna(method="ffill") + with pytest.warns( + FutureWarning, + match="DataFrameGroupBy.fillna with 'method' is deprecated", + ): + pandas_groupby.fillna(method="ffill") + + +def test_groupby_agg_provided_callable_warning(): + data = { + "col1": [0, 3, 2, 3], + "col2": [4, 1, 6, 7], + } + modin_df, pandas_df = create_test_dfs(data) + modin_groupby = modin_df.groupby(by="col1") + pandas_groupby = pandas_df.groupby(by="col1") + + for func in (sum, max): + with pytest.warns( + FutureWarning, + match="In a future version of pandas, the provided callable will be used directly", + ): + modin_groupby.agg(func) + with pytest.warns( + FutureWarning, + match="In a future version of pandas, the provided callable will be used directly", + ): + pandas_groupby.agg(func) From 0c58c1e9b7f4427be2838946ff62d9792facc389 Mon Sep 17 00:00:00 2001 From: Andrey Pavlenko Date: Tue, 26 Sep 2023 17:10:01 +0200 Subject: [PATCH 020/201] FEAT-#6484: HDK: Add support for nlargest/nsmallest groupby aggregation (#6485) Signed-off-by: Andrey Pavlenko --- .../hdk_on_native/calcite_builder.py | 95 +++++++++++++++++-- .../hdk_on_native/dataframe/dataframe.py | 14 ++- .../implementations/hdk_on_native/expr.py | 14 ++- .../hdk_on_native/test/test_dataframe.py | 8 ++ .../storage_formats/hdk/query_compiler.py | 1 + 5 files changed, 111 insertions(+), 21 deletions(-) diff --git a/modin/experimental/core/execution/native/implementations/hdk_on_native/calcite_builder.py b/modin/experimental/core/execution/native/implementations/hdk_on_native/calcite_builder.py index 3b9b7ff9330..5f80c71b60e 100644 --- a/modin/experimental/core/execution/native/implementations/hdk_on_native/calcite_builder.py +++ b/modin/experimental/core/execution/native/implementations/hdk_on_native/calcite_builder.py @@ -45,6 +45,7 @@ AggregateExpr, InputRefExpr, LiteralExpr, + OpExpr, build_if_then_else, build_row_idx_filter_expr, ) @@ -67,8 +68,8 @@ class CompoundAggregate: ---------- builder : CalciteBuilder A builder to use for translation. - arg : BaseExpr - An aggregated value. + arg : BaseExpr or List of BaseExpr + An aggregated values. """ def __init__(self, builder, arg): @@ -108,6 +109,53 @@ def gen_reduce_expr(self): """ pass + class CompoundAggregateWithColArg(CompoundAggregate): + """ + A base class for a compound aggregate that require a `LiteralExpr` column argument. + + This aggregate requires 2 arguments. The first argument is an `InputRefExpr`, + refering to the aggregation column. The second argument is a `LiteralExpr`, + this expression is added into the frame as a new column. + + Parameters + ---------- + agg : str + Aggregate name. + builder : CalciteBuilder + A builder to use for translation. + arg : List of BaseExpr + Aggregate arguments. + dtype : dtype, optional + Aggregate data type. If not specified, `_dtype` from the first argument is used. + """ + + def __init__(self, agg, builder, arg, dtype=None): + assert isinstance(arg[0], InputRefExpr) + assert isinstance(arg[1], LiteralExpr) + super().__init__(builder, arg) + self._agg = agg + self._agg_column = f"{arg[0].column}__{agg}__" + self._dtype = dtype or arg[0]._dtype + + def gen_proj_exprs(self): + return {self._agg_column: self._arg[1]} + + def gen_agg_exprs(self): + frame = self._arg[0].modin_frame + return { + self._agg_column: AggregateExpr( + self._agg, + [ + self._builder._ref_idx(frame, self._arg[0].column), + self._builder._ref_idx(frame, self._agg_column), + ], + dtype=self._dtype, + ) + } + + def gen_reduce_expr(self): + return self._builder._ref(self._arg[0].modin_frame, self._agg_column) + class StdAggregate(CompoundAggregate): """ A sample standard deviation aggregate generator. @@ -116,13 +164,13 @@ class StdAggregate(CompoundAggregate): ---------- builder : CalciteBuilder A builder to use for translation. - arg : BaseExpr + arg : list of BaseExpr An aggregated value. """ def __init__(self, builder, arg): - assert isinstance(arg, InputRefExpr) - super().__init__(builder, arg) + assert isinstance(arg[0], InputRefExpr) + super().__init__(builder, arg[0]) self._quad_name = self._arg.column + "__quad__" self._sum_name = self._arg.column + "__sum__" @@ -207,13 +255,13 @@ class SkewAggregate(CompoundAggregate): ---------- builder : CalciteBuilder A builder to use for translation. - arg : BaseExpr + arg : list of BaseExpr An aggregated value. """ def __init__(self, builder, arg): - assert isinstance(arg, InputRefExpr) - super().__init__(builder, arg) + assert isinstance(arg[0], InputRefExpr) + super().__init__(builder, arg[0]) self._quad_name = self._arg.column + "__quad__" self._cube_name = self._arg.column + "__cube__" @@ -307,7 +355,34 @@ def gen_reduce_expr(self): skew_expr._dtype, ) - _compound_aggregates = {"std": StdAggregate, "skew": SkewAggregate} + class TopkAggregate(CompoundAggregateWithColArg): + """ + A TOP_K aggregate generator. + + Parameters + ---------- + builder : CalciteBuilder + A builder to use for translation. + arg : List of BaseExpr + An aggregated values. + """ + + def __init__(self, builder, arg): + super().__init__("TOP_K", builder, arg) + + def gen_reduce_expr(self): + return OpExpr( + "PG_UNNEST", + [super().gen_reduce_expr()], + self._dtype, + ) + + _compound_aggregates = { + "std": StdAggregate, + "skew": SkewAggregate, + "nlargest": TopkAggregate, + "nsmallest": TopkAggregate, + } class InputContext: """ @@ -930,7 +1005,7 @@ def _process_groupby(self, op): for agg, expr in agg_exprs.items(): if expr.agg in self._compound_aggregates: compound_aggs[agg] = self._compound_aggregates[expr.agg]( - self, expr.operands[0] + self, expr.operands ) extra_exprs = compound_aggs[agg].gen_proj_exprs() proj_cols.extend(extra_exprs.keys()) diff --git a/modin/experimental/core/execution/native/implementations/hdk_on_native/dataframe/dataframe.py b/modin/experimental/core/execution/native/implementations/hdk_on_native/dataframe/dataframe.py index 66a5e81b6ec..a7d64ea4dd9 100644 --- a/modin/experimental/core/execution/native/implementations/hdk_on_native/dataframe/dataframe.py +++ b/modin/experimental/core/execution/native/implementations/hdk_on_native/dataframe/dataframe.py @@ -646,8 +646,16 @@ def generate_by_name(by): agg_exprs = OrderedDict() if isinstance(agg, str): - for col in agg_cols: - agg_exprs[col] = AggregateExpr(agg, base.ref(col)) + if agg == "nlargest" or agg == "nsmallest": + n = kwargs["agg_kwargs"]["n"] + if agg == "nsmallest": + n = -n + n = LiteralExpr(n) + for col in agg_cols: + agg_exprs[col] = AggregateExpr(agg, [base.ref(col), n]) + else: + for col in agg_cols: + agg_exprs[col] = AggregateExpr(agg, base.ref(col)) else: assert isinstance(agg, dict), "unsupported aggregate type" multiindex = any(isinstance(v, list) for v in agg.values()) @@ -742,7 +750,7 @@ def _groupby_head_tail( filter = transform.copy(op=FilterNode(transform, cond)) exprs = filter._index_exprs() exprs.update((col, filter.ref(col)) for col in base.columns) - return base.copy(op=TransformNode(filter, exprs)) + return base.copy(op=TransformNode(filter, exprs), partitions=None, index=None) def agg(self, agg): """ diff --git a/modin/experimental/core/execution/native/implementations/hdk_on_native/expr.py b/modin/experimental/core/execution/native/implementations/hdk_on_native/expr.py index 726873e34b3..1a8f9dbb7a3 100644 --- a/modin/experimental/core/execution/native/implementations/hdk_on_native/expr.py +++ b/modin/experimental/core/execution/native/implementations/hdk_on_native/expr.py @@ -77,7 +77,7 @@ def _get_common_dtype(lhs_dtype, rhs_dtype): ) -_aggs_preserving_numeric_type = {"sum", "min", "max"} +_aggs_preserving_numeric_type = {"sum", "min", "max", "nlargest", "nsmallest"} _aggs_with_int_result = {"count", "size"} _aggs_with_float_result = {"mean", "median", "std", "skew"} @@ -1257,7 +1257,7 @@ class AggregateExpr(BaseExpr): ---------- agg : str Aggregate name. - op : BaseExpr + op : BaseExpr or list of BaseExpr Aggregate operand. distinct : bool, default: False Distinct modifier for 'count' aggregate. @@ -1269,7 +1269,7 @@ class AggregateExpr(BaseExpr): agg : str Aggregate name. operands : list of BaseExpr - Aggregate operands. Always has a single operand. + Aggregate operands. distinct : bool Distinct modifier for 'count' aggregate. _dtype : dtype @@ -1283,10 +1283,8 @@ def __init__(self, agg, op, distinct=False, dtype=None): else: self.agg = agg self.distinct = distinct - self.operands = [op] - self._dtype = ( - dtype if dtype else _agg_dtype(self.agg, op._dtype if op else None) - ) + self.operands = op if isinstance(op, list) else [op] + self._dtype = dtype or _agg_dtype(self.agg, self.operands[0]._dtype) assert self._dtype is not None def copy(self): @@ -1297,7 +1295,7 @@ def copy(self): ------- AggregateExpr """ - return AggregateExpr(self.agg, self.operands[0], self.distinct, self._dtype) + return AggregateExpr(self.agg, self.operands, self.distinct, self._dtype) def __repr__(self): """ diff --git a/modin/experimental/core/execution/native/implementations/hdk_on_native/test/test_dataframe.py b/modin/experimental/core/execution/native/implementations/hdk_on_native/test/test_dataframe.py index a8d44423b3d..37c00cb3a4a 100644 --- a/modin/experimental/core/execution/native/implementations/hdk_on_native/test/test_dataframe.py +++ b/modin/experimental/core/execution/native/implementations/hdk_on_native/test/test_dataframe.py @@ -1279,6 +1279,14 @@ def head(df, **kwargs): # When invert is false, the rowid column is materialized. run_and_compare(head, data=test_data["int_data"], force_lazy=invert) + @pytest.mark.parametrize("agg", ["nlargest", "nsmallest"]) + @pytest.mark.parametrize("n", [1, 5, 10]) + def test_topk(self, agg, n): + def topk(df, **kwargs): + return getattr(df.groupby("id6")["v3"], agg)(n).reset_index()[["id6", "v3"]] + + run_and_compare(topk, data=self.h2o_data) + class TestAgg: data = { diff --git a/modin/experimental/core/storage_formats/hdk/query_compiler.py b/modin/experimental/core/storage_formats/hdk/query_compiler.py index 39b4efe025b..8c9b0c56fc1 100644 --- a/modin/experimental/core/storage_formats/hdk/query_compiler.py +++ b/modin/experimental/core/storage_formats/hdk/query_compiler.py @@ -366,6 +366,7 @@ def groupby_agg( agg_kwargs, how="axis_wise", drop=False, + series_groupby=False, ): # TODO: handle `drop` args if callable(agg_func): From b4ed639ebda331b6c3d9087d884f122e73790b5a Mon Sep 17 00:00:00 2001 From: Anatoly Myachev Date: Tue, 26 Sep 2023 18:15:04 +0200 Subject: [PATCH 021/201] FIX-#6601: 'sort_values' shouldn't affect source dataframe/series (#6603) Signed-off-by: Anatoly Myachev --- modin/core/dataframe/pandas/dataframe/dataframe.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modin/core/dataframe/pandas/dataframe/dataframe.py b/modin/core/dataframe/pandas/dataframe/dataframe.py index 4bce1769c91..949040c61e0 100644 --- a/modin/core/dataframe/pandas/dataframe/dataframe.py +++ b/modin/core/dataframe/pandas/dataframe/dataframe.py @@ -2538,7 +2538,7 @@ def sort_function(df): # pragma: no cover # We perform the final steps of the sort on full axis partitions, so we know that the # length of each partition is the full length of the dataframe. if self.has_materialized_columns: - self._set_axis_lengths_cache([len(self.columns)], axis=axis.value ^ 1) + result._set_axis_lengths_cache([len(self.columns)], axis=axis.value ^ 1) if kwargs.get("ignore_index", False): result.index = RangeIndex(len(self.get_axis(axis.value))) From be0eab987fa185610dce3542d8f7b1281276dbe8 Mon Sep 17 00:00:00 2001 From: Anatoly Myachev Date: Tue, 26 Sep 2023 18:20:16 +0200 Subject: [PATCH 022/201] FIX-#6582: avoid `FutureWarning`s in `bfill/backfill/ffill/pad` unless necessary (#6599) Signed-off-by: Anatoly Myachev --- modin/pandas/base.py | 67 ++++++++++++++++++++++++++++++++++++++------ 1 file changed, 59 insertions(+), 8 deletions(-) diff --git a/modin/pandas/base.py b/modin/pandas/base.py index 5bf30b4fed9..0c16fc67e0e 100644 --- a/modin/pandas/base.py +++ b/modin/pandas/base.py @@ -1098,11 +1098,33 @@ def bfill( Synonym for `DataFrame.fillna` with ``method='bfill'``. """ downcast = self._deprecate_downcast(downcast, "bfill") - return self.fillna( - method="bfill", axis=axis, limit=limit, downcast=downcast, inplace=inplace - ) + with warnings.catch_warnings(): + warnings.filterwarnings( + "ignore", ".*fillna with 'method' is deprecated", category=FutureWarning + ) + return self.fillna( + method="bfill", + axis=axis, + limit=limit, + downcast=downcast, + inplace=inplace, + ) - backfill = bfill + def backfill( + self, *, axis=None, inplace=False, limit=None, downcast=lib.no_default + ): # noqa: PR01, RT01, D200 + """ + Synonym for `DataFrame.bfill`. + """ + warnings.warn( + "DataFrame.backfill/Series.backfill is deprecated. Use DataFrame.bfill/Series.bfill instead", + FutureWarning, + ) + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", category=FutureWarning) + return self.bfill( + axis=axis, inplace=inplace, limit=limit, downcast=downcast + ) def bool(self): # noqa: RT01, D200 """ @@ -1564,11 +1586,33 @@ def ffill( Synonym for `DataFrame.fillna` with ``method='ffill'``. """ downcast = self._deprecate_downcast(downcast, "ffill") - return self.fillna( - method="ffill", axis=axis, limit=limit, downcast=downcast, inplace=inplace - ) + with warnings.catch_warnings(): + warnings.filterwarnings( + "ignore", ".*fillna with 'method' is deprecated", category=FutureWarning + ) + return self.fillna( + method="ffill", + axis=axis, + limit=limit, + downcast=downcast, + inplace=inplace, + ) - pad = ffill + def pad( + self, *, axis=None, inplace=False, limit=None, downcast=lib.no_default + ): # noqa: PR01, RT01, D200 + """ + Synonym for `DataFrame.ffill`. + """ + warnings.warn( + "DataFrame.pad/Series.pad is deprecated. Use DataFrame.ffill/Series.ffill instead", + FutureWarning, + ) + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", category=FutureWarning) + return self.ffill( + axis=axis, inplace=inplace, limit=limit, downcast=downcast + ) def fillna( self, @@ -1625,6 +1669,13 @@ def fillna( Series, DataFrame or None Object with missing values filled or None if ``inplace=True``. """ + if method is not None: + warnings.warn( + f"{type(self).__name__}.fillna with 'method' is deprecated and " + + "will raise in a future version. Use obj.ffill() or obj.bfill() " + + "instead.", + FutureWarning, + ) downcast = self._deprecate_downcast(downcast, "fillna") inplace = validate_bool_kwarg(inplace, "inplace") axis = self._get_axis_number(axis) From be2cb567de5cbf48d008ec97b92bbe0a1a038dac Mon Sep 17 00:00:00 2001 From: Andrey Pavlenko Date: Tue, 26 Sep 2023 21:04:12 +0200 Subject: [PATCH 023/201] FEAT-#6527: HDK: Add support for the quantile group by aggregation. (#6528) Signed-off-by: Andrey Pavlenko --- .../hdk_on_native/calcite_builder.py | 35 +++++++++++- .../hdk_on_native/dataframe/dataframe.py | 56 +++++++++++++------ .../implementations/hdk_on_native/expr.py | 17 ++++++ .../hdk_on_native/test/test_dataframe.py | 13 +++++ 4 files changed, 103 insertions(+), 18 deletions(-) diff --git a/modin/experimental/core/execution/native/implementations/hdk_on_native/calcite_builder.py b/modin/experimental/core/execution/native/implementations/hdk_on_native/calcite_builder.py index 5f80c71b60e..9b7cfb239c6 100644 --- a/modin/experimental/core/execution/native/implementations/hdk_on_native/calcite_builder.py +++ b/modin/experimental/core/execution/native/implementations/hdk_on_native/calcite_builder.py @@ -46,6 +46,7 @@ InputRefExpr, LiteralExpr, OpExpr, + _quantile_agg_dtype, build_if_then_else, build_row_idx_filter_expr, ) @@ -377,11 +378,42 @@ def gen_reduce_expr(self): self._dtype, ) + class QuantileAggregate(CompoundAggregateWithColArg): + """ + A QUANTILE aggregate generator. + + Parameters + ---------- + builder : CalciteBuilder + A builder to use for translation. + arg : List of BaseExpr + A list of 3 values: + 0. InputRefExpr - the column to compute the quantiles for. + 1. LiteralExpr - the quantile value. + 2. str - the interpolation method to use. + """ + + def __init__(self, builder, arg): + super().__init__( + "QUANTILE", + builder, + arg, + _quantile_agg_dtype(arg[0]._dtype), + ) + self._interpolation = arg[2].val.upper() + + def gen_agg_exprs(self): + exprs = super().gen_agg_exprs() + for expr in exprs.values(): + expr.interpolation = self._interpolation + return exprs + _compound_aggregates = { "std": StdAggregate, "skew": SkewAggregate, "nlargest": TopkAggregate, "nsmallest": TopkAggregate, + "quantile": QuantileAggregate, } class InputContext: @@ -419,7 +451,6 @@ class InputContext: "min": "MIN", "size": "COUNT", "count": "COUNT", - "median": "APPROX_QUANTILE", } _no_arg_aggregates = {"size"} @@ -662,7 +693,7 @@ def __exit__(self, type, value, traceback): _bool_cast_aggregates = { "sum": _get_dtype(int), "mean": _get_dtype(float), - "median": _get_dtype(float), + "quantile": _get_dtype(float), } def __init__(self): diff --git a/modin/experimental/core/execution/native/implementations/hdk_on_native/dataframe/dataframe.py b/modin/experimental/core/execution/native/implementations/hdk_on_native/dataframe/dataframe.py index a7d64ea4dd9..c0bf6beb65f 100644 --- a/modin/experimental/core/execution/native/implementations/hdk_on_native/dataframe/dataframe.py +++ b/modin/experimental/core/execution/native/implementations/hdk_on_native/dataframe/dataframe.py @@ -646,26 +646,19 @@ def generate_by_name(by): agg_exprs = OrderedDict() if isinstance(agg, str): - if agg == "nlargest" or agg == "nsmallest": - n = kwargs["agg_kwargs"]["n"] - if agg == "nsmallest": - n = -n - n = LiteralExpr(n) - for col in agg_cols: - agg_exprs[col] = AggregateExpr(agg, [base.ref(col), n]) - else: - for col in agg_cols: - agg_exprs[col] = AggregateExpr(agg, base.ref(col)) + col_to_ref = {col: base.ref(col) for col in agg_cols} + self._add_agg_exprs(agg, col_to_ref, kwargs, agg_exprs) else: assert isinstance(agg, dict), "unsupported aggregate type" multiindex = any(isinstance(v, list) for v in agg.values()) - for k, v in agg.items(): - if isinstance(v, list): - for item in v: - agg_exprs[(k, item)] = AggregateExpr(item, base.ref(k)) + for col, aggs in agg.items(): + if isinstance(aggs, list): + for a in aggs: + col_to_ref = {(col, a): base.ref(col)} + self._add_agg_exprs(a, col_to_ref, kwargs, agg_exprs) else: - col_name = (k, v) if multiindex else k - agg_exprs[col_name] = AggregateExpr(v, base.ref(k)) + col_to_ref = {((col, aggs) if multiindex else col): base.ref(col)} + self._add_agg_exprs(aggs, col_to_ref, kwargs, agg_exprs) new_columns.extend(agg_exprs.keys()) new_dtypes.extend((x._dtype for x in agg_exprs.values())) new_columns = Index.__new__(Index, data=new_columns, dtype=self.columns.dtype) @@ -692,6 +685,37 @@ def generate_by_name(by): ) return new_frame + def _add_agg_exprs(self, agg, col_to_ref, kwargs, agg_exprs): + """ + Add `AggregateExpr`s for each column to `agg_exprs`. + + Parameters + ---------- + agg : str + col_to_ref : dict + kwargs : dict + agg_exprs : dict + """ + if agg == "nlargest" or agg == "nsmallest": + n = kwargs["agg_kwargs"]["n"] + if agg == "nsmallest": + n = -n + n = LiteralExpr(n) + for col, ref in col_to_ref.items(): + agg_exprs[col] = AggregateExpr(agg, [ref, n]) + elif agg == "median" or agg == "quantile": + agg_kwargs = kwargs["agg_kwargs"] + q = agg_kwargs.get("q", 0.5) + if not isinstance(q, float): + raise NotImplementedError("Non-float quantile") + q = LiteralExpr(q) + interpolation = LiteralExpr(agg_kwargs.get("interpolation", "linear")) + for col, ref in col_to_ref.items(): + agg_exprs[col] = AggregateExpr("quantile", [ref, q, interpolation]) + else: + for col, ref in col_to_ref.items(): + agg_exprs[col] = AggregateExpr(agg, ref) + def _groupby_head_tail( self, agg: str, n: int, cols: Iterable[str] ) -> "HdkOnNativeDataframe": diff --git a/modin/experimental/core/execution/native/implementations/hdk_on_native/expr.py b/modin/experimental/core/execution/native/implementations/hdk_on_native/expr.py index 1a8f9dbb7a3..8a4a1543e6c 100644 --- a/modin/experimental/core/execution/native/implementations/hdk_on_native/expr.py +++ b/modin/experimental/core/execution/native/implementations/hdk_on_native/expr.py @@ -82,6 +82,21 @@ def _get_common_dtype(lhs_dtype, rhs_dtype): _aggs_with_float_result = {"mean", "median", "std", "skew"} +def _quantile_agg_dtype(dtype): + """ + Compute the quantile aggregate data type. + + Parameters + ---------- + dtype : dtype + + Returns + ------- + dtype + """ + return dtype if is_datetime64_any_dtype(dtype) else _get_dtype(float) + + def _agg_dtype(agg, dtype): """ Compute aggregate data type. @@ -104,6 +119,8 @@ def _agg_dtype(agg, dtype): return _get_dtype(int) elif agg in _aggs_with_float_result: return _get_dtype(float) + elif agg == "quantile": + return _quantile_agg_dtype(dtype) else: raise NotImplementedError(f"unsupported aggregate {agg}") diff --git a/modin/experimental/core/execution/native/implementations/hdk_on_native/test/test_dataframe.py b/modin/experimental/core/execution/native/implementations/hdk_on_native/test/test_dataframe.py index 37c00cb3a4a..39ed182febe 100644 --- a/modin/experimental/core/execution/native/implementations/hdk_on_native/test/test_dataframe.py +++ b/modin/experimental/core/execution/native/implementations/hdk_on_native/test/test_dataframe.py @@ -1287,6 +1287,19 @@ def topk(df, **kwargs): run_and_compare(topk, data=self.h2o_data) + @pytest.mark.parametrize("time", [False, True]) + @pytest.mark.parametrize("q", [0.1, 0.5, 1.0]) + @pytest.mark.parametrize( + "interpolation", ["linear", "lower", "higher", "midpoint", "nearest"] + ) + def test_quantile(self, time, q, interpolation): + def quantile(df, **kwargs): + if time: + df["v1"] = df["v1"].astype("datetime64[ns]") + return df.groupby("id4")[["v1", "v2", "v3"]].quantile(q, interpolation) + + run_and_compare(quantile, data=self.h2o_data) + class TestAgg: data = { From 22ce95e78b4db2f21d5940cbd8ee656e7e565d15 Mon Sep 17 00:00:00 2001 From: Anatoly Myachev Date: Tue, 26 Sep 2023 21:53:10 +0200 Subject: [PATCH 024/201] Release version 0.24.0 (#6605) Signed-off-by: Anatoly Myachev From ab5efad849eb9faf57679230150d042d809a832c Mon Sep 17 00:00:00 2001 From: Andrey Pavlenko Date: Wed, 27 Sep 2023 13:53:34 +0200 Subject: [PATCH 025/201] FIX-#6604: HDK: Added support for list to DataFrame.agg() (#6606) Signed-off-by: Andrey Pavlenko --- .../implementations/hdk_on_native/dataframe/dataframe.py | 8 ++++++-- .../implementations/hdk_on_native/test/test_dataframe.py | 6 ++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/modin/experimental/core/execution/native/implementations/hdk_on_native/dataframe/dataframe.py b/modin/experimental/core/execution/native/implementations/hdk_on_native/dataframe/dataframe.py index c0bf6beb65f..082b78d4143 100644 --- a/modin/experimental/core/execution/native/implementations/hdk_on_native/dataframe/dataframe.py +++ b/modin/experimental/core/execution/native/implementations/hdk_on_native/dataframe/dataframe.py @@ -648,8 +648,9 @@ def generate_by_name(by): if isinstance(agg, str): col_to_ref = {col: base.ref(col) for col in agg_cols} self._add_agg_exprs(agg, col_to_ref, kwargs, agg_exprs) - else: - assert isinstance(agg, dict), "unsupported aggregate type" + elif isinstance(agg, (dict, list)): + if isinstance(agg, list): + agg = {col: agg for col in agg_cols} multiindex = any(isinstance(v, list) for v in agg.values()) for col, aggs in agg.items(): if isinstance(aggs, list): @@ -659,6 +660,9 @@ def generate_by_name(by): else: col_to_ref = {((col, aggs) if multiindex else col): base.ref(col)} self._add_agg_exprs(aggs, col_to_ref, kwargs, agg_exprs) + else: + raise NotImplementedError(f"aggregate type {type(agg)}") + new_columns.extend(agg_exprs.keys()) new_dtypes.extend((x._dtype for x in agg_exprs.values())) new_columns = Index.__new__(Index, data=new_columns, dtype=self.columns.dtype) diff --git a/modin/experimental/core/execution/native/implementations/hdk_on_native/test/test_dataframe.py b/modin/experimental/core/execution/native/implementations/hdk_on_native/test/test_dataframe.py index 39ed182febe..b4e98e1e77f 100644 --- a/modin/experimental/core/execution/native/implementations/hdk_on_native/test/test_dataframe.py +++ b/modin/experimental/core/execution/native/implementations/hdk_on_native/test/test_dataframe.py @@ -958,6 +958,12 @@ def dict_agg_all_cols(df, **kwargs): run_and_compare(dict_agg_all_cols, data=self.data) + def test_groupby_agg_list(self): + def agg(df, **kwargs): + return df.groupby("a")[["b", "c"]].agg(["sum", "size", "mean", "median"]) + + run_and_compare(agg, data=self.data) + # modin-issue#3461 def test_groupby_pure_by(self): data = [1, 1, 2, 2] From 47b7cae3b6a60fbef33fa9e8319087bd539fb66f Mon Sep 17 00:00:00 2001 From: Dmitry Chigarev Date: Wed, 27 Sep 2023 14:25:33 +0200 Subject: [PATCH 026/201] FIX-#6607: Fix incorrect cache after '.sort_values()' (#6608) Signed-off-by: Dmitry Chigarev --- .../dataframe/pandas/dataframe/dataframe.py | 34 ++++++--- .../storage_formats/pandas/test_internals.py | 70 ++++++++++++++++--- 2 files changed, 84 insertions(+), 20 deletions(-) diff --git a/modin/core/dataframe/pandas/dataframe/dataframe.py b/modin/core/dataframe/pandas/dataframe/dataframe.py index 949040c61e0..29eaaad3bea 100644 --- a/modin/core/dataframe/pandas/dataframe/dataframe.py +++ b/modin/core/dataframe/pandas/dataframe/dataframe.py @@ -2380,7 +2380,7 @@ def combine_and_apply( ) def _apply_func_to_range_partitioning( - self, key_columns, func, ascending=True, **kwargs + self, key_columns, func, ascending=True, preserve_columns=False, **kwargs ): """ Reshuffle data so it would be range partitioned and then apply the passed function row-wise. @@ -2393,6 +2393,8 @@ def _apply_func_to_range_partitioning( Function to apply against partitions. ascending : bool, default: True Whether the range should be built in ascending or descending order. + preserve_columns : bool, default: False + If the columns cache should be preserved (specify this flag if `func` doesn't change column labels). **kwargs : dict Additional arguments to forward to the range builder function. @@ -2403,7 +2405,14 @@ def _apply_func_to_range_partitioning( """ # If there's only one row partition can simply apply the function row-wise without the need to reshuffle if self._partitions.shape[0] == 1: - return self.apply_full_axis(axis=1, func=func) + result = self.apply_full_axis( + axis=1, + func=func, + new_columns=self.copy_columns_cache() if preserve_columns else None, + ) + if preserve_columns: + result._set_axis_lengths_cache(self._column_widths_cache, axis=1) + return result # don't want to inherit over-partitioning so doing this 'min' check ideal_num_new_partitions = min(len(self._partitions), NPartitions.get()) @@ -2473,7 +2482,14 @@ def _apply_func_to_range_partitioning( func, ) - return self.__constructor__(new_partitions) + result = self.__constructor__(new_partitions) + if preserve_columns: + result.set_columns_cache(self.copy_columns_cache()) + # We perform the final steps of the sort on full axis partitions, so we know that the + # length of each partition is the full length of the dataframe. + if self.has_materialized_columns: + result._set_axis_lengths_cache([len(self.columns)], axis=1) + return result @lazy_metadata_decorator(apply_axis="both") def sort_by( @@ -2530,15 +2546,13 @@ def sort_function(df): # pragma: no cover ) result = self._apply_func_to_range_partitioning( - key_columns=[columns[0]], func=sort_function, ascending=ascending, **kwargs + key_columns=[columns[0]], + func=sort_function, + ascending=ascending, + preserve_columns=True, + **kwargs, ) - - result.set_axis_cache(self.copy_axis_cache(axis.value ^ 1), axis=axis.value ^ 1) result.set_dtypes_cache(self.copy_dtypes_cache()) - # We perform the final steps of the sort on full axis partitions, so we know that the - # length of each partition is the full length of the dataframe. - if self.has_materialized_columns: - result._set_axis_lengths_cache([len(self.columns)], axis=axis.value ^ 1) if kwargs.get("ignore_index", False): result.index = RangeIndex(len(self.get_axis(axis.value))) diff --git a/modin/test/storage_formats/pandas/test_internals.py b/modin/test/storage_formats/pandas/test_internals.py index 8d4213015ba..ddecde800ac 100644 --- a/modin/test/storage_formats/pandas/test_internals.py +++ b/modin/test/storage_formats/pandas/test_internals.py @@ -113,20 +113,30 @@ def construct_modin_df_by_scheme(pandas_df, partitioning_scheme): return md_df -def validate_partitions_cache(df): - """Assert that the ``PandasDataframe`` shape caches correspond to the actual partition's shapes.""" - row_lengths = df._row_lengths_cache - column_widths = df._column_widths_cache +def validate_partitions_cache(df, axis=None): + """ + Assert that the ``PandasDataframe`` shape caches correspond to the actual partition's shapes. - assert row_lengths is not None - assert column_widths is not None - assert df._partitions.shape[0] == len(row_lengths) - assert df._partitions.shape[1] == len(column_widths) + Parameters + ---------- + df : PandasDataframe + axis : int, optional + An axis to verify the cache for. If not specified, verify cache for both of the axes. + """ + axis = [0, 1] if axis is None else [axis] + + axis_lengths = [df._row_lengths_cache, df._column_widths_cache] + + for ax in axis: + assert axis_lengths[ax] is not None + assert df._partitions.shape[ax] == len(axis_lengths[ax]) for i in range(df._partitions.shape[0]): for j in range(df._partitions.shape[1]): - assert df._partitions[i, j].length() == row_lengths[i] - assert df._partitions[i, j].width() == column_widths[j] + if 0 in axis: + assert df._partitions[i, j].length() == axis_lengths[0][i] + if 1 in axis: + assert df._partitions[i, j].width() == axis_lengths[1][j] def assert_has_no_cache(df, axis=0): @@ -1340,3 +1350,43 @@ def test_query_dispatching(): qc.rowwise_query("a < (b + @local_var + (b - e.min())) * c > 10") with pytest.raises(NotImplementedError): qc.rowwise_query("a < b.size") + + +def test_sort_values_cache(): + """ + Test that the column widths cache after ``.sort_values()`` is valid: + https://github.com/modin-project/modin/issues/6607 + """ + # 1 row partition and 2 column partitions, in this case '.sort_values()' will use + # row-wise implementation and so the column widths WILL NOT be changed + modin_df = construct_modin_df_by_scheme( + pandas.DataFrame({f"col{i}": range(100) for i in range(64)}), + partitioning_scheme={"row_lengths": [100], "column_widths": [32, 32]}, + ) + mf_initial = modin_df._query_compiler._modin_frame + + mf_res = modin_df.sort_values("col0")._query_compiler._modin_frame + # check that row-wise implementation was indeed used (col widths were not changed) + assert mf_res._column_widths_cache == [32, 32] + # check that the cache and actual col widths match + validate_partitions_cache(mf_res, axis=1) + # check that the initial frame's cache wasn't changed + assert mf_initial._column_widths_cache == [32, 32] + validate_partitions_cache(mf_initial, axis=1) + + # 2 row partition and 2 column partitions, in this case '.sort_values()' will use + # range-partitioning implementation and so the column widths WILL be changed + modin_df = construct_modin_df_by_scheme( + pandas.DataFrame({f"col{i}": range(100) for i in range(64)}), + partitioning_scheme={"row_lengths": [50, 50], "column_widths": [32, 32]}, + ) + mf_initial = modin_df._query_compiler._modin_frame + + mf_res = modin_df.sort_values("col0")._query_compiler._modin_frame + # check that range-partitioning implementation was indeed used (col widths were changed) + assert mf_res._column_widths_cache == [64] + # check that the cache and actual col widths match + validate_partitions_cache(mf_res, axis=1) + # check that the initial frame's cache wasn't changed + assert mf_initial._column_widths_cache == [32, 32] + validate_partitions_cache(mf_initial, axis=1) From 02b1323175b32906e8a64d991d35cb950ee8a90d Mon Sep 17 00:00:00 2001 From: Anatoly Myachev Date: Fri, 29 Sep 2023 10:30:11 +0200 Subject: [PATCH 027/201] FIX-#6602: refactor `join` to avoid `distributing a dict object` warning (#6612) Signed-off-by: Anatoly Myachev --- modin/pandas/dataframe.py | 2 +- modin/pandas/test/dataframe/test_join_sort.py | 22 +++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/modin/pandas/dataframe.py b/modin/pandas/dataframe.py index 98ef7a46f91..1cee5e62c14 100644 --- a/modin/pandas/dataframe.py +++ b/modin/pandas/dataframe.py @@ -1160,7 +1160,7 @@ def join( if isinstance(other, Series): if other.name is None: raise ValueError("Other Series must have a name") - other = self.__constructor__({other.name: other}) + other = self.__constructor__(other) if on is not None: return self.__constructor__( query_compiler=self._query_compiler.join( diff --git a/modin/pandas/test/dataframe/test_join_sort.py b/modin/pandas/test/dataframe/test_join_sort.py index 0a079fff569..19f0f3d6b03 100644 --- a/modin/pandas/test/dataframe/test_join_sort.py +++ b/modin/pandas/test/dataframe/test_join_sort.py @@ -11,6 +11,8 @@ # ANY KIND, either express or implied. See the License for the specific language # governing permissions and limitations under the License. +import warnings + import matplotlib import numpy as np import pandas @@ -180,6 +182,26 @@ def test_join_5203(): dfs[0].join([dfs[1], dfs[2]], how="inner", on="a") +def test_join_6602(): + abbreviations = pd.Series( + ["Major League Baseball", "National Basketball Association"], + index=["MLB", "NBA"], + ) + teams = pd.DataFrame( + { + "name": ["Mariners", "Lakers"] * 50, + "league_abbreviation": ["MLB", "NBA"] * 50, + } + ) + + with warnings.catch_warnings(): + # check that join doesn't show UserWarning + warnings.filterwarnings( + "error", "Distributing object", category=UserWarning + ) + teams.set_index("league_abbreviation").join(abbreviations.rename("league_name")) + + @pytest.mark.parametrize( "test_data, test_data2", [ From 65ad7355a91b6012cfba1c6e09d1e13cd6238027 Mon Sep 17 00:00:00 2001 From: Andrey Pavlenko Date: Fri, 29 Sep 2023 17:27:45 +0200 Subject: [PATCH 028/201] PERF-#5533: Improved `sort_values` by reducing the number of partitions (#6589) Signed-off-by: Andrey Pavlenko --- .../dataframe/pandas/partitioning/partition_manager.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/modin/core/dataframe/pandas/partitioning/partition_manager.py b/modin/core/dataframe/pandas/partitioning/partition_manager.py index a642f9453f1..904a2809b4f 100644 --- a/modin/core/dataframe/pandas/partitioning/partition_manager.py +++ b/modin/core/dataframe/pandas/partitioning/partition_manager.py @@ -257,8 +257,16 @@ def groupby_reduce( ) else: mapped_partitions = cls.map_partitions(partitions, map_func) + + # Assuming, that the output will not be larger than the input, + # keep the current number of partitions. + num_splits = min(len(partitions), NPartitions.get()) return cls.map_axis_partitions( - axis, mapped_partitions, reduce_func, enumerate_partitions=True + axis, + mapped_partitions, + reduce_func, + enumerate_partitions=True, + num_splits=num_splits, ) @classmethod From ebe23d7c2bfb840f3bb55bf64ed70450c00d0fbb Mon Sep 17 00:00:00 2001 From: Anatoly Myachev Date: Fri, 29 Sep 2023 17:50:14 +0200 Subject: [PATCH 029/201] FIX-#6600: fix usage of list of UDF functions in 'Series.groupby.agg' (#6613) Signed-off-by: Anatoly Myachev --- modin/pandas/groupby.py | 9 ++++----- modin/pandas/test/test_groupby.py | 18 ++++++++++++++++++ 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/modin/pandas/groupby.py b/modin/pandas/groupby.py index b9c4f2109a7..ff708bce6b8 100644 --- a/modin/pandas/groupby.py +++ b/modin/pandas/groupby.py @@ -1824,14 +1824,13 @@ def _try_get_str_func(self, fn): Returns ------- str, list - If `fn` is a callable, return its name if it's a method of the groupby - object, otherwise return `fn` itself. If `fn` is a string, return it. - If `fn` is an Iterable, return a list of _try_get_str_func applied to - each element of `fn`. + If `fn` is a callable, return its name, otherwise return `fn` itself. + If `fn` is a string, return it. If `fn` is an Iterable, return a list + of _try_get_str_func applied to each element of `fn`. """ if not isinstance(fn, str) and isinstance(fn, Iterable): return [self._try_get_str_func(f) for f in fn] - return fn.__name__ if callable(fn) and fn.__name__ in dir(self) else fn + return fn.__name__ if callable(fn) else fn def value_counts( self, diff --git a/modin/pandas/test/test_groupby.py b/modin/pandas/test/test_groupby.py index 2406cf20497..52aeeae0da3 100644 --- a/modin/pandas/test/test_groupby.py +++ b/modin/pandas/test/test_groupby.py @@ -1127,6 +1127,24 @@ def test_series_groupby(by, as_index_series_or_dataframe): eval_groups(modin_groupby, pandas_groupby) +def test_agg_udf_6600(): + data = { + "name": ["Mariners", "Lakers"] * 50, + "league_abbreviation": ["MLB", "NBA"] * 50, + } + modin_teams, pandas_teams = create_test_dfs(data) + + def my_first_item(s): + return s.iloc[0] + + for agg in (my_first_item, [my_first_item], ["nunique", my_first_item]): + eval_general( + modin_teams, + pandas_teams, + operation=lambda df: df.groupby("league_abbreviation").name.agg(agg), + ) + + def test_multi_column_groupby(): pandas_df = pandas.DataFrame( { From fc17c48cb092ea90d7c0beac81063fdb068e4731 Mon Sep 17 00:00:00 2001 From: ahmadjubair33 <89836059+ahmadjubair33@users.noreply.github.com> Date: Mon, 2 Oct 2023 19:29:40 +0530 Subject: [PATCH 030/201] DOCS-#4085: Replace vague links to actual names of the pages/sections in docs (#4096) Co-authored-by: Iaroslav Igoshev Signed-off-by: Anatoly Myachev --- docs/flow/modin/core/execution/dispatching.rst | 2 +- .../core/storage_formats/base/query_compiler.rst | 2 +- docs/flow/modin/pandas/dataframe.rst | 2 +- docs/flow/modin/pandas/series.rst | 2 +- docs/getting_started/examples.rst | 4 ++-- docs/getting_started/faq.rst | 11 ++++++----- .../using_modin/using_modin_locally.rst | 5 +++-- .../why_modin/modin_vs_dask_vs_koalas.rst | 2 +- docs/usage_guide/optimization_notes/index.rst | 2 +- 9 files changed, 17 insertions(+), 15 deletions(-) diff --git a/docs/flow/modin/core/execution/dispatching.rst b/docs/flow/modin/core/execution/dispatching.rst index cf158c88b23..507bb21d0e3 100644 --- a/docs/flow/modin/core/execution/dispatching.rst +++ b/docs/flow/modin/core/execution/dispatching.rst @@ -31,7 +31,7 @@ extends ``PandasDataframe``. In the scope of this module, each execution is represented with a factory class located in ``modin/core/execution/dispatching/factories/factories.py``. Each factory contains a field that identifies the IO module of the corresponding execution. This IO module is responsible for dispatching calls of IO functions to their actual implementations in the -underlying IO module. For more information about IO module visit :doc:`related doc `. +underlying IO module. For more information about IO module visit :doc:`IO ` page. Factory Dispatcher '''''''''''''''''' diff --git a/docs/flow/modin/core/storage_formats/base/query_compiler.rst b/docs/flow/modin/core/storage_formats/base/query_compiler.rst index bfdb6a2b4b7..999019ccb40 100644 --- a/docs/flow/modin/core/storage_formats/base/query_compiler.rst +++ b/docs/flow/modin/core/storage_formats/base/query_compiler.rst @@ -88,7 +88,7 @@ and already can be used in Modin DataFrame: To be able to select this query compiler as default via ``modin.config`` you also need to define the combination of your query compiler and pandas engine as an execution by adding the corresponding factory. To find more information about factories, -visit :doc:`corresponding section ` of the flow documentation. +visit :doc:`dispatching ` page. Query Compiler API '''''''''''''''''' diff --git a/docs/flow/modin/pandas/dataframe.rst b/docs/flow/modin/pandas/dataframe.rst index dd5c3eb9615..3b74b54caf7 100644 --- a/docs/flow/modin/pandas/dataframe.rst +++ b/docs/flow/modin/pandas/dataframe.rst @@ -26,7 +26,7 @@ Usage Guide The most efficient way to create Modin ``DataFrame`` is to import data from external storage using the highly efficient Modin IO methods (for example using ``pd.read_csv``, -see details for Modin IO methods in the :doc:`separate section `), +see details for Modin IO methods in the :doc:`IO ` page), but even if the data does not originate from a file, any pandas supported data type or ``pandas.DataFrame`` can be used. Internally, the ``DataFrame`` data is divided into partitions, which number along an axis usually corresponds to the number of the user's hardware CPUs. If needed, diff --git a/docs/flow/modin/pandas/series.rst b/docs/flow/modin/pandas/series.rst index 6d3010c7ee1..7a0bd9a6094 100644 --- a/docs/flow/modin/pandas/series.rst +++ b/docs/flow/modin/pandas/series.rst @@ -26,7 +26,7 @@ Usage Guide The most efficient way to create Modin ``Series`` is to import data from external storage using the highly efficient Modin IO methods (for example using ``pd.read_csv``, -see details for Modin IO methods in the :doc:`separate section `), +see details for Modin IO methods in the :doc:`IO ` page), but even if the data does not originate from a file, any pandas supported data type or ``pandas.Series`` can be used. Internally, the ``Series`` data is divided into partitions, which number along an axis usually corresponds to the number of the user's hardware CPUs. If needed, diff --git a/docs/getting_started/examples.rst b/docs/getting_started/examples.rst index 3c3614955ae..cf436cdd7d6 100644 --- a/docs/getting_started/examples.rst +++ b/docs/getting_started/examples.rst @@ -1,8 +1,8 @@ Examples and Resources ====================== -Here you can find additional resources to learn about Modin. To learn more about -advanced usage for Modin, please refer to :doc:`this section `. +Here you can find additional resources to learn about Modin. To learn more about +advanced usage for Modin, please refer to :doc:`Usage Guide ` section.. Usage Examples '''''''''''''' diff --git a/docs/getting_started/faq.rst b/docs/getting_started/faq.rst index 724b57113cb..bca4e36f521 100644 --- a/docs/getting_started/faq.rst +++ b/docs/getting_started/faq.rst @@ -21,8 +21,8 @@ The :py:class:`~modin.pandas.dataframe.DataFrame` is a highly scalable, parallel DataFrame. Modin transparently distributes the data and computation so that you can continue using the same pandas API while being able to work with more data faster. Modin lets you use all the CPU cores on your machine, and because it is lightweight, it -often has less memory overhead than pandas. See this :doc:`page ` to -learn more about how Modin is different from pandas. +often has less memory overhead than pandas. See :doc:` Why Modin? ` +page to learn more about how Modin is different from pandas. Why not just improve pandas? """""""""""""""""""""""""""" @@ -61,7 +61,8 @@ How does Modin compare to Dask DataFrame and Koalas? TLDR: Modin has better coverage of the pandas API, has a flexible backend, better ordering semantics, and supports both row and column-parallel operations. -Check out this :doc:`page ` detailing the differences! +Check out :doc:`Modin vs Dask vs Koalas ` page detailing +the differences! How does Modin work under the hood? """"""""""""""""""""""""""""""""""" @@ -135,8 +136,8 @@ This can also be done with: modin_cfg.Engine.put('unidist') # Modin will use Unidist unidist_cfg.Backend.put('mpi') # Unidist will use MPI backend -We also have an experimental HDK-based engine of Modin you can read about :doc:`here `. -We plan to support more execution engines in future. If you have a specific request, +We also have an experimental HDK-based engine of Modin, which you can read about on :doc:`Using HDK ` +page. We plan to support more execution engines in future. If you have a specific request, please post on the #feature-requests channel on our Slack_ community. How do I connect Modin to a database via `read_sql`? diff --git a/docs/getting_started/using_modin/using_modin_locally.rst b/docs/getting_started/using_modin/using_modin_locally.rst index 23d48693dca..4ea095189ef 100644 --- a/docs/getting_started/using_modin/using_modin_locally.rst +++ b/docs/getting_started/using_modin/using_modin_locally.rst @@ -67,7 +67,7 @@ cluster for you: Finally, if you already have an Ray or Dask engine initialized, Modin will automatically attach to whichever engine is available. If you are interested in using Modin with HDK engine, please refer to :doc:`these instructions `. For additional information on other settings you can configure, see -:doc:`this page ` for more details. +:doc:`Modin's config ` page for more details. Advanced: Configuring the resources Modin uses ---------------------------------------------- @@ -81,7 +81,8 @@ the following code: import modin print(modin.config.NPartitions.get()) #prints 16 on a laptop with 16 physical cores -Modin fully utilizes the resources on your machine. To read more about how this works, see :doc:`this page` for more details. +Modin fully utilizes the resources on your machine. To read more about how this works, see :doc:`Why Modin? ` +page for more details. Since Modin will use all of the resources available on your machine by default, at times, it is possible that you may like to limit the amount of resources Modin uses to diff --git a/docs/getting_started/why_modin/modin_vs_dask_vs_koalas.rst b/docs/getting_started/why_modin/modin_vs_dask_vs_koalas.rst index a8482ce3873..5fb95a09223 100644 --- a/docs/getting_started/why_modin/modin_vs_dask_vs_koalas.rst +++ b/docs/getting_started/why_modin/modin_vs_dask_vs_koalas.rst @@ -46,7 +46,7 @@ Execution Semantics **DaskDF and Koalas make use of lazy evaluation, which means that the computation is delayed until users explicitly evaluate the results.** This mode of evaluation places a lot of optimization responsibility on the user, forcing them to think about when it would be useful to inspect the intermediate results or delay doing so. Specifically, DaskDF's API differs from pandas in that it requires users to explicitly call ``.compute()`` to materialize the result of the computation. Often if that computation corresponds to a long chain of operators, this call can take a very long time to execute. Overall, the need to explicitly trigger computation makes the API less convenient to work with, but gives DaskDF and Koalas the opportunity to perform holistic optimizations over the entire dataflow graph. However, to the best of our knowledge, neither DaskDF nor Koalas actually leverage holistic optimizations. -**Modin employs eager evaluation, like pandas.** Eager evaluation is the default mode of operation for data scientists when working with pandas in an interactive environment, such as Jupyter Notebooks. Modin reproduces this familiar behavior by performing all computations eagerly as soon as it is issued, so that users can inspect intermediate results and quickly see the results of their computations without having to wait or explicitly trigger computation. This is especially useful during interactive data analysis, where users often iterate on their dataframe workflows or build up their dataframe queries in an incremental fashion. Modin also supports lazy evaluation via the HDK engine, you can learn more about it :doc:`here `. We also have developed techniques for `opportunistic evaluation `_ that bridges the gap between lazy and eager evaluation that will be incorporated in Modin in the future. +**Modin employs eager evaluation, like pandas.** Eager evaluation is the default mode of operation for data scientists when working with pandas in an interactive environment, such as Jupyter Notebooks. Modin reproduces this familiar behavior by performing all computations eagerly as soon as it is issued, so that users can inspect intermediate results and quickly see the results of their computations without having to wait or explicitly trigger computation. This is especially useful during interactive data analysis, where users often iterate on their dataframe workflows or build up their dataframe queries in an incremental fashion. Modin also supports lazy evaluation via the HDK engine, you can learn more about it on :doc:`HDK ` page. We also have developed techniques for `opportunistic evaluation `_ that bridges the gap between lazy and eager evaluation that will be incorporated in Modin in the future. Ordering Semantics ------------------ diff --git a/docs/usage_guide/optimization_notes/index.rst b/docs/usage_guide/optimization_notes/index.rst index 8eed7f77b7a..52fd9b19bc3 100644 --- a/docs/usage_guide/optimization_notes/index.rst +++ b/docs/usage_guide/optimization_notes/index.rst @@ -196,7 +196,7 @@ Use Modin's Dataframe Algebra API to implement custom parallel functions Modin provides a set of low-level parallel-implemented operators which can be used to build most of the aggregation functions. These operators are present in the :doc:`algebra module `. Modin DataFrame allows users to use their own aggregations built with this module. Visit the -:doc:`appropriate section ` of the documentation for the steps to do it. +:doc:`DataFrame's algebra ` page of the documentation for the steps to do it. Avoid mixing pandas and Modin DataFrames """""""""""""""""""""""""""""""""""""""" From 4531ae1a00e51134fba6e8b599f77255101898ef Mon Sep 17 00:00:00 2001 From: Andrey Pavlenko Date: Mon, 2 Oct 2023 16:48:36 +0200 Subject: [PATCH 031/201] PERF-#6614: HDK: Use `MODIN_CPUS` instead of `os.cpu_count()` for the fragment size calculation (#6615) Signed-off-by: Andrey Pavlenko --- .../native/implementations/hdk_on_native/hdk_worker.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/modin/experimental/core/execution/native/implementations/hdk_on_native/hdk_worker.py b/modin/experimental/core/execution/native/implementations/hdk_on_native/hdk_worker.py index aad566ef8e0..4db43ffa194 100644 --- a/modin/experimental/core/execution/native/implementations/hdk_on_native/hdk_worker.py +++ b/modin/experimental/core/execution/native/implementations/hdk_on_native/hdk_worker.py @@ -12,7 +12,6 @@ # governing permissions and limitations under the License. """Module provides ``HdkWorker`` class.""" -import os from typing import List, Optional, Tuple, Union import pyarrow as pa @@ -20,7 +19,7 @@ from packaging import version from pyhdk.hdk import HDK, ExecutionResult, QueryNode, RelAlgExecutor -from modin.config import HdkFragmentSize, HdkLaunchParameters +from modin.config import CpuCount, HdkFragmentSize, HdkLaunchParameters from modin.utils import _inherit_docstrings from .base_worker import BaseDbWorker, DbTable @@ -143,7 +142,7 @@ def compute_fragment_size(cls, table): fragment_size = HdkFragmentSize.get() if fragment_size is None: if cls._preferred_device == "CPU": - cpu_count = os.cpu_count() + cpu_count = CpuCount.get() if cpu_count is not None: fragment_size = table.num_rows // cpu_count fragment_size = min(fragment_size, 2**25) From 2087e18e7d333739d8c0b11af277b1abe9889182 Mon Sep 17 00:00:00 2001 From: Anatoly Myachev Date: Tue, 3 Oct 2023 10:56:10 +0200 Subject: [PATCH 032/201] REFACTOR-#6622: don't use deprecated 'random_integers' func (#6623) Signed-off-by: Anatoly Myachev --- modin/pandas/test/dataframe/test_map_metadata.py | 6 +++--- modin/pandas/test/test_series.py | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/modin/pandas/test/dataframe/test_map_metadata.py b/modin/pandas/test/dataframe/test_map_metadata.py index dd86c8966cc..0decc17faa7 100644 --- a/modin/pandas/test/dataframe/test_map_metadata.py +++ b/modin/pandas/test/dataframe/test_map_metadata.py @@ -863,7 +863,7 @@ def test_clip(request, data, axis, bound_type): else len(modin_df.columns) ) # set bounds - lower, upper = np.sort(random_state.random_integers(RAND_LOW, RAND_HIGH, 2)) + lower, upper = np.sort(random_state.randint(RAND_LOW, RAND_HIGH, 2)) # test only upper scalar bound modin_result = modin_df.clip(None, upper, axis=axis) @@ -875,8 +875,8 @@ def test_clip(request, data, axis, bound_type): pandas_result = pandas_df.clip(lower, upper, axis=axis) df_equals(modin_result, pandas_result) - lower = random_state.random_integers(RAND_LOW, RAND_HIGH, ind_len) - upper = random_state.random_integers(RAND_LOW, RAND_HIGH, ind_len) + lower = random_state.randint(RAND_LOW, RAND_HIGH, ind_len) + upper = random_state.randint(RAND_LOW, RAND_HIGH, ind_len) if bound_type == "series": modin_lower = pd.Series(lower) diff --git a/modin/pandas/test/test_series.py b/modin/pandas/test/test_series.py index 1749cd4d6a2..c5511c9c2ab 100644 --- a/modin/pandas/test/test_series.py +++ b/modin/pandas/test/test_series.py @@ -1252,7 +1252,7 @@ def test_clip_scalar(request, data, bound_type): if name_contains(request.node.name, numeric_dfs): # set bounds - lower, upper = np.sort(random_state.random_integers(RAND_LOW, RAND_HIGH, 2)) + lower, upper = np.sort(random_state.randint(RAND_LOW, RAND_HIGH, 2)) # test only upper scalar bound modin_result = modin_series.clip(None, upper) @@ -1273,8 +1273,8 @@ def test_clip_sequence(request, data, bound_type): ) if name_contains(request.node.name, numeric_dfs): - lower = random_state.random_integers(RAND_LOW, RAND_HIGH, len(pandas_series)) - upper = random_state.random_integers(RAND_LOW, RAND_HIGH, len(pandas_series)) + lower = random_state.randint(RAND_LOW, RAND_HIGH, len(pandas_series)) + upper = random_state.randint(RAND_LOW, RAND_HIGH, len(pandas_series)) if bound_type == "series": modin_lower = pd.Series(lower) From 77b8d367962766ad5cfe45a2a0981827a6508214 Mon Sep 17 00:00:00 2001 From: Anatoly Myachev Date: Tue, 3 Oct 2023 10:56:42 +0200 Subject: [PATCH 033/201] FIX-#6585: avoid `FutureWarning`s in `rolling` unless necessary (#6586) Signed-off-by: Anatoly Myachev --- .../storage_formats/pandas/query_compiler.py | 4 +- modin/pandas/test/conftest.py | 4 +- modin/pandas/test/test_rolling.py | 41 +++++++++++++++---- 3 files changed, 38 insertions(+), 11 deletions(-) diff --git a/modin/core/storage_formats/pandas/query_compiler.py b/modin/core/storage_formats/pandas/query_compiler.py index 9feb32f4919..aef305d6c17 100644 --- a/modin/core/storage_formats/pandas/query_compiler.py +++ b/modin/core/storage_formats/pandas/query_compiler.py @@ -1519,9 +1519,9 @@ def expanding_corr( ) ) rolling_quantile = Fold.register( - lambda df, rolling_kwargs, quantile, interpolation, **kwargs: pandas.DataFrame( + lambda df, rolling_kwargs, q, interpolation, **kwargs: pandas.DataFrame( df.rolling(**rolling_kwargs).quantile( - quantile=quantile, interpolation=interpolation, **kwargs + q=q, interpolation=interpolation, **kwargs ) ) ) diff --git a/modin/pandas/test/conftest.py b/modin/pandas/test/conftest.py index 56e5de2b8c8..b50769a7257 100644 --- a/modin/pandas/test/conftest.py +++ b/modin/pandas/test/conftest.py @@ -24,8 +24,8 @@ def pytest_collection_modifyitems(items): ): for item in items: if item.name in ( - "test_dataframe_dt_index[3s-both-DateCol-0]", - "test_dataframe_dt_index[3s-right-DateCol-0]", + "test_dataframe_dt_index[3s-both-DateCol-_NoDefault.no_default]", + "test_dataframe_dt_index[3s-right-DateCol-_NoDefault.no_default]", ): item.add_marker( pytest.mark.xfail( diff --git a/modin/pandas/test/test_rolling.py b/modin/pandas/test/test_rolling.py index a3181cc5b53..687bd59aa94 100644 --- a/modin/pandas/test/test_rolling.py +++ b/modin/pandas/test/test_rolling.py @@ -13,6 +13,7 @@ import numpy as np import pandas +import pandas._libs.lib as lib import pytest import modin.pandas as pd @@ -34,7 +35,19 @@ # have too many such instances. # TODO(https://github.com/modin-project/modin/issues/3655): catch all instances # of defaulting to pandas. -pytestmark = pytest.mark.filterwarnings(default_to_pandas_ignore_string) +pytestmark = [ + pytest.mark.filterwarnings(default_to_pandas_ignore_string), + # TO MAKE SURE ALL FUTUREWARNINGS ARE CONSIDERED + pytest.mark.filterwarnings("error::FutureWarning"), + # IGNORE FUTUREWARNINGS MARKS TO CLEANUP OUTPUT + pytest.mark.filterwarnings( + "ignore:Support for axis=1 in DataFrame.rolling is deprecated:FutureWarning" + ), + # FIXME: these cases inconsistent between modin and pandas + pytest.mark.filterwarnings( + "ignore:.*In a future version of pandas, the provided callable will be used directly.*:FutureWarning" + ), +] def create_test_series(vals): @@ -50,7 +63,7 @@ def create_test_series(vals): @pytest.mark.parametrize("data", test_data_values, ids=test_data_keys) @pytest.mark.parametrize("window", [5, 100]) @pytest.mark.parametrize("min_periods", [None, 5]) -@pytest.mark.parametrize("axis", [0, 1]) +@pytest.mark.parametrize("axis", [lib.no_default, 1]) @pytest.mark.parametrize( "method, kwargs", [ @@ -94,7 +107,7 @@ def test_dataframe_rolling(data, window, min_periods, axis, method, kwargs): @pytest.mark.parametrize("data", test_data_values, ids=test_data_keys) @pytest.mark.parametrize("window", [5, 100]) @pytest.mark.parametrize("min_periods", [None, 5]) -@pytest.mark.parametrize("axis", [0, 1]) +@pytest.mark.parametrize("axis", [lib.no_default, 1]) def test_dataframe_agg(data, window, min_periods, axis): modin_df, pandas_df = create_test_dfs(data) if window > len(pandas_df): @@ -119,7 +132,7 @@ def test_dataframe_agg(data, window, min_periods, axis): @pytest.mark.parametrize("data", test_data_values, ids=test_data_keys) @pytest.mark.parametrize("window", [5, 100]) @pytest.mark.parametrize("min_periods", [None, 5]) -@pytest.mark.parametrize("axis", [0, 1]) +@pytest.mark.parametrize("axis", [lib.no_default, 1]) @pytest.mark.parametrize( "method, kwargs", [ @@ -150,7 +163,7 @@ def test_dataframe_window(data, window, min_periods, axis, method, kwargs): ) -@pytest.mark.parametrize("axis", [0, "columns"]) +@pytest.mark.parametrize("axis", [lib.no_default, "columns"]) @pytest.mark.parametrize("on", [None, "DateCol"]) @pytest.mark.parametrize("closed", ["both", "right"]) @pytest.mark.parametrize("window", [3, "3s"]) @@ -159,7 +172,7 @@ def test_dataframe_dt_index(axis, on, closed, window): data = {"A": range(12), "B": range(12)} pandas_df = pandas.DataFrame(data, index=index) modin_df = pd.DataFrame(data, index=index) - if on is not None and axis == 0 and isinstance(window, str): + if on is not None and axis == lib.no_default and isinstance(window, str): pandas_df[on] = pandas.date_range("22/06/1941", periods=12, freq="T") modin_df[on] = pd.date_range("22/06/1941", periods=12, freq="T") else: @@ -181,7 +194,7 @@ def test_dataframe_dt_index(axis, on, closed, window): df_equals( modin_rolled.cov(modin_df, False), pandas_rolled.cov(pandas_df, False) ) - if axis == 0: + if axis == lib.no_default: df_equals( modin_rolled.cov(modin_df[modin_df.columns[0]], True), pandas_rolled.cov(pandas_df[pandas_df.columns[0]], True), @@ -333,3 +346,17 @@ def test_issue_3512(): pandas_ans = pandas_df[0:33].rolling(window=21).mean() df_equals(modin_ans, pandas_ans) + + +### TEST ROLLING WARNINGS ### + + +def test_rolling_axis_1_depr(): + index = pandas.date_range("31/12/2000", periods=12, freq="T") + data = {"A": range(12), "B": range(12)} + modin_df = pd.DataFrame(data, index=index) + with pytest.warns( + FutureWarning, + match="Support for axis=1 in DataFrame.rolling is deprecated", + ): + modin_df.rolling(window=3, axis=1) From bad7af1eec9999c80da3a2ce6cbb96780b418fea Mon Sep 17 00:00:00 2001 From: Igor Zamyatin Date: Thu, 5 Oct 2023 05:39:44 -0500 Subject: [PATCH 034/201] FIX-#6628: Allow groupby diff for dates (#6631) Signed-off-by: izamyati --- modin/pandas/groupby.py | 11 +++++++++-- modin/pandas/test/test_groupby.py | 15 +++++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/modin/pandas/groupby.py b/modin/pandas/groupby.py index ff708bce6b8..c50e97a596a 100644 --- a/modin/pandas/groupby.py +++ b/modin/pandas/groupby.py @@ -23,7 +23,12 @@ import pandas.core.groupby from pandas._libs import lib from pandas.core.apply import reconstruct_func -from pandas.core.dtypes.common import is_integer, is_list_like, is_numeric_dtype +from pandas.core.dtypes.common import ( + is_datetime64_any_dtype, + is_integer, + is_list_like, + is_numeric_dtype, +) from pandas.errors import SpecificationError from modin.core.dataframe.algebra.default2pandas.groupby import GroupBy @@ -1421,7 +1426,9 @@ def diff(self, periods=1, axis=lib.no_default): for col, dtype in self._df.dtypes.items(): # can't calculate diff on non-numeric columns, so check for non-numeric # columns that are not included in the `by` - if not is_numeric_dtype(dtype) and not ( + if not ( + is_numeric_dtype(dtype) or is_datetime64_any_dtype(dtype) + ) and not ( isinstance(self._by, BaseQueryCompiler) and col in self._by.columns ): raise TypeError(f"unsupported operand type for -: got {dtype}") diff --git a/modin/pandas/test/test_groupby.py b/modin/pandas/test/test_groupby.py index 52aeeae0da3..30ba2c1103a 100644 --- a/modin/pandas/test/test_groupby.py +++ b/modin/pandas/test/test_groupby.py @@ -2726,6 +2726,21 @@ def test_groupby_pct_change_diff_6194(): ) +def test_groupby_datetime_diff_6628(): + dates = pd.date_range(start="2023-01-01", periods=10, freq="W") + df = pd.DataFrame( + { + "date": dates, + "group": "A", + } + ) + eval_general( + df, + df._to_pandas(), + lambda df: df.groupby("group").diff(), + ) + + def eval_rolling(md_window, pd_window): eval_general(md_window, pd_window, lambda window: window.count()) eval_general(md_window, pd_window, lambda window: window.sum()) From 205a1700746de9c8bed2eaf2a4c893201b9559cb Mon Sep 17 00:00:00 2001 From: Andrey Pavlenko Date: Fri, 6 Oct 2023 12:01:29 +0200 Subject: [PATCH 035/201] PERF-#6629: HDK: Avoid LazyProxyCategoricalDtype materialization on merge (#6630) Signed-off-by: Andrey Pavlenko --- .../core/dataframe/pandas/metadata/dtypes.py | 40 ++++++++++++++++++- .../hdk_on_native/dataframe/dataframe.py | 7 +++- .../hdk_on_native/dataframe/utils.py | 17 ++++++++ 3 files changed, 61 insertions(+), 3 deletions(-) diff --git a/modin/core/dataframe/pandas/metadata/dtypes.py b/modin/core/dataframe/pandas/metadata/dtypes.py index c249a3fb497..4cd0463f630 100644 --- a/modin/core/dataframe/pandas/metadata/dtypes.py +++ b/modin/core/dataframe/pandas/metadata/dtypes.py @@ -12,6 +12,7 @@ # governing permissions and limitations under the License. """Module contains class ``ModinDtypes``.""" +from typing import Union import pandas @@ -202,7 +203,7 @@ def _update_proxy(self, parent, column_name): return self._build_proxy(parent, column_name, self._materializer) @classmethod - def _build_proxy(cls, parent, column_name, materializer): + def _build_proxy(cls, parent, column_name, materializer, dtype=None): """ Construct a lazy proxy. @@ -214,6 +215,8 @@ def _build_proxy(cls, parent, column_name, materializer): Column name of the categorical column in the source object. materializer : callable(parent, column_name) -> pandas.CategoricalDtype A function to call in order to extract categorical values. + dtype : dtype, optional + The categories dtype. Returns ------- @@ -223,8 +226,21 @@ def _build_proxy(cls, parent, column_name, materializer): result._parent = parent result._column_name = column_name result._materializer = materializer + result._dtype = dtype return result + def _get_dtype(self): + """ + Get the categories dtype. + + Returns + ------- + dtype + """ + if self._dtype is None: + self._dtype = self.categories.dtype + return self._dtype + def __reduce__(self): """ Serialize an object of this class. @@ -265,6 +281,7 @@ def _categories(self, categories): self._categories_val = categories self._parent = None # The parent is not required any more self._materializer = None + self._dtype = None @property def _is_materialized(self) -> bool: @@ -286,3 +303,24 @@ def _materialize_categories(self): categoricals = self._materializer(self._parent, self._column_name) self._categories = categoricals.categories self._ordered = categoricals.ordered + + +def get_categories_dtype( + cdt: Union[LazyProxyCategoricalDtype, pandas.CategoricalDtype] +): + """ + Get the categories dtype. + + Parameters + ---------- + cdt : LazyProxyCategoricalDtype or pandas.CategoricalDtype + + Returns + ------- + dtype + """ + return ( + cdt._get_dtype() + if isinstance(cdt, LazyProxyCategoricalDtype) + else cdt.categories.dtype + ) diff --git a/modin/experimental/core/execution/native/implementations/hdk_on_native/dataframe/dataframe.py b/modin/experimental/core/execution/native/implementations/hdk_on_native/dataframe/dataframe.py index 082b78d4143..faeb24e8c48 100644 --- a/modin/experimental/core/execution/native/implementations/hdk_on_native/dataframe/dataframe.py +++ b/modin/experimental/core/execution/native/implementations/hdk_on_native/dataframe/dataframe.py @@ -38,6 +38,7 @@ ) from modin.core.dataframe.pandas.dataframe.dataframe import PandasDataframe from modin.core.dataframe.pandas.metadata import LazyProxyCategoricalDtype +from modin.core.dataframe.pandas.metadata.dtypes import get_categories_dtype from modin.core.dataframe.pandas.utils import concatenate from modin.error_message import ErrorMessage from modin.experimental.core.storage_formats.hdk.query_compiler import ( @@ -74,6 +75,7 @@ from .utils import ( ColNameCodec, arrow_to_pandas, + arrow_type_to_pandas, build_categorical_from_at, check_cols_to_join, check_join_supported, @@ -1105,8 +1107,8 @@ def join( if isinstance(left_dt, pd.CategoricalDtype) and isinstance( right_dt, pd.CategoricalDtype ): - left_dt = left_dt.categories.dtype - right_dt = right_dt.categories.dtype + left_dt = get_categories_dtype(left_dt) + right_dt = get_categories_dtype(right_dt) if not ( (is_integer_dtype(left_dt) and is_integer_dtype(right_dt)) or (is_string_dtype(left_dt) and is_string_dtype(right_dt)) @@ -2847,6 +2849,7 @@ def from_arrow( parent=at, column_name=col._name, materializer=build_categorical_from_at, + dtype=arrow_type_to_pandas(col.type.value_type), ) ) else: diff --git a/modin/experimental/core/execution/native/implementations/hdk_on_native/dataframe/utils.py b/modin/experimental/core/execution/native/implementations/hdk_on_native/dataframe/utils.py index 45cf0a64b5e..daef14a6b4b 100644 --- a/modin/experimental/core/execution/native/implementations/hdk_on_native/dataframe/utils.py +++ b/modin/experimental/core/execution/native/implementations/hdk_on_native/dataframe/utils.py @@ -565,6 +565,23 @@ def mapper(at): return df +def arrow_type_to_pandas(at: pa.lib.DataType): + """ + Convert the specified arrow type to pandas dtype. + + Parameters + ---------- + at : pa.lib.DataType + + Returns + ------- + dtype + """ + if at == pa.string(): + return _get_dtype(str) + return at.to_pandas_dtype() + + class _CategoricalDtypeMapper: # noqa: GL08 @staticmethod def __from_arrow__(arr): # noqa: GL08 From 762a00f68c9fc8c65cbec9b5bf5938831524ecd1 Mon Sep 17 00:00:00 2001 From: Dmitry Chigarev Date: Sun, 8 Oct 2023 20:26:34 +0300 Subject: [PATCH 036/201] PERF-#6362: Implement 2D setitem without to-pandas conversion (#6618) Signed-off-by: Dmitry Chigarev Co-authored-by: Anatoly Myachev --- modin/pandas/dataframe.py | 45 +++++++++++++++++--- modin/pandas/test/dataframe/test_indexing.py | 38 +++++++++++++++++ 2 files changed, 77 insertions(+), 6 deletions(-) diff --git a/modin/pandas/dataframe.py b/modin/pandas/dataframe.py index 1cee5e62c14..aa1fc8841ed 100644 --- a/modin/pandas/dataframe.py +++ b/modin/pandas/dataframe.py @@ -2570,12 +2570,45 @@ def __setitem__(self, key, value): value = np.array(value) if len(key) != value.shape[-1]: raise ValueError("Columns must be same length as key") - new_qc = self._query_compiler.write_items( - slice(None), - self.columns.get_indexer_for(key), - value, - need_columns_reindex=False, - ) + if isinstance(value, type(self)): + # importing here to avoid circular import + from .general import concat + + if not value.columns.equals(pandas.Index(key)): + # we only need to change the labels, so shallow copy here + value = value.copy(deep=False) + value.columns = key + + # here we iterate over every column in the 'self' frame, then check if it's in the 'key' + # and so has to be taken from either from the 'value' or from the 'self'. After that, + # we concatenate those mixed column chunks and get a dataframe with updated columns + to_concat = [] + # columns to take for this chunk + to_take = [] + # whether columns in this chunk are in the 'key' and has to be taken from the 'value' + get_cols_from_value = False + # an object to take columns from for this chunk + src_obj = self + for col in self.columns: + if (col in key) != get_cols_from_value: + if len(to_take): + to_concat.append(src_obj[to_take]) + to_take = [col] + get_cols_from_value = not get_cols_from_value + src_obj = value if get_cols_from_value else self + else: + to_take.append(col) + if len(to_take): + to_concat.append(src_obj[to_take]) + + new_qc = concat(to_concat, axis=1)._query_compiler + else: + new_qc = self._query_compiler.write_items( + slice(None), + self.columns.get_indexer_for(key), + value, + need_columns_reindex=False, + ) self._update_inplace(new_qc) # self.loc[:, key] = value return diff --git a/modin/pandas/test/dataframe/test_indexing.py b/modin/pandas/test/dataframe/test_indexing.py index 3fba4be7d62..642766922f1 100644 --- a/modin/pandas/test/dataframe/test_indexing.py +++ b/modin/pandas/test/dataframe/test_indexing.py @@ -2388,6 +2388,44 @@ def build_value_picker(modin_value, pandas_value): ) +@pytest.mark.parametrize("does_value_have_different_columns", [True, False]) +def test_setitem_2d_update(does_value_have_different_columns): + def test(dfs, iloc): + """Update columns on the given numeric indices.""" + df1, df2 = dfs + cols1 = df1.columns[iloc].tolist() + cols2 = df2.columns[iloc].tolist() + df1[cols1] = df2[cols2] + return df1 + + modin_df, pandas_df = create_test_dfs(test_data["int_data"]) + modin_df2, pandas_df2 = create_test_dfs(test_data["int_data"]) + modin_df2 *= 10 + pandas_df2 *= 10 + + if does_value_have_different_columns: + new_columns = [f"{col}_new" for col in modin_df.columns] + modin_df2.columns = new_columns + pandas_df2.columns = new_columns + + modin_dfs = (modin_df, modin_df2) + pandas_dfs = (pandas_df, pandas_df2) + + eval_general(modin_dfs, pandas_dfs, test, iloc=[0, 1, 2]) + eval_general(modin_dfs, pandas_dfs, test, iloc=[0, -1]) + eval_general( + modin_dfs, pandas_dfs, test, iloc=slice(1, None) + ) # (start=1, stop=None) + eval_general( + modin_dfs, pandas_dfs, test, iloc=slice(None, -2) + ) # (start=None, stop=-2) + eval_general(modin_dfs, pandas_dfs, test, iloc=[0, 1, 5, 6, 9, 10, -2, -1]) + eval_general(modin_dfs, pandas_dfs, test, iloc=[5, 4, 0, 10, 1, -1]) + eval_general( + modin_dfs, pandas_dfs, test, iloc=slice(None, None, 2) + ) # (start=None, stop=None, step=2) + + def test___setitem__single_item_in_series(): # Test assigning a single item in a Series for issue # https://github.com/modin-project/modin/issues/3860 From bc7f7d29a512902f749da59e4085de49578ed25a Mon Sep 17 00:00:00 2001 From: Andrey Pavlenko Date: Mon, 9 Oct 2023 16:36:06 +0200 Subject: [PATCH 037/201] FIX-#6635: HDK: read_csv(): treat object dtype as string (#6636) Signed-off-by: Andrey Pavlenko --- .../native/implementations/hdk_on_native/io/io.py | 2 +- .../hdk_on_native/test/test_dataframe.py | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/modin/experimental/core/execution/native/implementations/hdk_on_native/io/io.py b/modin/experimental/core/execution/native/implementations/hdk_on_native/io/io.py index 0579a13a191..332f53f12af 100644 --- a/modin/experimental/core/execution/native/implementations/hdk_on_native/io/io.py +++ b/modin/experimental/core/execution/native/implementations/hdk_on_native/io/io.py @@ -290,7 +290,7 @@ def _dtype_to_arrow(cls, dtype): tname = dtype if isinstance(dtype, str) else dtype.name if tname == "category": return pa.dictionary(index_type=pa.int32(), value_type=pa.string()) - elif tname == "string": + elif tname == "string" or tname == "object": return pa.string() else: return pa.from_numpy_dtype(tname) diff --git a/modin/experimental/core/execution/native/implementations/hdk_on_native/test/test_dataframe.py b/modin/experimental/core/execution/native/implementations/hdk_on_native/test/test_dataframe.py index b4e98e1e77f..131f828fa32 100644 --- a/modin/experimental/core/execution/native/implementations/hdk_on_native/test/test_dataframe.py +++ b/modin/experimental/core/execution/native/implementations/hdk_on_native/test/test_dataframe.py @@ -391,6 +391,19 @@ def test(df, lib, **kwargs): run_and_compare(test, data={}) + def test_read_csv_dtype_object(self): + with pytest.warns(UserWarning) as warns: + with ensure_clean(".csv") as file: + with open(file, "w") as f: + f.write("test\ntest") + + def test(**kwargs): + return pd.read_csv(file, dtype={"test": "object"}) + + run_and_compare(test, data={}) + for warn in warns.list: + assert not re.match(r".*defaulting to pandas.*", str(warn)) + class TestMasks: data = { From a01ce302a47c6949b582b3d444cf781bacfd7116 Mon Sep 17 00:00:00 2001 From: Anatoly Myachev Date: Mon, 9 Oct 2023 17:23:54 +0200 Subject: [PATCH 038/201] FIX-#6637: Fix 'skiprows' parameter usage for 'read_excel' (#6638) Signed-off-by: Anatoly Myachev --- modin/core/io/text/excel_dispatcher.py | 7 +++++++ modin/core/storage_formats/pandas/parsers.py | 3 ++- modin/pandas/test/test_io.py | 11 +++++++++++ 3 files changed, 20 insertions(+), 1 deletion(-) diff --git a/modin/core/io/text/excel_dispatcher.py b/modin/core/io/text/excel_dispatcher.py index c18f4034267..96628ba52e6 100644 --- a/modin/core/io/text/excel_dispatcher.py +++ b/modin/core/io/text/excel_dispatcher.py @@ -59,6 +59,13 @@ def _read(cls, io, **kwargs): **kwargs ) + if kwargs.get("skiprows") is not None: + return cls.single_worker_read( + io, + reason="Modin doesn't support 'skiprows' parameter of `read_excel`", + **kwargs + ) + if isinstance(io, bytes): io = BytesIO(io) diff --git a/modin/core/storage_formats/pandas/parsers.py b/modin/core/storage_formats/pandas/parsers.py index e131ddc02de..826d546ffd8 100644 --- a/modin/core/storage_formats/pandas/parsers.py +++ b/modin/core/storage_formats/pandas/parsers.py @@ -580,7 +580,6 @@ def parse(fname, **kwargs): num_splits = kwargs.pop("num_splits", None) start = kwargs.pop("start", None) end = kwargs.pop("end", None) - _skiprows = kwargs.pop("skiprows") excel_header = kwargs.get("_header") sheet_name = kwargs.get("sheet_name", 0) footer = b"" @@ -589,6 +588,8 @@ def parse(fname, **kwargs): if start is None or end is None: return pandas.read_excel(fname, **kwargs) + _skiprows = kwargs.pop("skiprows") + import re from zipfile import ZipFile diff --git a/modin/pandas/test/test_io.py b/modin/pandas/test/test_io.py index c1e6ad2c83c..323f3c016d5 100644 --- a/modin/pandas/test/test_io.py +++ b/modin/pandas/test/test_io.py @@ -2197,6 +2197,17 @@ def test_read_excel(self, pathlike, make_excel_file): io=Path(unique_filename) if pathlike else unique_filename, ) + @check_file_leaks + @pytest.mark.parametrize("skiprows", [2, [1, 3], lambda x: x in [0, 2]]) + def test_read_excel_skiprows(self, skiprows, make_excel_file): + eval_io( + fn_name="read_excel", + # read_excel kwargs + io=make_excel_file(), + skiprows=skiprows, + check_kwargs_callable=False, + ) + @check_file_leaks @pytest.mark.parametrize( "dtype_backend", [lib.no_default, "numpy_nullable", "pyarrow"] From 3fe050d222acbf5e743ab1f22628adfeb97f534f Mon Sep 17 00:00:00 2001 From: Anatoly Myachev Date: Mon, 9 Oct 2023 18:34:19 +0200 Subject: [PATCH 039/201] TEST-#5489: Allow for pytest to print warnings in tests output (#6621) Signed-off-by: Anatoly Myachev --- setup.cfg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.cfg b/setup.cfg index 28eca736e45..8f479523bec 100644 --- a/setup.cfg +++ b/setup.cfg @@ -12,7 +12,7 @@ tag_prefix = parentdir_prefix = modin- [tool:pytest] -addopts = --disable-pytest-warnings --cov-config=setup.cfg --cov=modin --cov-append --cov-report= +addopts = --cov-config=setup.cfg --cov=modin --cov-append --cov-report= xfail_strict=true markers = xfail_executions From b608ea056ef164da4730a5db5b0ac9a82e6e803c Mon Sep 17 00:00:00 2001 From: Anatoly Myachev Date: Mon, 9 Oct 2023 20:08:48 +0200 Subject: [PATCH 040/201] FIX-#6624: Add `FutureWarning`s for `first/last/bool` (#6625) Signed-off-by: Anatoly Myachev --- modin/pandas/base.py | 16 +++++++++++ modin/pandas/test/dataframe/test_default.py | 31 +++++++++++++++++---- modin/pandas/test/test_series.py | 29 +++++++++++++++---- 3 files changed, 65 insertions(+), 11 deletions(-) diff --git a/modin/pandas/base.py b/modin/pandas/base.py index 0c16fc67e0e..b730d38ca6b 100644 --- a/modin/pandas/base.py +++ b/modin/pandas/base.py @@ -1130,6 +1130,11 @@ def bool(self): # noqa: RT01, D200 """ Return the bool of a single element `BasePandasDataset`. """ + warnings.warn( + f"{type(self).__name__}.bool is now deprecated and will be removed " + + "in future version of pandas", + FutureWarning, + ) shape = self.shape if shape != (1,) and shape != (1, 1): raise ValueError( @@ -1757,6 +1762,11 @@ def first(self, offset): # noqa: PR01, RT01, D200 """ Select initial periods of time series data based on a date offset. """ + warnings.warn( + "first is deprecated and will be removed in a future version. " + + "Please create a mask and filter using `.loc` instead", + FutureWarning, + ) return self._create_or_update_from_compiler( self._query_compiler.first(offset=to_offset(offset)) ) @@ -1913,6 +1923,12 @@ def last(self, offset): # noqa: PR01, RT01, D200 """ Select final periods of time series data based on a date offset. """ + warnings.warn( + "last is deprecated and will be removed in a future version. " + + "Please create a mask and filter using `.loc` instead", + FutureWarning, + ) + return self._create_or_update_from_compiler( self._query_compiler.last(offset=to_offset(offset)) ) diff --git a/modin/pandas/test/dataframe/test_default.py b/modin/pandas/test/dataframe/test_default.py index ab200e0375c..b3eca75ef1c 100644 --- a/modin/pandas/test/dataframe/test_default.py +++ b/modin/pandas/test/dataframe/test_default.py @@ -53,7 +53,19 @@ # Our configuration in pytest.ini requires that we explicitly catch all # instances of defaulting to pandas, but some test modules, like this one, # have too many such instances. -pytestmark = pytest.mark.filterwarnings(default_to_pandas_ignore_string) +pytestmark = [ + pytest.mark.filterwarnings(default_to_pandas_ignore_string), + # IGNORE FUTUREWARNINGS MARKS TO CLEANUP OUTPUT + pytest.mark.filterwarnings( + "ignore:.*bool is now deprecated and will be removed:FutureWarning" + ), + pytest.mark.filterwarnings( + "ignore:first is deprecated and will be removed:FutureWarning" + ), + pytest.mark.filterwarnings( + "ignore:last is deprecated and will be removed:FutureWarning" + ), +] @pytest.mark.parametrize( @@ -188,9 +200,12 @@ def test_bfill(data): def test_bool(data): modin_df = pd.DataFrame(data) - with pytest.raises(ValueError): - modin_df.bool() - modin_df.__bool__() + with pytest.warns( + FutureWarning, match="bool is now deprecated and will be removed" + ): + with pytest.raises(ValueError): + modin_df.bool() + modin_df.__bool__() single_bool_pandas_df = pandas.DataFrame([True]) single_bool_modin_df = pd.DataFrame([True]) @@ -446,7 +461,9 @@ def test_first(): pandas_df = pandas.DataFrame( {"A": list(range(400)), "B": list(range(400))}, index=i ) - df_equals(modin_df.first("3D"), pandas_df.first("3D")) + with pytest.warns(FutureWarning, match="first is deprecated and will be removed"): + modin_result = modin_df.first("3D") + df_equals(modin_result, pandas_df.first("3D")) df_equals(modin_df.first("20D"), pandas_df.first("20D")) @@ -522,7 +539,9 @@ def test_last(): pandas_df = pandas.DataFrame( {"A": list(range(400)), "B": list(range(400))}, index=pandas_index ) - df_equals(modin_df.last("3D"), pandas_df.last("3D")) + with pytest.warns(FutureWarning, match="last is deprecated and will be removed"): + modin_result = modin_df.last("3D") + df_equals(modin_result, pandas_df.last("3D")) df_equals(modin_df.last("20D"), pandas_df.last("20D")) diff --git a/modin/pandas/test/test_series.py b/modin/pandas/test/test_series.py index c5511c9c2ab..525cb44a21b 100644 --- a/modin/pandas/test/test_series.py +++ b/modin/pandas/test/test_series.py @@ -89,7 +89,19 @@ # have too many such instances. # TODO(https://github.com/modin-project/modin/issues/3655): catch all instances # of defaulting to pandas. -pytestmark = pytest.mark.filterwarnings(default_to_pandas_ignore_string) +pytestmark = [ + pytest.mark.filterwarnings(default_to_pandas_ignore_string), + # IGNORE FUTUREWARNINGS MARKS TO CLEANUP OUTPUT + pytest.mark.filterwarnings( + "ignore:.*bool is now deprecated and will be removed:FutureWarning" + ), + pytest.mark.filterwarnings( + "ignore:first is deprecated and will be removed:FutureWarning" + ), + pytest.mark.filterwarnings( + "ignore:last is deprecated and will be removed:FutureWarning" + ), +] NPartitions.put(4) @@ -1237,8 +1249,11 @@ def test_bfill(data): def test_bool(data): modin_series, _ = create_test_series(data) - with pytest.raises(ValueError): - modin_series.bool() + with pytest.warns( + FutureWarning, match="bool is now deprecated and will be removed" + ): + with pytest.raises(ValueError): + modin_series.bool() with pytest.raises(ValueError): modin_series.__bool__() @@ -2034,7 +2049,9 @@ def test_first(): i = pd.date_range("2010-04-09", periods=400, freq="2D") modin_series = pd.Series(list(range(400)), index=i) pandas_series = pandas.Series(list(range(400)), index=i) - df_equals(modin_series.first("3D"), pandas_series.first("3D")) + with pytest.warns(FutureWarning, match="first is deprecated and will be removed"): + modin_result = modin_series.first("3D") + df_equals(modin_result, pandas_series.first("3D")) df_equals(modin_series.first("20D"), pandas_series.first("20D")) @@ -2277,7 +2294,9 @@ def test_last(): pandas_index = pandas.date_range("2010-04-09", periods=400, freq="2D") modin_series = pd.Series(list(range(400)), index=modin_index) pandas_series = pandas.Series(list(range(400)), index=pandas_index) - df_equals(modin_series.last("3D"), pandas_series.last("3D")) + with pytest.warns(FutureWarning, match="last is deprecated and will be removed"): + modin_result = modin_series.last("3D") + df_equals(modin_result, pandas_series.last("3D")) df_equals(modin_series.last("20D"), pandas_series.last("20D")) From c60087eec949b5f8a138ac2a58b06522799753e6 Mon Sep 17 00:00:00 2001 From: Anatoly Myachev Date: Wed, 11 Oct 2023 18:58:40 +0200 Subject: [PATCH 041/201] FIX-#6642: fix 'modin.numpy.array.sum' on HDK (#6643) Signed-off-by: Anatoly Myachev --- modin/experimental/core/storage_formats/hdk/query_compiler.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modin/experimental/core/storage_formats/hdk/query_compiler.py b/modin/experimental/core/storage_formats/hdk/query_compiler.py index 8c9b0c56fc1..439af6246d5 100644 --- a/modin/experimental/core/storage_formats/hdk/query_compiler.py +++ b/modin/experimental/core/storage_formats/hdk/query_compiler.py @@ -399,7 +399,7 @@ def min(self, **kwargs): return self._agg("min", **kwargs) def sum(self, **kwargs): - min_count = kwargs.pop("min_count") + min_count = kwargs.pop("min_count", 0) if min_count != 0: raise NotImplementedError( f"HDK's sum does not support such set of parameters: min_count={min_count}." From b0ef411df7dec628d4d9fc035f60c9e74895518f Mon Sep 17 00:00:00 2001 From: Dmitry Chigarev Date: Wed, 11 Oct 2023 23:34:45 +0300 Subject: [PATCH 042/201] FIX-#4507: Do not call 'ray.get()' inside of the kernel executing call queues (#6633) Signed-off-by: Dmitry Chigarev --- modin/core/execution/ray/common/utils.py | 108 ++++++++++++++++ .../pandas_on_ray/partitioning/partition.py | 39 ++++-- .../partitioning/virtual_partition.py | 62 +++++---- .../storage_formats/pandas/test_internals.py | 121 ++++++++++++++++++ 4 files changed, 292 insertions(+), 38 deletions(-) diff --git a/modin/core/execution/ray/common/utils.py b/modin/core/execution/ray/common/utils.py index 3b168c6f044..bca48f97473 100644 --- a/modin/core/execution/ray/common/utils.py +++ b/modin/core/execution/ray/common/utils.py @@ -286,3 +286,111 @@ def deserialize(obj): # pragma: no cover return dict(zip(obj.keys(), RayWrapper.materialize(list(obj.values())))) else: return obj + + +def deconstruct_call_queue(call_queue): + """ + Deconstruct the passed call queue into a 1D list. + + This is required, so the call queue can be then passed to a Ray's kernel + as a variable-length argument ``kernel(*queue)`` so the Ray engine + automatically materialize all the futures that the queue might have contained. + + Parameters + ---------- + call_queue : list[list[func, args, kwargs], ...] + + Returns + ------- + num_funcs : int + The number of functions in the call queue. + arg_lengths : list of ints + The number of positional arguments for each function in the call queue. + kw_key_lengths : list of ints + The number of key-word arguments for each function in the call queue. + kw_value_lengths : 2D list of dict{"len": int, "was_iterable": bool} + Description of keyword arguments for each function. For example, `kw_value_lengths[i][j]` + describes the j-th keyword argument of the i-th function in the call queue. + The describtion contains of the lengths of the argument and whether it's a list at all + (for example, {"len": 1, "was_iterable": False} describes a non-list argument). + unfolded_queue : list + A 1D call queue that can be reconstructed using ``reconstruct_call_queue`` function. + """ + num_funcs = len(call_queue) + arg_lengths = [] + kw_key_lengths = [] + kw_value_lengths = [] + unfolded_queue = [] + for call in call_queue: + unfolded_queue.append(call[0]) + unfolded_queue.extend(call[1]) + arg_lengths.append(len(call[1])) + # unfold keyword dict + ## unfold keys + unfolded_queue.extend(call[2].keys()) + kw_key_lengths.append(len(call[2])) + ## unfold values + value_lengths = [] + for value in call[2].values(): + was_iterable = True + if not isinstance(value, (list, tuple)): + was_iterable = False + value = (value,) + unfolded_queue.extend(value) + value_lengths.append({"len": len(value), "was_iterable": was_iterable}) + kw_value_lengths.append(value_lengths) + + return num_funcs, arg_lengths, kw_key_lengths, kw_value_lengths, *unfolded_queue + + +def reconstruct_call_queue( + num_funcs, arg_lengths, kw_key_lengths, kw_value_lengths, unfolded_queue +): + """ + Reconstruct original call queue from the result of the ``deconstruct_call_queue()``. + + Parameters + ---------- + num_funcs : int + The number of functions in the call queue. + arg_lengths : list of ints + The number of positional arguments for each function in the call queue. + kw_key_lengths : list of ints + The number of key-word arguments for each function in the call queue. + kw_value_lengths : 2D list of dict{"len": int, "was_iterable": bool} + Description of keyword arguments for each function. For example, `kw_value_lengths[i][j]` + describes the j-th keyword argument of the i-th function in the call queue. + The describtion contains of the lengths of the argument and whether it's a list at all + (for example, {"len": 1, "was_iterable": False} describes a non-list argument). + unfolded_queue : list + A 1D call queue that is result of the ``deconstruct_call_queue()`` function. + + Returns + ------- + list[list[func, args, kwargs], ...] + Original call queue. + """ + items_took = 0 + + def take_n_items(n): + nonlocal items_took + res = unfolded_queue[items_took : items_took + n] + items_took += n + return res + + call_queue = [] + for i in range(num_funcs): + func = take_n_items(1)[0] + args = take_n_items(arg_lengths[i]) + kw_keys = take_n_items(kw_key_lengths[i]) + kwargs = {} + value_lengths = kw_value_lengths[i] + for key, value_length in zip(kw_keys, value_lengths): + vals = take_n_items(value_length["len"]) + if value_length["len"] == 1 and not value_length["was_iterable"]: + vals = vals[0] + kwargs[key] = vals + + call_queue.append((func, args, kwargs)) + + return call_queue diff --git a/modin/core/execution/ray/implementations/pandas_on_ray/partitioning/partition.py b/modin/core/execution/ray/implementations/pandas_on_ray/partitioning/partition.py index 380095f7fbc..339f6bf64fc 100644 --- a/modin/core/execution/ray/implementations/pandas_on_ray/partitioning/partition.py +++ b/modin/core/execution/ray/implementations/pandas_on_ray/partitioning/partition.py @@ -18,7 +18,11 @@ from modin.core.dataframe.pandas.partitioning.partition import PandasDataframePartition from modin.core.execution.ray.common import RayWrapper -from modin.core.execution.ray.common.utils import ObjectIDType, deserialize +from modin.core.execution.ray.common.utils import ( + ObjectIDType, + deconstruct_call_queue, + reconstruct_call_queue, +) from modin.logging import get_logger from modin.pandas.indexing import compute_sliced_len @@ -97,7 +101,9 @@ def apply(self, func, *args, **kwargs): self._is_debug(log) and log.debug( f"SUBMIT::_apply_list_of_funcs::{self._identity}" ) - result, length, width, ip = _apply_list_of_funcs.remote(call_queue, data) + result, length, width, ip = _apply_list_of_funcs.remote( + data, *deconstruct_call_queue(call_queue) + ) else: # We handle `len(call_queue) == 1` in a different way because # this dramatically improves performance. @@ -128,7 +134,7 @@ def drain_call_queue(self): new_length, new_width, self._ip_cache, - ) = _apply_list_of_funcs.remote(call_queue, data) + ) = _apply_list_of_funcs.remote(data, *deconstruct_call_queue(call_queue)) else: # We handle `len(call_queue) == 1` in a different way because # this dramatically improves performance. @@ -391,16 +397,29 @@ def _apply_func(partition, func, *args, **kwargs): # pragma: no cover @ray.remote(num_returns=4) -def _apply_list_of_funcs(call_queue, partition): # pragma: no cover +def _apply_list_of_funcs( + partition, num_funcs, arg_lengths, kw_key_lengths, kw_value_lengths, *futures +): # pragma: no cover """ Execute all operations stored in the call queue on the partition in a worker process. Parameters ---------- - call_queue : list - A call queue that needs to be executed on the partition. partition : pandas.DataFrame A pandas DataFrame the call queue needs to be executed on. + num_funcs : int + The number of functions in the call queue. + arg_lengths : list of ints + The number of positional arguments for each function in the call queue. + kw_key_lengths : list of ints + The number of key-word arguments for each function in the call queue. + kw_value_lengths : 2D list of dict{"len": int, "was_iterable": bool} + Description of keyword arguments for each function. For example, `kw_value_lengths[i][j]` + describes the j-th keyword argument of the i-th function in the call queue. + The describtion contains of the lengths of the argument and whether it's a list at all + (for example, {"len": 1, "was_iterable": False} describes a non-list argument). + *futures : list + A 1D call queue that is result of the ``deconstruct_call_queue()`` function. Returns ------- @@ -413,10 +432,10 @@ def _apply_list_of_funcs(call_queue, partition): # pragma: no cover str The node IP address of the worker process. """ - for func, f_args, f_kwargs in call_queue: - func = deserialize(func) - args = deserialize(f_args) - kwargs = deserialize(f_kwargs) + call_queue = reconstruct_call_queue( + num_funcs, arg_lengths, kw_key_lengths, kw_value_lengths, futures + ) + for func, args, kwargs in call_queue: try: partition = func(partition, *args, **kwargs) # Sometimes Arrow forces us to make a copy of an object before we operate on it. We diff --git a/modin/core/execution/ray/implementations/pandas_on_ray/partitioning/virtual_partition.py b/modin/core/execution/ray/implementations/pandas_on_ray/partitioning/virtual_partition.py index c04680a1a36..66463ecb2ba 100644 --- a/modin/core/execution/ray/implementations/pandas_on_ray/partitioning/virtual_partition.py +++ b/modin/core/execution/ray/implementations/pandas_on_ray/partitioning/virtual_partition.py @@ -21,7 +21,6 @@ PandasDataframeAxisPartition, ) from modin.core.execution.ray.common import RayWrapper -from modin.core.execution.ray.common.utils import deserialize from modin.utils import _inherit_docstrings from .partition import PandasOnRayDataframePartition @@ -116,12 +115,13 @@ def deploy_splitting_func( else num_splits, ).remote( cls._get_deploy_split_func(), - axis, - func, - f_args, - f_kwargs, + *f_args, num_splits, *partitions, + axis=axis, + f_to_deploy=func, + f_len_args=len(f_args), + f_kwargs=f_kwargs, extract_metadata=extract_metadata, ) @@ -177,13 +177,14 @@ def deploy_axis_func( **({"max_retries": max_retries} if max_retries is not None else {}), ).remote( cls._get_deploy_axis_func(), - axis, - func, - f_args, - f_kwargs, + *f_args, num_splits, maintain_partitioning, *partitions, + axis=axis, + f_to_deploy=func, + f_len_args=len(f_args), + f_kwargs=f_kwargs, manual_partition=manual_partition, lengths=lengths, ) @@ -232,14 +233,15 @@ def deploy_func_between_two_axis_partitions( num_returns=num_splits * (1 + cls._PARTITIONS_METADATA_LEN) ).remote( PandasDataframeAxisPartition.deploy_func_between_two_axis_partitions, - axis, - func, - f_args, - f_kwargs, + *f_args, num_splits, len_of_left, other_shape, *partitions, + axis=axis, + f_to_deploy=func, + f_len_args=len(f_args), + f_kwargs=f_kwargs, ) def wait(self): @@ -262,11 +264,11 @@ class PandasOnRayDataframeRowPartition(PandasOnRayDataframeVirtualPartition): @ray.remote def _deploy_ray_func( deployer, + *positional_args, axis, f_to_deploy, - f_args, + f_len_args, f_kwargs, - *args, extract_metadata=True, **kwargs, ): # pragma: no cover @@ -275,29 +277,32 @@ def _deploy_ray_func( This is ALWAYS called on either ``PandasDataframeAxisPartition.deploy_axis_func`` or ``PandasDataframeAxisPartition.deploy_func_between_two_axis_partitions``, which both - serve to deploy another dataframe function on a Ray worker process. The provided ``f_args`` - is thus are deserialized here (on the Ray worker) before the function is called (``f_kwargs`` - will never contain more Ray objects, and thus does not require deserialization). + serve to deploy another dataframe function on a Ray worker process. The provided `positional_args` + contains positional arguments for both: `deployer` and for `f_to_deploy`, the parameters can be separated + using the `f_len_args` value. The parameters are combined so they will be deserialized by Ray before the + kernel is executed (`f_kwargs` will never contain more Ray objects, and thus does not require deserialization). Parameters ---------- deployer : callable A `PandasDataFrameAxisPartition.deploy_*` method that will call ``f_to_deploy``. + *positional_args : list + The first `f_len_args` elements in this list represent positional arguments + to pass to the `f_to_deploy`. The rest are positional arguments that will be + passed to `deployer`. axis : {0, 1} - The axis to perform the function along. + The axis to perform the function along. This argument is keyword only. f_to_deploy : callable or RayObjectID - The function to deploy. - f_args : list or tuple - Positional arguments to pass to ``f_to_deploy``. + The function to deploy. This argument is keyword only. + f_len_args : int + Number of positional arguments to pass to ``f_to_deploy``. This argument is keyword only. f_kwargs : dict - Keyword arguments to pass to ``f_to_deploy``. - *args : list - Positional arguments to pass to ``deployer``. + Keyword arguments to pass to ``f_to_deploy``. This argument is keyword only. extract_metadata : bool, default: True Whether to return metadata (length, width, ip) of the result. Passing `False` may relax the load on object storage as the remote function would return 4 times fewer futures. Passing `False` makes sense for temporary results where you know for sure that the - metadata will never be requested. + metadata will never be requested. This argument is keyword only. **kwargs : dict Keyword arguments to pass to ``deployer``. @@ -310,8 +315,9 @@ def _deploy_ray_func( ----- Ray functions are not detected by codecov (thus pragma: no cover). """ - f_args = deserialize(f_args) - result = deployer(axis, f_to_deploy, f_args, f_kwargs, *args, **kwargs) + f_args = positional_args[:f_len_args] + deploy_args = positional_args[f_len_args:] + result = deployer(axis, f_to_deploy, f_args, f_kwargs, *deploy_args, **kwargs) if not extract_metadata: return result ip = get_node_ip_address() diff --git a/modin/test/storage_formats/pandas/test_internals.py b/modin/test/storage_formats/pandas/test_internals.py index ddecde800ac..71310f3ee5d 100644 --- a/modin/test/storage_formats/pandas/test_internals.py +++ b/modin/test/storage_formats/pandas/test_internals.py @@ -1390,3 +1390,124 @@ def test_sort_values_cache(): # check that the initial frame's cache wasn't changed assert mf_initial._column_widths_cache == [32, 32] validate_partitions_cache(mf_initial, axis=1) + + +class DummyFuture: + """ + A dummy object emulating future's behaviour, this class is used in ``test_call_queue_serialization``. + + It stores a random numeric value representing its data and `was_materialized` state. + Initially this object is considered to be serialized, the state can be changed by calling + the ``.materialize()`` method. + """ + + def __init__(self): + self._value = np.random.randint(0, 1_000_000) + self._was_materialized = False + + def materialize(self): + self._was_materialized = True + return self + + def __eq__(self, other): + if isinstance(other, type(self)) and self._value == other._value: + return True + return False + + +@pytest.mark.parametrize( + "call_queue", + [ + # empty call queue + [], + # a single-function call queue (the function has no argument and it's materialized) + [(0, [], {})], + # a single-function call queue (the function has no argument and it's serialized) + [(DummyFuture(), [], {})], + # a multiple-functions call queue, none of the functions have arguments + [(DummyFuture(), [], {}), (DummyFuture(), [], {}), (0, [], {})], + # a single-function call queue (the function has both positional and keyword arguments) + [ + ( + DummyFuture(), + [DummyFuture()], + { + "a": DummyFuture(), + "b": [DummyFuture()], + "c": [DummyFuture, DummyFuture()], + }, + ) + ], + # a multiple-functions call queue with mixed types of functions/arguments + [ + ( + DummyFuture(), + [1, DummyFuture(), DummyFuture(), [4, 5]], + {"a": [DummyFuture(), 2], "b": DummyFuture(), "c": [1]}, + ), + (0, [], {}), + (0, [1], {}), + (0, [DummyFuture(), DummyFuture()], {}), + ], + ], +) +def test_call_queue_serialization(call_queue): + """ + Test that the process of passing a call queue to Ray's kernel works correctly. + + Before passing a call queue to the kernel that actually executes it, the call queue + is unwrapped into a 1D list using the ``deconstruct_call_queue`` function. After that, + the 1D list is passed as a variable length argument to the kernel ``kernel(*queue)``, + this is done so the Ray engine automatically materialize all the futures that the queue + might have contained. In the end, inside of the kernel, the ``reconstruct_call_queue`` function + is called to rebuild the call queue into its original structure. + + This test emulates the described flow and verifies that it works properly. + """ + from modin.core.execution.ray.implementations.pandas_on_ray.partitioning.partition import ( + deconstruct_call_queue, + reconstruct_call_queue, + ) + + def materialize_queue(*values): + """ + Walk over the `values` and materialize all the future types. + + This function emulates how Ray remote functions materialize their positional arguments. + """ + return [ + val.materialize() if isinstance(val, DummyFuture) else val for val in values + ] + + def assert_everything_materialized(queue): + """Walk over the call queue and verify that all entities there are materialized.""" + + def assert_materialized(obj): + assert ( + isinstance(obj, DummyFuture) and obj._was_materialized + ) or not isinstance(obj, DummyFuture) + + for func, args, kwargs in queue: + assert_materialized(func) + for arg in args: + assert_materialized(arg) + for value in kwargs.values(): + if not isinstance(value, (list, tuple)): + value = [value] + for val in value: + assert_materialized(val) + + ( + num_funcs, + arg_lengths, + kw_key_lengths, + kw_value_lengths, + *queue, + ) = deconstruct_call_queue(call_queue) + queue = materialize_queue(*queue) + reconstructed_queue = reconstruct_call_queue( + num_funcs, arg_lengths, kw_key_lengths, kw_value_lengths, queue + ) + + assert call_queue == reconstructed_queue + assert_everything_materialized(reconstructed_queue) From ce54013f2116fcc57e7a6a09cfdd3532beb24d45 Mon Sep 17 00:00:00 2001 From: Egor Date: Fri, 13 Oct 2023 17:30:56 +0200 Subject: [PATCH 043/201] FIX-#6647: Added init file to make modin/experimental/sql/hdk/query.py part of modin package (#6646) Signed-off-by: Egor Krivov --- modin/experimental/sql/hdk/__init__.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 modin/experimental/sql/hdk/__init__.py diff --git a/modin/experimental/sql/hdk/__init__.py b/modin/experimental/sql/hdk/__init__.py new file mode 100644 index 00000000000..31de5addb64 --- /dev/null +++ b/modin/experimental/sql/hdk/__init__.py @@ -0,0 +1,14 @@ +# Licensed to Modin Development Team under one or more contributor license agreements. +# See the NOTICE file distributed with this work for additional information regarding +# copyright ownership. The Modin Development Team licenses this file to you under the +# Apache License, Version 2.0 (the "License"); you may not use this file except in +# compliance with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software distributed under +# the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF +# ANY KIND, either express or implied. See the License for the specific language +# governing permissions and limitations under the License. + +"""Implementation of HDK SQL functionality.""" From 3dbdfc60e67c8947685a08fba0844daa0ae0559a Mon Sep 17 00:00:00 2001 From: Anatoly Myachev Date: Mon, 16 Oct 2023 15:37:58 +0200 Subject: [PATCH 044/201] FEAT-#5634: Introduce `materialize` parameter for `partition.ip` func (#6650) Signed-off-by: Anatoly Myachev --- .../pandas_on_dask/partitioning/partition.py | 14 +++++++++++--- .../partitioning/virtual_partition.py | 2 +- .../pandas_on_ray/partitioning/partition.py | 18 +++++++++++++----- .../partitioning/virtual_partition.py | 2 +- .../partitioning/partition.py | 14 +++++++++++--- .../partitioning/virtual_partition.py | 2 +- .../distributed/dataframe/pandas/partitions.py | 5 ++++- 7 files changed, 42 insertions(+), 15 deletions(-) diff --git a/modin/core/execution/dask/implementations/pandas_on_dask/partitioning/partition.py b/modin/core/execution/dask/implementations/pandas_on_dask/partitioning/partition.py index 47bc97c3e6c..8dcc87b5699 100644 --- a/modin/core/execution/dask/implementations/pandas_on_dask/partitioning/partition.py +++ b/modin/core/execution/dask/implementations/pandas_on_dask/partitioning/partition.py @@ -13,6 +13,7 @@ """Module houses class that wraps data (block partition) and its metadata.""" +import pandas from distributed import Future from distributed.utils import get_ip @@ -289,18 +290,25 @@ def width(self, materialize=True): self._width_cache = DaskWrapper.materialize(self._width_cache) return self._width_cache - def ip(self): + def ip(self, materialize=True): """ Get the node IP address of the object wrapped by this partition. + Parameters + ---------- + materialize : bool, default: True + Whether to forcibly materialize the result into an integer. If ``False`` + was specified, may return a future of the result if it hasn't been + materialized yet. + Returns ------- str IP address of the node that holds the data. """ if self._ip_cache is None: - self._ip_cache = self.apply(lambda df: df)._ip_cache - if isinstance(self._ip_cache, Future): + self._ip_cache = self.apply(lambda df: pandas.DataFrame([]))._ip_cache + if materialize and isinstance(self._ip_cache, Future): self._ip_cache = DaskWrapper.materialize(self._ip_cache) return self._ip_cache diff --git a/modin/core/execution/dask/implementations/pandas_on_dask/partitioning/virtual_partition.py b/modin/core/execution/dask/implementations/pandas_on_dask/partitioning/virtual_partition.py index e8f7b6465c6..5681eb23b01 100644 --- a/modin/core/execution/dask/implementations/pandas_on_dask/partitioning/virtual_partition.py +++ b/modin/core/execution/dask/implementations/pandas_on_dask/partitioning/virtual_partition.py @@ -67,7 +67,7 @@ def list_of_ips(self): result = [None] * len(self.list_of_block_partitions) for idx, partition in enumerate(self.list_of_block_partitions): partition.drain_call_queue() - result[idx] = partition._ip_cache + result[idx] = partition.ip(materialize=False) return result @classmethod diff --git a/modin/core/execution/ray/implementations/pandas_on_ray/partitioning/partition.py b/modin/core/execution/ray/implementations/pandas_on_ray/partitioning/partition.py index 339f6bf64fc..36469b0ccd3 100644 --- a/modin/core/execution/ray/implementations/pandas_on_ray/partitioning/partition.py +++ b/modin/core/execution/ray/implementations/pandas_on_ray/partitioning/partition.py @@ -13,6 +13,7 @@ """Module houses class that wraps data (block partition) and its metadata.""" +import pandas import ray from ray.util import get_node_ip_address @@ -279,7 +280,7 @@ def length(self, materialize=True): self._length_cache, self._width_cache = _get_index_and_columns.remote( self._data ) - if isinstance(self._length_cache, ObjectIDType) and materialize: + if materialize and isinstance(self._length_cache, ObjectIDType): self._length_cache = RayWrapper.materialize(self._length_cache) return self._length_cache @@ -306,14 +307,21 @@ def width(self, materialize=True): self._length_cache, self._width_cache = _get_index_and_columns.remote( self._data ) - if isinstance(self._width_cache, ObjectIDType) and materialize: + if materialize and isinstance(self._width_cache, ObjectIDType): self._width_cache = RayWrapper.materialize(self._width_cache) return self._width_cache - def ip(self): + def ip(self, materialize=True): """ Get the node IP address of the object wrapped by this partition. + Parameters + ---------- + materialize : bool, default: True + Whether to forcibly materialize the result into an integer. If ``False`` + was specified, may return a future of the result if it hasn't been + materialized yet. + Returns ------- str @@ -323,8 +331,8 @@ def ip(self): if len(self.call_queue): self.drain_call_queue() else: - self._ip_cache = self.apply(lambda df: df)._ip_cache - if isinstance(self._ip_cache, ObjectIDType): + self._ip_cache = self.apply(lambda df: pandas.DataFrame([]))._ip_cache + if materialize and isinstance(self._ip_cache, ObjectIDType): self._ip_cache = RayWrapper.materialize(self._ip_cache) return self._ip_cache diff --git a/modin/core/execution/ray/implementations/pandas_on_ray/partitioning/virtual_partition.py b/modin/core/execution/ray/implementations/pandas_on_ray/partitioning/virtual_partition.py index 66463ecb2ba..07144413f57 100644 --- a/modin/core/execution/ray/implementations/pandas_on_ray/partitioning/virtual_partition.py +++ b/modin/core/execution/ray/implementations/pandas_on_ray/partitioning/virtual_partition.py @@ -94,7 +94,7 @@ def list_of_ips(self): result = [None] * len(self.list_of_block_partitions) for idx, partition in enumerate(self.list_of_block_partitions): partition.drain_call_queue() - result[idx] = partition._ip_cache + result[idx] = partition.ip(materialize=False) return result @classmethod diff --git a/modin/core/execution/unidist/implementations/pandas_on_unidist/partitioning/partition.py b/modin/core/execution/unidist/implementations/pandas_on_unidist/partitioning/partition.py index 000eb59c7f6..e674fd98207 100644 --- a/modin/core/execution/unidist/implementations/pandas_on_unidist/partitioning/partition.py +++ b/modin/core/execution/unidist/implementations/pandas_on_unidist/partitioning/partition.py @@ -15,6 +15,7 @@ import warnings +import pandas import unidist from modin.core.dataframe.pandas.partitioning.partition import PandasDataframePartition @@ -281,10 +282,17 @@ def width(self, materialize=True): self._width_cache = UnidistWrapper.materialize(self._width_cache) return self._width_cache - def ip(self): + def ip(self, materialize=True): """ Get the node IP address of the object wrapped by this partition. + Parameters + ---------- + materialize : bool, default: True + Whether to forcibly materialize the result into an integer. If ``False`` + was specified, may return a future of the result if it hasn't been + materialized yet. + Returns ------- str @@ -294,8 +302,8 @@ def ip(self): if len(self.call_queue): self.drain_call_queue() else: - self._ip_cache = self.apply(lambda df: df)._ip_cache - if unidist.is_object_ref(self._ip_cache): + self._ip_cache = self.apply(lambda df: pandas.DataFrame([]))._ip_cache + if materialize and unidist.is_object_ref(self._ip_cache): self._ip_cache = UnidistWrapper.materialize(self._ip_cache) return self._ip_cache diff --git a/modin/core/execution/unidist/implementations/pandas_on_unidist/partitioning/virtual_partition.py b/modin/core/execution/unidist/implementations/pandas_on_unidist/partitioning/virtual_partition.py index f662c7a8d81..012456998a7 100644 --- a/modin/core/execution/unidist/implementations/pandas_on_unidist/partitioning/virtual_partition.py +++ b/modin/core/execution/unidist/implementations/pandas_on_unidist/partitioning/virtual_partition.py @@ -96,7 +96,7 @@ def list_of_ips(self): result = [None] * len(self.list_of_block_partitions) for idx, partition in enumerate(self.list_of_block_partitions): partition.drain_call_queue() - result[idx] = partition._ip_cache + result[idx] = partition.ip(materialize=False) return result @classmethod diff --git a/modin/distributed/dataframe/pandas/partitions.py b/modin/distributed/dataframe/pandas/partitions.py index 1a71aa5ff30..62a05ff81d3 100644 --- a/modin/distributed/dataframe/pandas/partitions.py +++ b/modin/distributed/dataframe/pandas/partitions.py @@ -109,7 +109,10 @@ def get_block(partition: PartitionUnionType) -> np.ndarray: if get_ip: return [ - [(partition._ip_cache, get_block(partition)) for partition in row] + [ + (partition.ip(materialize=False), get_block(partition)) + for partition in row + ] for row in modin_frame._partitions ] else: From 658f6e1b6cdc1ec363ea80d254dd7ebf60588af1 Mon Sep 17 00:00:00 2001 From: Dmitry Chigarev Date: Mon, 16 Oct 2023 18:51:56 +0300 Subject: [PATCH 045/201] PERF-#2813: Distributed `from_pandas()` for numerical data in Ray (#6640) Signed-off-by: Dmitry Chigarev Co-authored-by: Iaroslav Igoshev --- modin/config/envvars.py | 16 +++- .../pandas/partitioning/partition_manager.py | 92 ++++++++++++------- .../partitioning/partition_manager.py | 67 ++++++++++++++ modin/pandas/test/test_io.py | 11 +++ 4 files changed, 149 insertions(+), 37 deletions(-) diff --git a/modin/config/envvars.py b/modin/config/envvars.py index 25e611c6084..da35bbaff00 100644 --- a/modin/config/envvars.py +++ b/modin/config/envvars.py @@ -660,8 +660,20 @@ class AsyncReadMode(EnvironmentVariable, type=bool): """ It does not wait for the end of reading information from the source. - Can break situations when reading occurs in a context, when exiting - from which the source is deleted. + It basically means, that the reading function only launches tasks for the dataframe + to be read/created, but not ensures that the construction is finalized by the time + the reading function returns a dataframe. + + This option was brought to improve performance of reading/construction + of Modin DataFrames, however it may also: + + 1. Increase the peak memory consumption. Since the garbage collection of the + temporary objects created during the reading is now also lazy and will only + be performed when the reading/construction is actually finished. + + 2. Can break situations when the source is manually deleted after the reading + function returns a result, for example, when reading inside of a context-block + that deletes the file on ``__exit__()``. """ varname = "MODIN_ASYNC_READ_MODE" diff --git a/modin/core/dataframe/pandas/partitioning/partition_manager.py b/modin/core/dataframe/pandas/partitioning/partition_manager.py index 904a2809b4f..fea0c686a96 100644 --- a/modin/core/dataframe/pandas/partitioning/partition_manager.py +++ b/modin/core/dataframe/pandas/partitioning/partition_manager.py @@ -766,6 +766,53 @@ def to_numpy(cls, partitions, **kwargs): [[block.to_numpy(**kwargs) for block in row] for row in partitions] ) + @classmethod + def split_pandas_df_into_partitions( + cls, df, row_chunksize, col_chunksize, update_bar + ): + """ + Split given pandas DataFrame according to the row/column chunk sizes into distributed partitions. + + Parameters + ---------- + df : pandas.DataFrame + row_chunksize : int + col_chunksize : int + update_bar : callable(x) -> x + Function that updates a progress bar. + + Returns + ------- + 2D np.ndarray[PandasDataframePartition] + """ + put_func = cls._partition_class.put + # even a full-axis slice can cost something (https://github.com/pandas-dev/pandas/issues/55202) + # so we try not to do it if unnecessary. + # FIXME: it appears that this optimization doesn't work for Unidist correctly as it + # doesn't explicitly copy the data when putting it into storage (as the rest engines do) + # causing it to eventially share memory with a pandas object that was provided by user. + # Everything works fine if we do this column slicing as pandas then would set some flags + # to perform in COW mode apparently (and so it wouldn't crash our tests). + # @YarShev promised that this will be eventially fixed on Unidist's side, but for now there's + # this hacky condition + if col_chunksize >= len(df.columns) and Engine.get() != "Unidist": + col_parts = [df] + else: + col_parts = [ + df.iloc[:, i : i + col_chunksize] + for i in range(0, len(df.columns), col_chunksize) + ] + parts = [ + [ + update_bar( + put_func(col_part.iloc[i : i + row_chunksize]), + ) + for col_part in col_parts + ] + for i in range(0, len(df), row_chunksize) + ] + return np.array(parts) + @classmethod @wait_computations_if_benchmark_mode def from_pandas(cls, df, return_dims=False): @@ -785,14 +832,7 @@ def from_pandas(cls, df, return_dims=False): np.ndarray or (np.ndarray, row_lengths, col_widths) A NumPy array with partitions (with dimensions or not). """ - - def update_bar(pbar, f): - if ProgressBar.get(): - pbar.update(1) - return f - num_splits = NPartitions.get() - put_func = cls._partition_class.put row_chunksize = compute_chunksize(df.shape[0], num_splits) col_chunksize = compute_chunksize(df.shape[1], num_splits) @@ -820,36 +860,18 @@ def update_bar(pbar, f): else: pbar = None - # even a full-axis slice can cost something (https://github.com/pandas-dev/pandas/issues/55202) - # so we try not to do it if unnecessary. - # FIXME: it appears that this optimization doesn't work for Unidist correctly as it - # doesn't explicitly copy the data when putting it into storage (as the rest engines do) - # causing it to eventially share memory with a pandas object that was provided by user. - # Everything works fine if we do this column slicing as pandas then would set some flags - # to perform in COW mode apparently (and so it wouldn't crash our tests). - # @YarShev promised that this will be eventially fixed on Unidist's side, but for now there's - # this hacky condition - if col_chunksize >= len(df.columns) and Engine.get() != "Unidist": - col_parts = [df] - else: - col_parts = [ - df.iloc[:, i : i + col_chunksize] - for i in range(0, len(df.columns), col_chunksize) - ] - parts = [ - [ - update_bar( - pbar, - put_func(col_part.iloc[i : i + row_chunksize]), - ) - for col_part in col_parts - ] - for i in range(0, len(df), row_chunksize) - ] + def update_bar(f): + if ProgressBar.get(): + pbar.update(1) + return f + + parts = cls.split_pandas_df_into_partitions( + df, row_chunksize, col_chunksize, update_bar + ) if ProgressBar.get(): pbar.close() if not return_dims: - return np.array(parts) + return parts else: row_lengths = [ row_chunksize @@ -863,7 +885,7 @@ def update_bar(pbar, f): else len(df.columns) % col_chunksize or col_chunksize for i in range(0, len(df.columns), col_chunksize) ] - return np.array(parts), row_lengths, col_widths + return parts, row_lengths, col_widths @classmethod def from_arrow(cls, at, return_dims=False): diff --git a/modin/core/execution/ray/implementations/pandas_on_ray/partitioning/partition_manager.py b/modin/core/execution/ray/implementations/pandas_on_ray/partitioning/partition_manager.py index 26164984fa7..074e60a30e2 100644 --- a/modin/core/execution/ray/implementations/pandas_on_ray/partitioning/partition_manager.py +++ b/modin/core/execution/ray/implementations/pandas_on_ray/partitioning/partition_manager.py @@ -13,11 +13,17 @@ """Module houses class that implements ``GenericRayDataframePartitionManager`` using Ray.""" +import numpy as np +from pandas.core.dtypes.common import is_numeric_dtype + +from modin.config import AsyncReadMode from modin.core.execution.modin_aqp import progress_bar_wrapper from modin.core.execution.ray.common import RayWrapper from modin.core.execution.ray.generic.partitioning import ( GenericRayDataframePartitionManager, ) +from modin.logging import get_logger +from modin.utils import _inherit_docstrings from .partition import PandasOnRayDataframePartition from .virtual_partition import ( @@ -51,6 +57,67 @@ def wait_partitions(cls, partitions): [block for partition in partitions for block in partition.list_of_blocks] ) + @classmethod + @_inherit_docstrings( + GenericRayDataframePartitionManager.split_pandas_df_into_partitions + ) + def split_pandas_df_into_partitions( + cls, df, row_chunksize, col_chunksize, update_bar + ): + # it was found out, that with the following condition it's more beneficial + # to use the distributed splitting, let's break them down: + # 1. The distributed splitting is used only when there's more than 6mln elements + # in the `df`, as with fewer data it's better to use the sequential splitting + # 2. Only used with numerical data, as with other dtypes, putting the whole big + # dataframe into the storage takes too much time. + # 3. The distributed splitting consumes more memory that the sequential one. + # It was estimated that it requires ~2.5x of the dataframe size, for now there + # was no good way found to automatically fall back to the sequential + # implementation in case of not enough memory, so currently we're enabling + # the distributed version only if 'AsyncReadMode' is set to True. Follow this + # discussion for more info on why automatical dispatching is hard: + # https://github.com/modin-project/modin/pull/6640#issuecomment-1759932664 + enough_elements = (len(df) * len(df.columns)) > 6_000_000 + all_numeric_types = all(is_numeric_dtype(dtype) for dtype in df.dtypes) + async_mode_on = AsyncReadMode.get() + + distributed_splitting = enough_elements and all_numeric_types and async_mode_on + + log = get_logger() + + if not distributed_splitting: + log.info( + "Using sequential splitting in '.from_pandas()' because of some of the conditions are False: " + + f"{enough_elements=}; {all_numeric_types=}; {async_mode_on=}" + ) + return super().split_pandas_df_into_partitions( + df, row_chunksize, col_chunksize, update_bar + ) + + log.info("Using distributed splitting in '.from_pandas()'") + put_func = cls._partition_class.put + + def mask(part, row_loc, col_loc): + # 2D iloc works surprisingly slow, so doing this chained iloc calls: + # https://github.com/pandas-dev/pandas/issues/55202 + return part.apply(lambda df: df.iloc[row_loc, :].iloc[:, col_loc]) + + main_part = put_func(df) + parts = [ + [ + update_bar( + mask( + main_part, + slice(i, i + row_chunksize), + slice(j, j + col_chunksize), + ), + ) + for j in range(0, len(df.columns), col_chunksize) + ] + for i in range(0, len(df), row_chunksize) + ] + return np.array(parts) + def _make_wrapped_method(name: str): """ diff --git a/modin/pandas/test/test_io.py b/modin/pandas/test/test_io.py index 323f3c016d5..6f1aa00c13a 100644 --- a/modin/pandas/test/test_io.py +++ b/modin/pandas/test/test_io.py @@ -3146,6 +3146,17 @@ def test_from_arrow(): df_equals(modin_df, pandas_df) +@pytest.mark.skipif( + condition=Engine.get() != "Ray", + reason="Distributed 'from_pandas' is only available for Ray engine", +) +@pytest.mark.parametrize("modify_config", [{AsyncReadMode: True}], indirect=True) +def test_distributed_from_pandas(modify_config): + pandas_df = pandas.DataFrame({f"col{i}": np.arange(200_000) for i in range(64)}) + modin_df = pd.DataFrame(pandas_df) + df_equals(modin_df, pandas_df) + + @pytest.mark.filterwarnings(default_to_pandas_ignore_string) def test_from_spmatrix(): data = sparse.eye(3) From f14b0fe6db4755903cd52ddd3b51e4aff172c359 Mon Sep 17 00:00:00 2001 From: Anatoly Myachev Date: Mon, 16 Oct 2023 18:06:14 +0200 Subject: [PATCH 046/201] PERF-#6645: avoid label synchronization for `dot` operation (#6644) Signed-off-by: Anatoly Myachev Co-authored-by: Dmitry Chigarev --- .../storage_formats/pandas/query_compiler.py | 40 ++++++++++++++----- 1 file changed, 31 insertions(+), 9 deletions(-) diff --git a/modin/core/storage_formats/pandas/query_compiler.py b/modin/core/storage_formats/pandas/query_compiler.py index aef305d6c17..0fc992bb05b 100644 --- a/modin/core/storage_formats/pandas/query_compiler.py +++ b/modin/core/storage_formats/pandas/query_compiler.py @@ -2231,14 +2231,6 @@ def dot(self, other, squeeze_self=None, squeeze_other=None): else other.to_pandas() ) - def map_func(df, other=other, squeeze_self=squeeze_self): # pragma: no cover - """Compute matrix multiplication of the passed frames.""" - result = df.squeeze(axis=1).dot(other) if squeeze_self else df.dot(other) - if is_list_like(result): - return pandas.DataFrame(result) - else: - return pandas.DataFrame([result]) - num_cols = other.shape[1] if len(other.shape) > 1 else 1 if len(self.columns) == 1: new_index = ( @@ -2255,8 +2247,38 @@ def map_func(df, other=other, squeeze_self=squeeze_self): # pragma: no cover new_columns = [MODIN_UNNAMED_SERIES_LABEL] if num_cols == 1 else None axis = 1 + # If either new index or new columns are supposed to be a single-dimensional, + # then we use a special labeling for them. Besides setting the new labels as + # a metadata to the resulted frame, we also want to set them inside the kernel, + # so actual partitions would be labeled accordingly (there's a 'sync_label' + # parameter that can do the same, but doing it manually is faster) + align_index = isinstance(new_index, list) and new_index == [ + MODIN_UNNAMED_SERIES_LABEL + ] + align_columns = new_columns == [MODIN_UNNAMED_SERIES_LABEL] + + def map_func(df, other=other, squeeze_self=squeeze_self): # pragma: no cover + """Compute matrix multiplication of the passed frames.""" + result = df.squeeze(axis=1).dot(other) if squeeze_self else df.dot(other) + + if is_list_like(result): + res = pandas.DataFrame(result) + else: + res = pandas.DataFrame([result]) + + # manual aligning with external index to avoid `sync_labels` overhead + if align_columns: + res.columns = [MODIN_UNNAMED_SERIES_LABEL] + if align_index: + res.index = [MODIN_UNNAMED_SERIES_LABEL] + return res + new_modin_frame = self._modin_frame.apply_full_axis( - axis, map_func, new_index=new_index, new_columns=new_columns + axis, + map_func, + new_index=new_index, + new_columns=new_columns, + sync_labels=False, ) return self.__constructor__(new_modin_frame) From 9c03202da0026543e09a1c597b8bdbbfda284e35 Mon Sep 17 00:00:00 2001 From: Andrey Pavlenko Date: Tue, 17 Oct 2023 15:41:21 +0200 Subject: [PATCH 047/201] PERF-#6653: value_counts(): Eliminate redundant sorting. (#6654) Signed-off-by: Andrey Pavlenko Co-authored-by: Dmitry Chigarev --- modin/pandas/base.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/modin/pandas/base.py b/modin/pandas/base.py index b730d38ca6b..b254068524f 100644 --- a/modin/pandas/base.py +++ b/modin/pandas/base.py @@ -3630,7 +3630,15 @@ def value_counts( ): if subset is None: subset = self._query_compiler.columns - counted_values = self.groupby(by=subset, dropna=dropna, observed=True).size() + with warnings.catch_warnings(): + warnings.filterwarnings( + "ignore", + message=".*groupby keys will be sorted anyway.*", + category=UserWarning, + ) + counted_values = self.groupby( + by=subset, dropna=dropna, observed=True, sort=False + ).size() if sort: counted_values.sort_values(ascending=ascending, inplace=True) if normalize: From 21518c44a343adb94be0cfcd8ccfec8da32db836 Mon Sep 17 00:00:00 2001 From: Anatoly Myachev Date: Tue, 17 Oct 2023 16:37:08 +0200 Subject: [PATCH 048/201] FIX-#6651: make sure `Series.between` works correctly (#6656) Signed-off-by: Anatoly Myachev Co-authored-by: Dmitry Chigarev --- modin/pandas/series.py | 6 +++--- modin/pandas/test/test_series.py | 9 +++++---- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/modin/pandas/series.py b/modin/pandas/series.py index c8016e777ce..c6cea020fd4 100644 --- a/modin/pandas/series.py +++ b/modin/pandas/series.py @@ -718,9 +718,9 @@ def between(self, left, right, inclusive: str = "both"): # noqa: PR01, RT01, D2 """ Return boolean Series equivalent to left <= series <= right. """ - return self.__constructor__( - query_compiler=self._query_compiler.between(left, right, inclusive) - ) + # 'pandas.Series.between()' only uses public Series' API, + # so passing a Modin Series there is safe + return pandas.Series.between(self, left, right, inclusive) def combine(self, other, func, fill_value=None): # noqa: PR01, RT01, D200 """ diff --git a/modin/pandas/test/test_series.py b/modin/pandas/test/test_series.py index 525cb44a21b..3736a8c7a0b 100644 --- a/modin/pandas/test/test_series.py +++ b/modin/pandas/test/test_series.py @@ -1198,13 +1198,14 @@ def test_array(data): eval_general(modin_series, pandas_series, lambda df: df.array) -@pytest.mark.xfail(reason="Using pandas Series.") @pytest.mark.parametrize("data", test_data_values, ids=test_data_keys) def test_between(data): - modin_series = create_test_series(data) + modin_series, pandas_series = create_test_series(data) - with pytest.raises(NotImplementedError): - modin_series.between(None, None) + df_equals( + modin_series.between(1, 4), + pandas_series.between(1, 4), + ) def test_between_time(): From c400b96f7361b11428f31ada097ba24ea15d33e3 Mon Sep 17 00:00:00 2001 From: Anatoly Myachev Date: Wed, 18 Oct 2023 11:54:52 +0200 Subject: [PATCH 049/201] DOCS-#6658: Add a note how to enable object spilling in a multi-node Ray cluster (#6659) Signed-off-by: Anatoly Myachev --- docs/getting_started/why_modin/out_of_core.rst | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/getting_started/why_modin/out_of_core.rst b/docs/getting_started/why_modin/out_of_core.rst index 7c5b6885d46..ff8b4dfb6e8 100644 --- a/docs/getting_started/why_modin/out_of_core.rst +++ b/docs/getting_started/why_modin/out_of_core.rst @@ -1,13 +1,17 @@ Out-of-memory data with Modin ============================= -.. note:: +.. note:: | *Estimated Reading Time: 10 minutes* When using pandas, you might run into a memory error if you are working with large datasets that cannot fit in memory or perform certain memory-intensive operations (e.g., joins). Modin solves this problem by spilling over to disk, in other words, it uses your disk as an overflow for memory so that you can work with datasets that are too large to fit in memory. By default, Modin leverages out-of-core methods to handle datasets that don't fit in memory for both Ray and Dask engines. +.. note:: + Object spilling is disabled in a multi-node Ray cluster by default. To enable object spilling + use `Ray instruction `_. + Motivating Example: Memory error with pandas -------------------------------------------- From 61881ce71251b6c2a0c688b67fde0a293dfe6e19 Mon Sep 17 00:00:00 2001 From: Anatoly Myachev Date: Thu, 19 Oct 2023 15:27:50 +0200 Subject: [PATCH 050/201] TEST-#0000: remove test for deprecated 'reshape' func (#6657) Signed-off-by: Anatoly Myachev --- modin/pandas/test/test_series.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/modin/pandas/test/test_series.py b/modin/pandas/test/test_series.py index 3736a8c7a0b..f193a8efb05 100644 --- a/modin/pandas/test/test_series.py +++ b/modin/pandas/test/test_series.py @@ -3021,15 +3021,6 @@ def test_reset_index(data, drop, name, inplace): ) -@pytest.mark.xfail(reason="Using pandas Series.") -@pytest.mark.parametrize("data", test_data_values, ids=test_data_keys) -def test_reshape(data): - modin_series = create_test_series(data) - - with pytest.raises(NotImplementedError): - modin_series.reshape(None) - - @pytest.mark.parametrize("data", test_data_values, ids=test_data_keys) def test_rfloordiv(data): modin_series, pandas_series = create_test_series(data) From 112afb6fb455beec67c9ded6f42ffbf378bbbf79 Mon Sep 17 00:00:00 2001 From: Andrey Pavlenko Date: Fri, 20 Oct 2023 14:21:37 +0200 Subject: [PATCH 051/201] PERF-#6661: Do not convert columns dtypes if the new dtypes are the same (#6662) Signed-off-by: Andrey Pavlenko --- .../dataframe/pandas/dataframe/dataframe.py | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/modin/core/dataframe/pandas/dataframe/dataframe.py b/modin/core/dataframe/pandas/dataframe/dataframe.py index 29eaaad3bea..eb936991d48 100644 --- a/modin/core/dataframe/pandas/dataframe/dataframe.py +++ b/modin/core/dataframe/pandas/dataframe/dataframe.py @@ -25,7 +25,7 @@ import pandas from pandas._libs.lib import no_default from pandas.api.types import is_object_dtype -from pandas.core.dtypes.common import is_list_like, is_numeric_dtype +from pandas.core.dtypes.common import is_dtype_equal, is_list_like, is_numeric_dtype from pandas.core.indexes.api import Index, RangeIndex from modin.config import Engine, IsRayCluster, NPartitions @@ -1486,21 +1486,18 @@ def astype(self, col_dtypes, errors: str = "raise"): BaseDataFrame Dataframe with updated dtypes. """ - columns = col_dtypes.keys() - # Create Series for the updated dtypes - new_dtypes = self.dtypes.copy() + new_dtypes = None + self_dtypes = self.dtypes # When casting to "category" we have to make up the whole axis partition # to get the properly encoded table of categories. Every block partition # will store the encoded table. That can lead to higher memory footprint. # TODO: Revisit if this hurts users. use_full_axis_cast = False has_categorical_cast = False - for i, column in enumerate(columns): - dtype = col_dtypes[column] - if ( - not isinstance(dtype, type(self.dtypes[column])) - or dtype != self.dtypes[column] - ): + for column, dtype in col_dtypes.items(): + if not is_dtype_equal(dtype, self_dtypes[column]): + if new_dtypes is None: + new_dtypes = self_dtypes.copy() # Update the new dtype series to the proper pandas dtype try: new_dtype = np.dtype(dtype) @@ -1525,6 +1522,9 @@ def astype(self, col_dtypes, errors: str = "raise"): else: new_dtypes[column] = new_dtype + if new_dtypes is None: + return self.copy() + def astype_builder(df): """Compute new partition frame with dtypes updated.""" # TODO(https://github.com/modin-project/modin/issues/6266): Remove this From d142c845969b3f46a0e6cd40933d80ac4c3ac55b Mon Sep 17 00:00:00 2001 From: Igor Zamyatin Date: Sat, 21 Oct 2023 16:09:02 -0500 Subject: [PATCH 052/201] FIX-#6632: Return Series instead of Dataframe for groupby.apply in case of experimental groupby (#6649) Signed-off-by: izamyati Co-authored-by: Dmitry Chigarev --- modin/pandas/groupby.py | 22 +++++++++++++--------- modin/pandas/test/test_groupby.py | 17 +++++++++++++++++ 2 files changed, 30 insertions(+), 9 deletions(-) diff --git a/modin/pandas/groupby.py b/modin/pandas/groupby.py index c50e97a596a..caa3086eb3d 100644 --- a/modin/pandas/groupby.py +++ b/modin/pandas/groupby.py @@ -659,16 +659,20 @@ def apply(self, func, *args, **kwargs): if not isinstance(func, BuiltinFunctionType): func = wrap_udf_function(func) - return self._check_index( - self._wrap_aggregation( - qc_method=type(self._query_compiler).groupby_agg, - numeric_only=False, - agg_func=func, - agg_args=args, - agg_kwargs=kwargs, - how="group_wise", - ) + apply_res = self._wrap_aggregation( + qc_method=type(self._query_compiler).groupby_agg, + numeric_only=False, + agg_func=func, + agg_args=args, + agg_kwargs=kwargs, + how="group_wise", ) + reduced_index = pandas.Index([MODIN_UNNAMED_SERIES_LABEL]) + if not isinstance(apply_res, Series) and apply_res.columns.equals( + reduced_index + ): + apply_res = apply_res.squeeze(axis=1) + return self._check_index(apply_res) @property def dtypes(self): diff --git a/modin/pandas/test/test_groupby.py b/modin/pandas/test/test_groupby.py index 30ba2c1103a..3bbf649ea60 100644 --- a/modin/pandas/test/test_groupby.py +++ b/modin/pandas/test/test_groupby.py @@ -2962,6 +2962,23 @@ def test_reshuffling_groupby_on_strings(modify_config): ) +@pytest.mark.parametrize( + "modify_config", [{ExperimentalGroupbyImpl: True}], indirect=True +) +def test_groupby_apply_series_result(modify_config): + # reproducer from the issue: + # https://github.com/modin-project/modin/issues/6632 + df = pd.DataFrame( + np.random.randint(5, 10, size=5), index=[f"s{i+1}" for i in range(5)] + ) + df["group"] = [1, 1, 2, 2, 3] + + # res = df.groupby('group').apply(lambda x: x.name+2) + eval_general( + df, df._to_pandas(), lambda df: df.groupby("group").apply(lambda x: x.name + 2) + ) + + ### TEST GROUPBY WARNINGS ### From 19615af5d40019655347a7e0b14be84f54bec842 Mon Sep 17 00:00:00 2001 From: Andrey Pavlenko Date: Tue, 24 Oct 2023 18:01:48 +0200 Subject: [PATCH 053/201] FEAT-#6675: Bump pyhdk version to 0.9 (#6676) Signed-off-by: Andrey Pavlenko --- requirements/env_hdk.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/env_hdk.yml b/requirements/env_hdk.yml index 701f475ac06..551ec920bcf 100644 --- a/requirements/env_hdk.yml +++ b/requirements/env_hdk.yml @@ -7,7 +7,7 @@ dependencies: # required dependencies - pandas>=2.1,<2.2 - numpy>=1.22.4 - - pyhdk==0.8 + - pyhdk==0.9 - fsspec>=2022.05.0 - packaging>=21.0 - psutil>=5.8.0 From e558d9de571a5b2582f59b5dff023c6b51f058cc Mon Sep 17 00:00:00 2001 From: Anatoly Myachev Date: Wed, 25 Oct 2023 19:43:48 +0200 Subject: [PATCH 054/201] FEAT-#5221: add `execute` to trigger lazy computations and wait for them to complete (#6648) Signed-off-by: Anatoly Myachev --- docs/development/architecture.rst | 1 + docs/flow/modin/utils.rst | 12 +++++++ docs/usage_guide/benchmarking.rst | 22 ++++++------- modin/conftest.py | 4 +++ .../dataframe/pandas/dataframe/dataframe.py | 4 +++ .../storage_formats/base/query_compiler.py | 5 +++ .../storage_formats/pandas/query_compiler.py | 4 +++ .../storage_formats/hdk/query_compiler.py | 8 +++++ modin/test/test_executions_api.py | 1 + modin/test/test_utils.py | 32 +++++++++++++++++++ modin/utils.py | 22 +++++++++++++ 11 files changed, 102 insertions(+), 13 deletions(-) create mode 100644 docs/flow/modin/utils.rst diff --git a/docs/development/architecture.rst b/docs/development/architecture.rst index 130813a86f5..22fc7f91496 100644 --- a/docs/development/architecture.rst +++ b/docs/development/architecture.rst @@ -304,6 +304,7 @@ details. The documentation covers most modules, with more docs being added every ├───examples ├───modin │ ├─── :doc:`config ` + | ├─── :doc:`utils ` │ ├───core │ │ ├─── :doc:`dataframe ` │ │ │ ├─── :doc:`algebra ` diff --git a/docs/flow/modin/utils.rst b/docs/flow/modin/utils.rst new file mode 100644 index 00000000000..70c622d1d7a --- /dev/null +++ b/docs/flow/modin/utils.rst @@ -0,0 +1,12 @@ +:orphan: + +Modin Utils +""""""""""" + +Here are utilities that can be useful when working with Modin. + +Public API +'''''''''' + +.. autofunction:: modin.utils.try_cast_to_pandas +.. autofunction:: modin.utils.execute diff --git a/docs/usage_guide/benchmarking.rst b/docs/usage_guide/benchmarking.rst index 7340a31db97..551c9950ae7 100644 --- a/docs/usage_guide/benchmarking.rst +++ b/docs/usage_guide/benchmarking.rst @@ -8,8 +8,8 @@ To benchmark a single Modin function, often turning on the :code:`BenchmarkMode` will suffice. There is no simple way to benchmark more complex Modin workflows, though -benchmark mode or calling ``repr`` on Modin objects may be useful. The -:doc:`Modin logs ` may help you +benchmark mode or calling ``modin.utils.execute`` on Modin objects may be useful. +The :doc:`Modin logs ` may help you identify bottlenecks in your code, and they may also help profile the execution of each Modin function. @@ -125,9 +125,8 @@ at each Modin :doc:`layer `. Log mode is more useful when used in conjuction with benchmark mode. Sometimes, if you don't have a natural end-point to your workflow, you can -just call ``repr`` on the workflow's final Modin objects. That will typically -block on any asynchronous computation. However, beware that ``repr`` can also -be misleading, e.g. here: +just call ``modin.utils.execute`` on the workflow's final Modin objects. +That will typically block on any asynchronous computation: .. code-block:: python @@ -137,6 +136,7 @@ be misleading, e.g. here: import modin.pandas as pd from modin.config import MinPartitionSize, NPartitions + import modin.utils MinPartitionSize.put(32) NPartitions.put(16) @@ -149,17 +149,13 @@ be misleading, e.g. here: ray.init() df1 = pd.DataFrame(list(range(10_000)), columns=['A']) result = df1.map(slow_add_one) - %time repr(result) - # time.sleep(10) + # %time modin.utils.execute(result) %time result.to_parquet(BytesIO()) .. code-block::python -The ``repr`` takes only 802 milliseconds, but writing the result to a buffer -takes 9.84 seconds. However, if you uncomment the :code:`time.sleep` before the -:code:`to_parquet` call, the :code:`to_parquet` takes just 23.8 milliseconds! -The problem is that the ``repr`` blocks only on getting the first few and the -last few rows, but the slow execution is for row 5001, which Modin is -computing asynchronously in the background even after ``repr`` finishes. +Writing the result to a buffer takes 9.84 seconds. However, if you uncomment +the :code:`%time modin.utils.execute(result)` before the :code:`to_parquet` +call, the :code:`to_parquet` takes just 23.8 milliseconds! .. note:: If you see any Modin documentation touting Modin's speed without using diff --git a/modin/conftest.py b/modin/conftest.py index 87cc0083792..d860807ec19 100644 --- a/modin/conftest.py +++ b/modin/conftest.py @@ -175,6 +175,10 @@ def __init__(self, modin_frame): def finalize(self): self._modin_frame.finalize() + def execute(self): + self.finalize() + self._modin_frame.wait_computations() + @classmethod def from_pandas(cls, df, data_cls): return cls(data_cls.from_pandas(df)) diff --git a/modin/core/dataframe/pandas/dataframe/dataframe.py b/modin/core/dataframe/pandas/dataframe/dataframe.py index eb936991d48..5d2b32d9f36 100644 --- a/modin/core/dataframe/pandas/dataframe/dataframe.py +++ b/modin/core/dataframe/pandas/dataframe/dataframe.py @@ -4106,6 +4106,10 @@ def finalize(self): """ self._partition_mgr_cls.finalize(self._partitions) + def wait_computations(self): + """Wait for all computations to complete without materializing data.""" + self._partition_mgr_cls.wait_partitions(self._partitions.flatten()) + def __dataframe__(self, nan_as_null: bool = False, allow_copy: bool = True): """ Get a Modin DataFrame that implements the dataframe exchange protocol. diff --git a/modin/core/storage_formats/base/query_compiler.py b/modin/core/storage_formats/base/query_compiler.py index ec31f98bd25..3bf0e06f0df 100644 --- a/modin/core/storage_formats/base/query_compiler.py +++ b/modin/core/storage_formats/base/query_compiler.py @@ -317,6 +317,11 @@ def finalize(self): """Finalize constructing the dataframe calling all deferred functions which were used to build it.""" pass + @abc.abstractmethod + def execute(self): + """Wait for all computations to complete without materializing data.""" + pass + # END Data Management Methods # To/From Pandas diff --git a/modin/core/storage_formats/pandas/query_compiler.py b/modin/core/storage_formats/pandas/query_compiler.py index 0fc992bb05b..eb7c2e808af 100644 --- a/modin/core/storage_formats/pandas/query_compiler.py +++ b/modin/core/storage_formats/pandas/query_compiler.py @@ -278,6 +278,10 @@ def lazy_execution(self): def finalize(self): self._modin_frame.finalize() + def execute(self): + self.finalize() + self._modin_frame.wait_computations() + def to_pandas(self): return self._modin_frame.to_pandas() diff --git a/modin/experimental/core/storage_formats/hdk/query_compiler.py b/modin/experimental/core/storage_formats/hdk/query_compiler.py index 439af6246d5..b4a580478cd 100644 --- a/modin/experimental/core/storage_formats/hdk/query_compiler.py +++ b/modin/experimental/core/storage_formats/hdk/query_compiler.py @@ -183,6 +183,14 @@ def finalize(self): # TODO: implement this for HDK storage format raise NotImplementedError() + def execute(self): + self._modin_frame._execute() + + def force_import(self): + """Force table import.""" + # HDK-specific method + self._modin_frame.force_import() + def to_pandas(self): return self._modin_frame.to_pandas() diff --git a/modin/test/test_executions_api.py b/modin/test/test_executions_api.py index b60afd1ad59..35013132eea 100644 --- a/modin/test/test_executions_api.py +++ b/modin/test/test_executions_api.py @@ -25,6 +25,7 @@ def test_base_abstract_methods(): "__init__", "free", "finalize", + "execute", "to_pandas", "from_pandas", "from_arrow", diff --git a/modin/test/test_utils.py b/modin/test/test_utils.py index 3337385d139..8f1b68a60f7 100644 --- a/modin/test/test_utils.py +++ b/modin/test/test_utils.py @@ -13,7 +13,9 @@ import json from textwrap import dedent, indent +from unittest.mock import Mock, patch +import numpy as np import pandas import pytest @@ -336,3 +338,33 @@ def test_assert_dtypes_equal(): df1 = df1.astype({"a": "str"}) with pytest.raises(AssertionError): assert_dtypes_equal(df1, df2) + + +def test_execute(): + data = np.random.rand(100, 64) + modin_df, pandas_df = create_test_dfs(data) + partitions = modin_df._query_compiler._modin_frame._partitions.flatten() + mgr_cls = modin_df._query_compiler._modin_frame._partition_mgr_cls + + # check modin case + with patch.object(mgr_cls, "wait_partitions", new=Mock()): + modin.utils.execute(modin_df) + mgr_cls.wait_partitions.assert_called_once() + assert (mgr_cls.wait_partitions.call_args[0] == partitions).all() + + # check pandas case without error + with patch.object(mgr_cls, "wait_partitions", new=Mock()): + modin.utils.execute(pandas_df) + mgr_cls.wait_partitions.assert_not_called() + + # muke sure `trigger_hdk_import=True` doesn't broke anything + # when using other storage formats + with patch.object(mgr_cls, "wait_partitions", new=Mock()): + modin.utils.execute(modin_df, trigger_hdk_import=True) + mgr_cls.wait_partitions.assert_called_once() + + # check several modin dataframes + with patch.object(mgr_cls, "wait_partitions", new=Mock()): + modin.utils.execute(modin_df, modin_df[modin_df.columns[:4]]) + mgr_cls.wait_partitions.assert_called + assert mgr_cls.wait_partitions.call_count == 2 diff --git a/modin/utils.py b/modin/utils.py index 6aba77f530f..c42a0516fa3 100644 --- a/modin/utils.py +++ b/modin/utils.py @@ -27,6 +27,7 @@ from typing import ( Any, Callable, + Iterable, List, Mapping, Optional, @@ -606,6 +607,27 @@ def try_cast_to_pandas(obj: Any, squeeze: bool = False) -> Any: return obj +def execute(*objs: Iterable[Any], trigger_hdk_import: bool = False) -> None: + """ + Trigger the lazy computations for each obj in `objs`, if any, and wait for them to complete. + + Parameters + ---------- + *objs : Iterable[Any] + A collection of objects to trigger lazy computations. + trigger_hdk_import : bool, default: False + Trigger import execution. Makes sense only for HDK storage format. + Safe to use with other storage formats. + """ + for obj in objs: + if not hasattr(obj, "_query_compiler"): + continue + query_compiler = obj._query_compiler + query_compiler.execute() + if trigger_hdk_import and hasattr(query_compiler, "force_import"): + query_compiler.force_import() + + def wrap_into_list(*args: Any, skipna: bool = True) -> List[Any]: """ Wrap a sequence of passed values in a flattened list. From d665b150ced98a09c4770cdcab017385f0e3c4a6 Mon Sep 17 00:00:00 2001 From: Igor Zamyatin Date: Wed, 25 Oct 2023 15:48:03 -0500 Subject: [PATCH 055/201] FIX-#6680: Specify navigation_with_keys=True to fix docs build (#6681) Signed-off-by: izamyati --- docs/conf.py | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/conf.py b/docs/conf.py index 75bbc258912..72a204c8d09 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -178,6 +178,7 @@ def noop_decorator(*args, **kwargs): "icon": "fas fa-envelope-square", }, ], + "navigation_with_keys": True, } # Custom sidebar templates, must be a dictionary that maps document names From e12b21703494dcbb8f7aa951b40ebe1d033307ba Mon Sep 17 00:00:00 2001 From: Igor Zamyatin Date: Thu, 26 Oct 2023 11:13:40 -0500 Subject: [PATCH 056/201] Release version 0.25.0 (#6682) Signed-off-by: Anatoly Myachev From 763bf8ac0f452f387e0a8a4c61d94b79d7480b93 Mon Sep 17 00:00:00 2001 From: Anatoly Myachev Date: Mon, 30 Oct 2023 16:59:23 +0100 Subject: [PATCH 057/201] PERF-#6669: Avoid one extra 'copy()' call for 'Series.reset_index' (#6670) Signed-off-by: Anatoly Myachev --- modin/pandas/series.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/modin/pandas/series.py b/modin/pandas/series.py index c6cea020fd4..18cb621cdae 100644 --- a/modin/pandas/series.py +++ b/modin/pandas/series.py @@ -1600,7 +1600,9 @@ def reset_index( obj.name = name from .dataframe import DataFrame - return DataFrame(obj).reset_index( + # Here `query_compiler` is passed instead of `obj` to avoid unnecessary `copy()` + # inside `DataFrame` constructor + return DataFrame(query_compiler=obj._query_compiler).reset_index( level=level, drop=drop, inplace=inplace, From fab8bfda817445191f8479db2281d07b8b871aab Mon Sep 17 00:00:00 2001 From: Devin Petersohn Date: Mon, 30 Oct 2023 14:57:49 -0700 Subject: [PATCH 058/201] FIX-#6687: Explicitly add users to CODEOWNERS (#6688) Signed-off-by: Devin Petersohn --- CODEOWNERS | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 2c7c57de55a..4d640a8ecff 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1,8 +1,8 @@ # These owners will be the default owners for everything in # the repo unless a later match takes precedence, -* @modin-project/modin-core +* @modin-project/modin-core @devin-petersohn @mvashishtha @RehanSD @YarShev @vnlitvinov @anmyachev @dchigarev # These owners will review everything in the HDK engine component # of Modin. -/modin/experimental/core/storage_formats/hdk/** @modin-project/modin-hdk -/modin/experimental/core/execution/native/implementations/hdk_on_native/** @modin-project/modin-hdk +/modin/experimental/core/storage_formats/hdk/** @modin-project/modin-hdk @aregm @gshimansky @ienkovich @Garra1980 @YarShev @vnlitvinov @anmyachev @dchigarev @AndreyPavlenko +/modin/experimental/core/execution/native/implementations/hdk_on_native/** @modin-project/modin-hdk @aregm @gshimansky @ienkovich @Garra1980 @YarShev @vnlitvinov @anmyachev @dchigarev @AndreyPavlenko From 3ebbf96bd86953bf9a2e9951035a588e846aae74 Mon Sep 17 00:00:00 2001 From: Anatoly Myachev Date: Tue, 31 Oct 2023 09:37:05 +0100 Subject: [PATCH 059/201] PERF-#6671: don't iterate over the result of the 'Series.tolist' function (#6672) Signed-off-by: Anatoly Myachev --- modin/core/storage_formats/base/query_compiler.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/modin/core/storage_formats/base/query_compiler.py b/modin/core/storage_formats/base/query_compiler.py index 3bf0e06f0df..bc311201d91 100644 --- a/modin/core/storage_formats/base/query_compiler.py +++ b/modin/core/storage_formats/base/query_compiler.py @@ -169,6 +169,9 @@ def default_to_pandas(self, pandas_op, *args, **kwargs): warnings.filterwarnings("ignore", category=FutureWarning) result = pandas_op(try_cast_to_pandas(self), *args, **kwargs) if isinstance(result, (tuple, list)): + if "Series.tolist" in pandas_op.__name__: + # fast path: no need to iterate over the result from `tolist` function + return result return [self.__wrap_in_qc(obj) for obj in result] return self.__wrap_in_qc(result) From dcd750cbb828e10e30c5b1722827d7b6a18c7b66 Mon Sep 17 00:00:00 2001 From: Anatoly Myachev Date: Tue, 31 Oct 2023 09:41:35 +0100 Subject: [PATCH 060/201] FIX-#6684: Adapt to pandas 2.1.2 (#6685) Signed-off-by: Anatoly Myachev --- modin/pandas/base.py | 12 ++++++------ modin/pandas/groupby.py | 12 ++++++------ modin/pandas/test/test_groupby.py | 12 +++++++++--- 3 files changed, 21 insertions(+), 15 deletions(-) diff --git a/modin/pandas/base.py b/modin/pandas/base.py index b254068524f..d2921f49959 100644 --- a/modin/pandas/base.py +++ b/modin/pandas/base.py @@ -2188,13 +2188,13 @@ def pct_change( """ Percentage change between the current and a prior element. """ - if fill_method is not lib.no_default or limit is not lib.no_default: + if fill_method not in (lib.no_default, None) or limit is not lib.no_default: warnings.warn( - "The 'fill_method' and 'limit' keywords in " - + f"{type(self).__name__}.pct_change are deprecated and will be " - + "removed in a future version. Call " - + f"{'bfill' if fill_method in ('backfill', 'bfill') else 'ffill'} " - + "before calling pct_change instead.", + "The 'fill_method' keyword being not None and the 'limit' keyword in " + + f"{type(self).__name__}.pct_change are deprecated and will be removed " + + "in a future version. Either fill in any non-leading NA values prior " + + "to calling pct_change or specify 'fill_method=None' to not fill NA " + + "values.", FutureWarning, ) if fill_method is lib.no_default: diff --git a/modin/pandas/groupby.py b/modin/pandas/groupby.py index caa3086eb3d..8601bda9464 100644 --- a/modin/pandas/groupby.py +++ b/modin/pandas/groupby.py @@ -559,13 +559,13 @@ def pct_change( ): from .dataframe import DataFrame - if fill_method is not lib.no_default or limit is not lib.no_default: + if fill_method not in (lib.no_default, None) or limit is not lib.no_default: warnings.warn( - "The 'fill_method' and 'limit' keywords in " - + f"{type(self).__name__}.pct_change are deprecated and will be " - + "removed in a future version. Call " - + f"{'bfill' if fill_method in ('backfill', 'bfill') else 'ffill'} " - + "before calling pct_change instead.", + "The 'fill_method' keyword being not None and the 'limit' keyword in " + + f"{type(self).__name__}.pct_change are deprecated and will be removed " + + "in a future version. Either fill in any non-leading NA values prior " + + "to calling pct_change or specify 'fill_method=None' to not fill NA " + + "values.", FutureWarning, ) if fill_method is lib.no_default: diff --git a/modin/pandas/test/test_groupby.py b/modin/pandas/test/test_groupby.py index 3bbf649ea60..2b491372a92 100644 --- a/modin/pandas/test/test_groupby.py +++ b/modin/pandas/test/test_groupby.py @@ -77,7 +77,8 @@ "ignore:DataFrameGroupBy.pct_change with axis=1 is deprecated:FutureWarning" ), pytest.mark.filterwarnings( - "ignore:The 'fill_method' and 'limit' keywords in (DataFrame|DataFrameGroupBy).pct_change are deprecated:FutureWarning" + "ignore:The 'fill_method' keyword being not None and the 'limit' keyword " + + "in (DataFrame|DataFrameGroupBy).pct_change are deprecated:FutureWarning" ), pytest.mark.filterwarnings( "ignore:DataFrameGroupBy.shift with axis=1 is deprecated:FutureWarning" @@ -3061,14 +3062,19 @@ def test_groupby_pct_change_parameters_warning(): modin_groupby = modin_df.groupby(by="col1") pandas_groupby = pandas_df.groupby(by="col1") + match_string = ( + "The 'fill_method' keyword being not None and the 'limit' keyword " + + "in (DataFrame|DataFrameGroupBy).pct_change are deprecated" + ) + with pytest.warns( FutureWarning, - match="The 'fill_method' and 'limit' keywords in (DataFrame|DataFrameGroupBy).pct_change are deprecated", + match=match_string, ): modin_groupby.pct_change(fill_method="bfill", limit=1) with pytest.warns( FutureWarning, - match="The 'fill_method' and 'limit' keywords in (DataFrame|DataFrameGroupBy).pct_change are deprecated", + match=match_string, ): pandas_groupby.pct_change(fill_method="bfill", limit=1) From 521eb6034c7d0b4abf4f3ad84acf330612038358 Mon Sep 17 00:00:00 2001 From: Anatoly Myachev Date: Tue, 31 Oct 2023 15:42:29 +0100 Subject: [PATCH 061/201] PERF-#6668: Use `copy=False` for internal usage of `set_axis` (#6667) Signed-off-by: Anatoly Myachev --- modin/core/dataframe/pandas/dataframe/dataframe.py | 7 ++++++- modin/pandas/groupby.py | 4 +++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/modin/core/dataframe/pandas/dataframe/dataframe.py b/modin/core/dataframe/pandas/dataframe/dataframe.py index 5d2b32d9f36..04f1f7448fd 100644 --- a/modin/core/dataframe/pandas/dataframe/dataframe.py +++ b/modin/core/dataframe/pandas/dataframe/dataframe.py @@ -808,7 +808,12 @@ def _propagate_index_objs(self, axis=None): if axis is None: def apply_idx_objs(df, idx, cols): - return df.set_axis(idx, axis="index").set_axis(cols, axis="columns") + # We should make at least one copy to avoid the data modification problem + # that may arise when sharing buffers from distributed storage + # (zero-copy pickling). + return df.set_axis(idx, axis="index").set_axis( + cols, axis="columns", copy=False + ) self._partitions = np.array( [ diff --git a/modin/pandas/groupby.py b/modin/pandas/groupby.py index 8601bda9464..fb496cd979c 100644 --- a/modin/pandas/groupby.py +++ b/modin/pandas/groupby.py @@ -1990,7 +1990,9 @@ def aggregate(self, func=None, *args, engine=None, engine_kwargs=None, **kwargs) # because there is no need to identify which original column's aggregation # the new column represents. alternatively we could give the query compiler # a hint that it's for a series, not a dataframe. - return result.set_axis(labels=self._try_get_str_func(func), axis=1) + return result.set_axis( + labels=self._try_get_str_func(func), axis=1, copy=False + ) else: return super().aggregate( func, *args, engine=engine, engine_kwargs=engine_kwargs, **kwargs From 6157b966b7f332f00bb40d6dedbf5f39b7343451 Mon Sep 17 00:00:00 2001 From: Anatoly Myachev Date: Thu, 2 Nov 2023 16:30:02 +0100 Subject: [PATCH 062/201] PERF-#6690: Use 'sync_labels=False' for 'rank' function (#6689) Signed-off-by: Anatoly Myachev --- modin/core/storage_formats/pandas/query_compiler.py | 1 + 1 file changed, 1 insertion(+) diff --git a/modin/core/storage_formats/pandas/query_compiler.py b/modin/core/storage_formats/pandas/query_compiler.py index eb7c2e808af..7ca93d9a43e 100644 --- a/modin/core/storage_formats/pandas/query_compiler.py +++ b/modin/core/storage_formats/pandas/query_compiler.py @@ -2535,6 +2535,7 @@ def rank(self, **kwargs): if not numeric_only else None, dtypes=np.float64, + sync_labels=False, ) return self.__constructor__(new_modin_frame) From 7a9415e10895ef094f76b125812ef1bd5bf5640c Mon Sep 17 00:00:00 2001 From: Andrey Pavlenko Date: Fri, 3 Nov 2023 15:41:46 +0100 Subject: [PATCH 063/201] PERF-#6694: Use `lazy_map_partitions()` for dtypes conversion (#6695) Signed-off-by: Andrey Pavlenko --- modin/core/dataframe/pandas/dataframe/dataframe.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modin/core/dataframe/pandas/dataframe/dataframe.py b/modin/core/dataframe/pandas/dataframe/dataframe.py index 04f1f7448fd..ec3ca1ca71c 100644 --- a/modin/core/dataframe/pandas/dataframe/dataframe.py +++ b/modin/core/dataframe/pandas/dataframe/dataframe.py @@ -1548,7 +1548,7 @@ def astype_builder(df): 0, self._partitions, astype_builder, keep_partitioning=True ) else: - new_frame = self._partition_mgr_cls.map_partitions( + new_frame = self._partition_mgr_cls.lazy_map_partitions( self._partitions, astype_builder ) return self.__constructor__( From ee3e298cd2c78e4f58088d4f17917f0c25e9872b Mon Sep 17 00:00:00 2001 From: Anatoly Myachev Date: Mon, 6 Nov 2023 15:15:20 +0100 Subject: [PATCH 064/201] TEST-#6705: Don't compare 'pkl' files (#6706) Signed-off-by: Anatoly Myachev --- modin/pandas/test/test_io.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/modin/pandas/test/test_io.py b/modin/pandas/test/test_io.py index 6f1aa00c13a..672bd3a8003 100644 --- a/modin/pandas/test/test_io.py +++ b/modin/pandas/test/test_io.py @@ -3061,14 +3061,14 @@ def test_read_pickle(self, make_pickle_file): ) def test_to_pickle(self, tmp_path): - modin_df, pandas_df = create_test_dfs(TEST_DATA) - eval_to_file( - tmp_path, - modin_obj=modin_df, - pandas_obj=pandas_df, - fn="to_pickle", - extension="pkl", - ) + modin_df, _ = create_test_dfs(TEST_DATA) + + unique_filename_modin = get_unique_filename(extension="pkl", data_dir=tmp_path) + + modin_df.to_pickle(unique_filename_modin) + recreated_modin_df = pd.read_pickle(unique_filename_modin) + + df_equals(modin_df, recreated_modin_df) @pytest.mark.filterwarnings(default_to_pandas_ignore_string) From 65be4ce216f5a5296499946c4112b1e5c2fda9b0 Mon Sep 17 00:00:00 2001 From: Anatoly Myachev Date: Mon, 6 Nov 2023 17:04:53 +0100 Subject: [PATCH 065/201] PERF-#6666: Avoid internal `reset_index` for left `merge` (#6665) Signed-off-by: Anatoly Myachev Co-authored-by: Iaroslav Igoshev --- .../storage_formats/pandas/query_compiler.py | 55 ++++++++++++++----- 1 file changed, 40 insertions(+), 15 deletions(-) diff --git a/modin/core/storage_formats/pandas/query_compiler.py b/modin/core/storage_formats/pandas/query_compiler.py index 7ca93d9a43e..f844e75fcdf 100644 --- a/modin/core/storage_formats/pandas/query_compiler.py +++ b/modin/core/storage_formats/pandas/query_compiler.py @@ -503,8 +503,38 @@ def merge(self, right, **kwargs): kwargs["sort"] = False - def map_func(left, right=right_pandas, kwargs=kwargs): # pragma: no cover - return pandas.merge(left, right_pandas, **kwargs) + def should_keep_index(left, right): + keep_index = False + if left_on is not None and right_on is not None: + keep_index = any( + o in left.index.names + and o in right_on + and o in right.index.names + for o in left_on + ) + elif on is not None: + keep_index = any( + o in left.index.names and o in right.index.names for o in on + ) + return keep_index + + def map_func( + left, *axis_lengths, right=right_pandas, kwargs=kwargs, **service_kwargs + ): # pragma: no cover + df = pandas.merge(left, right, **kwargs) + + if kwargs["how"] == "left": + partition_idx = service_kwargs["partition_idx"] + if len(axis_lengths): + if not should_keep_index(left, right): + # Doesn't work for "inner" case, since the partition sizes of the + # left dataframe may change + start = sum(axis_lengths[:partition_idx]) + stop = sum(axis_lengths[: partition_idx + 1]) + + df.index = pandas.RangeIndex(start, stop) + + return df # Want to ensure that these are python lists if left_on is not None and right_on is not None: @@ -552,6 +582,7 @@ def map_func(left, right=right_pandas, kwargs=kwargs): # pragma: no cover self._modin_frame.apply_full_axis( axis=1, func=map_func, + enumerate_partitions=how == "left", # We're going to explicitly change the shape across the 1-axis, # so we want for partitioning to adapt as well keep_partitioning=False, @@ -561,6 +592,7 @@ def map_func(left, right=right_pandas, kwargs=kwargs): # pragma: no cover new_columns=new_columns, dtypes=new_dtypes, sync_labels=False, + pass_axis_lengths_to_partitions=how == "left", ) ) @@ -570,18 +602,7 @@ def map_func(left, right=right_pandas, kwargs=kwargs): # pragma: no cover # materialized quite often compared to the indexes. keep_index = False if self._modin_frame.has_materialized_index: - if left_on is not None and right_on is not None: - keep_index = any( - o in self.index.names - and o in right_on - and o in right_pandas.index.names - for o in left_on - ) - elif on is not None: - keep_index = any( - o in self.index.names and o in right_pandas.index.names - for o in on - ) + keep_index = should_keep_index(self, right_pandas) else: # Have to trigger columns materialization. Hope they're already available at this point. if left_on is not None and right_on is not None: @@ -611,7 +632,11 @@ def map_func(left, right=right_pandas, kwargs=kwargs): # pragma: no cover else new_self.sort_rows_by_column_values(on) ) - return new_self if keep_index else new_self.reset_index(drop=True) + return ( + new_self.reset_index(drop=True) + if not keep_index and (kwargs["how"] != "left" or sort) + else new_self + ) else: return self.default_to_pandas(pandas.DataFrame.merge, right, **kwargs) From 0c68d9fc1d239c2f8257414c68c5b4a152a6064f Mon Sep 17 00:00:00 2001 From: Anatoly Myachev Date: Mon, 6 Nov 2023 17:05:20 +0100 Subject: [PATCH 066/201] PERF-#6702: don't materialize axes when calling to_numpy (#6699) Signed-off-by: Anatoly Myachev --- modin/core/dataframe/pandas/dataframe/dataframe.py | 9 ++++++++- modin/core/storage_formats/pandas/query_compiler.py | 6 +----- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/modin/core/dataframe/pandas/dataframe/dataframe.py b/modin/core/dataframe/pandas/dataframe/dataframe.py index ec3ca1ca71c..5da628b4c6a 100644 --- a/modin/core/dataframe/pandas/dataframe/dataframe.py +++ b/modin/core/dataframe/pandas/dataframe/dataframe.py @@ -4068,7 +4068,14 @@ def to_numpy(self, **kwargs): ------- np.ndarray """ - return self._partition_mgr_cls.to_numpy(self._partitions, **kwargs) + arr = self._partition_mgr_cls.to_numpy(self._partitions, **kwargs) + ErrorMessage.catch_bugs_and_request_email( + self.has_materialized_index + and len(arr) != len(self.index) + or self.has_materialized_columns + and len(arr[0]) != len(self.columns) + ) + return arr @lazy_metadata_decorator(apply_axis=None, transpose=True) def transpose(self): diff --git a/modin/core/storage_formats/pandas/query_compiler.py b/modin/core/storage_formats/pandas/query_compiler.py index f844e75fcdf..19a31dd1db6 100644 --- a/modin/core/storage_formats/pandas/query_compiler.py +++ b/modin/core/storage_formats/pandas/query_compiler.py @@ -378,11 +378,7 @@ def free(self): # To NumPy def to_numpy(self, **kwargs): - arr = self._modin_frame.to_numpy(**kwargs) - ErrorMessage.catch_bugs_and_request_email( - len(arr) != len(self.index) or len(arr[0]) != len(self.columns) - ) - return arr + return self._modin_frame.to_numpy(**kwargs) # END To NumPy From aa049f4ac4557d485f0c8ad65b38bdf1ef2ba75b Mon Sep 17 00:00:00 2001 From: Anatoly Myachev Date: Tue, 7 Nov 2023 10:45:58 +0100 Subject: [PATCH 067/201] PERF-#6712: Copy '_shape_hint' in 'query_complier.copy' function (#6713) Signed-off-by: Anatoly Myachev --- modin/core/storage_formats/pandas/query_compiler.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modin/core/storage_formats/pandas/query_compiler.py b/modin/core/storage_formats/pandas/query_compiler.py index 19a31dd1db6..14bdcd090c0 100644 --- a/modin/core/storage_formats/pandas/query_compiler.py +++ b/modin/core/storage_formats/pandas/query_compiler.py @@ -329,7 +329,7 @@ def add_suffix(self, suffix, axis=1): # copies if we end up modifying something here. We copy all of the metadata # to prevent that. def copy(self): - return self.__constructor__(self._modin_frame.copy()) + return self.__constructor__(self._modin_frame.copy(), self._shape_hint) # END Copy From 19f035c9d2bcdcd904889d5569bc0d2842d383b9 Mon Sep 17 00:00:00 2001 From: Anatoly Myachev Date: Tue, 7 Nov 2023 10:47:04 +0100 Subject: [PATCH 068/201] PERF-#6710: Don't materialize index in `_groupby_shuffle` internal function (#6707) Signed-off-by: Anatoly Myachev --- .../dataframe/pandas/dataframe/dataframe.py | 24 ++++++++++++++----- .../core/dataframe/pandas/dataframe/utils.py | 2 +- .../storage_formats/pandas/query_compiler.py | 4 ++-- 3 files changed, 21 insertions(+), 9 deletions(-) diff --git a/modin/core/dataframe/pandas/dataframe/dataframe.py b/modin/core/dataframe/pandas/dataframe/dataframe.py index 5da628b4c6a..8216cb7d6ef 100644 --- a/modin/core/dataframe/pandas/dataframe/dataframe.py +++ b/modin/core/dataframe/pandas/dataframe/dataframe.py @@ -191,6 +191,20 @@ def row_lengths(self): self._row_lengths_cache = [] return self._row_lengths_cache + def __len__(self) -> int: + """ + Return length of index axis. + + Returns + ------- + int + """ + if self.has_materialized_index: + _len = len(self.index) + else: + _len = sum(self.row_lengths) + return _len + @property def column_widths(self): """ @@ -2421,10 +2435,8 @@ def _apply_func_to_range_partitioning( # don't want to inherit over-partitioning so doing this 'min' check ideal_num_new_partitions = min(len(self._partitions), NPartitions.get()) - m = len(self.index) / ideal_num_new_partitions - sampling_probability = (1 / m) * np.log( - ideal_num_new_partitions * len(self.index) - ) + m = len(self) / ideal_num_new_partitions + sampling_probability = (1 / m) * np.log(ideal_num_new_partitions * len(self)) # If this df is overpartitioned, we try to sample each partition with probability # greater than 1, which leads to an error. In this case, we can do one of the following # two things. If there is only enough rows for one partition, and we have only 1 column @@ -2435,8 +2447,8 @@ def _apply_func_to_range_partitioning( if sampling_probability >= 1: from modin.config import MinPartitionSize - ideal_num_new_partitions = round(len(self.index) / MinPartitionSize.get()) - if len(self.index) < MinPartitionSize.get() or ideal_num_new_partitions < 2: + ideal_num_new_partitions = round(len(self) / MinPartitionSize.get()) + if len(self) < MinPartitionSize.get() or ideal_num_new_partitions < 2: # If the data is too small, we shouldn't try reshuffling/repartitioning but rather # simply combine all partitions and apply the sorting to the whole dataframe return self.combine_and_apply(func=func) diff --git a/modin/core/dataframe/pandas/dataframe/utils.py b/modin/core/dataframe/pandas/dataframe/utils.py index eb909159582..56e37c1943d 100644 --- a/modin/core/dataframe/pandas/dataframe/utils.py +++ b/modin/core/dataframe/pandas/dataframe/utils.py @@ -132,7 +132,7 @@ def __init__( ideal_num_new_partitions: int, **kwargs: dict, ): - self.frame_len = len(modin_frame.index) + self.frame_len = len(modin_frame) self.ideal_num_new_partitions = ideal_num_new_partitions self.columns = columns if is_list_like(columns) else [columns] self.ascending = ascending diff --git a/modin/core/storage_formats/pandas/query_compiler.py b/modin/core/storage_formats/pandas/query_compiler.py index 14bdcd090c0..99dcb2f77e4 100644 --- a/modin/core/storage_formats/pandas/query_compiler.py +++ b/modin/core/storage_formats/pandas/query_compiler.py @@ -3637,7 +3637,7 @@ def _groupby_shuffle( # Higher API level won't pass empty data here unless the frame has delayed # computations. FIXME: We apparently lose some laziness here (due to index access) # because of the inability to process empty groupby natively. - if len(self.columns) == 0 or len(self.index) == 0: + if len(self.columns) == 0 or len(self._modin_frame) == 0: return super().groupby_agg( by, agg_func, axis, groupby_kwargs, agg_args, agg_kwargs, how, drop ) @@ -3832,7 +3832,7 @@ def groupby_agg( # Higher API level won't pass empty data here unless the frame has delayed # computations. So we apparently lose some laziness here (due to index access) # because of the inability to process empty groupby natively. - if len(self.columns) == 0 or len(self.index) == 0: + if len(self.columns) == 0 or len(self._modin_frame) == 0: return super().groupby_agg( by, agg_func, axis, groupby_kwargs, agg_args, agg_kwargs, how, drop ) From 0bfbe805d3e7f18113cf43200bb4bf245f3e4e67 Mon Sep 17 00:00:00 2001 From: Anatoly Myachev Date: Tue, 7 Nov 2023 10:48:08 +0100 Subject: [PATCH 069/201] PERF-#6701: use `get_axis` internal function instead of `axes` property (#6700) Signed-off-by: Anatoly Myachev --- .../dataframe/pandas/dataframe/dataframe.py | 32 ++++++++--------- modin/pandas/base.py | 34 +++++++++++++------ modin/pandas/dataframe.py | 4 +-- 3 files changed, 42 insertions(+), 28 deletions(-) diff --git a/modin/core/dataframe/pandas/dataframe/dataframe.py b/modin/core/dataframe/pandas/dataframe/dataframe.py index 8216cb7d6ef..233bca9f71c 100644 --- a/modin/core/dataframe/pandas/dataframe/dataframe.py +++ b/modin/core/dataframe/pandas/dataframe/dataframe.py @@ -763,7 +763,7 @@ def _filter_empties(self, compute_metadata=True): # do not trigger the computations return - if len(self.axes[0]) == 0 or len(self.axes[1]) == 0: + if len(self.get_axis(0)) == 0 or len(self.get_axis(1)) == 0: # This is the case for an empty frame. We don't want to completely remove # all metadata and partitions so for the moment, we won't prune if the frame # is empty. @@ -1081,7 +1081,7 @@ def _take_2d_positional( indexers = [] for axis, indexer in enumerate((row_positions, col_positions)): if is_range_like(indexer): - if indexer.step == 1 and len(indexer) == len(self.axes[axis]): + if indexer.step == 1 and len(indexer) == len(self.get_axis(axis)): # By this function semantics, `None` indexer is a full-axis access indexer = None elif indexer is not None and not isinstance(indexer, pandas.RangeIndex): @@ -1674,12 +1674,12 @@ def _get_dict_of_block_index(self, axis, indices, are_indices_sorted=False): if isinstance(indices, slice) and ( indices.step is not None and indices.step != 1 ): - indices = range(*indices.indices(len(self.axes[axis]))) + indices = range(*indices.indices(len(self.get_axis(axis)))) # Fasttrack slices if isinstance(indices, slice) or (is_range_like(indices) and indices.step == 1): # Converting range-like indexer to slice indices = slice(indices.start, indices.stop, indices.step) - if is_full_grab_slice(indices, sequence_len=len(self.axes[axis])): + if is_full_grab_slice(indices, sequence_len=len(self.get_axis(axis))): return OrderedDict( zip( range(self._partitions.shape[axis]), @@ -1698,7 +1698,7 @@ def _get_dict_of_block_index(self, axis, indices, are_indices_sorted=False): ) dict_of_slices.update({last_part: slice(last_idx[0])}) return dict_of_slices - elif indices.stop is None or indices.stop >= len(self.axes[axis]): + elif indices.stop is None or indices.stop >= len(self.get_axis(axis)): first_part, first_idx = list( self._get_dict_of_block_index(axis, [indices.start]).items() )[0] @@ -1755,7 +1755,7 @@ def _get_dict_of_block_index(self, axis, indices, are_indices_sorted=False): if isinstance(indices, np.ndarray) else np.array(indices, dtype=np.int64) ) - indices[negative_mask] = indices[negative_mask] % len(self.axes[axis]) + indices[negative_mask] = indices[negative_mask] % len(self.get_axis(axis)) # If the `indices` array was modified because of the negative indices conversion # then the original order was broken and so we have to sort anyway: if has_negative or not are_indices_sorted: @@ -1966,7 +1966,7 @@ def _compute_tree_reduce_metadata(self, axis, new_parts, dtypes=None): new_axes, new_axes_lengths = [0, 0], [0, 0] new_axes[axis] = [MODIN_UNNAMED_SERIES_LABEL] - new_axes[axis ^ 1] = self.axes[axis ^ 1] + new_axes[axis ^ 1] = self.get_axis(axis ^ 1) new_axes_lengths[axis] = [1] new_axes_lengths[axis ^ 1] = self._axes_lengths[axis ^ 1] @@ -2553,7 +2553,7 @@ def sort_function(df): # pragma: no cover return df # If this df is empty, we don't want to try and shuffle or sort. - if len(self.axes[0]) == 0 or len(self.axes[1]) == 0: + if len(self.get_axis(0)) == 0 or len(self.get_axis(1)) == 0: return self.copy() axis = Axis(axis) @@ -2992,7 +2992,7 @@ def broadcast_apply( axis, other, join_type, - sort=not self.axes[axis].equals(other.axes[axis]), + sort=not self.get_axis(axis).equals(other.get_axis(axis)), ) # unwrap list returned by `copartition`. right_parts = right_parts[0] @@ -3133,7 +3133,7 @@ def broadcast_apply_select_indices( if other is None: if apply_indices is None: - apply_indices = self.axes[axis][numeric_indices] + apply_indices = self.get_axis(axis)[numeric_indices] return self.apply_select_indices( axis=axis, func=func, @@ -3233,7 +3233,7 @@ def broadcast_apply_full_axis( other = [o._partitions for o in other] if len(other) else None if apply_indices is not None: - numeric_indices = self.axes[axis ^ 1].get_indexer_for(apply_indices) + numeric_indices = self.get_axis(axis ^ 1).get_indexer_for(apply_indices) apply_indices = self._get_dict_of_block_index( axis ^ 1, numeric_indices ).keys() @@ -3389,8 +3389,8 @@ def _copartition(self, axis, other, how, sort, force_repartition=False): if isinstance(other, type(self)): other = [other] - self_index = self.axes[axis] - others_index = [o.axes[axis] for o in other] + self_index = self.get_axis(axis) + others_index = [o.get_axis(axis) for o in other] joined_index, make_reindexer = self._join_index_objects( axis, [self_index] + others_index, how, sort ) @@ -3416,7 +3416,7 @@ def _copartition(self, axis, other, how, sort, force_repartition=False): # Picking first non-empty frame base_frame = frames[non_empty_frames_idx[0]] - base_index = base_frame.axes[axis] + base_index = base_frame.get_axis(axis) # define conditions for reindexing and repartitioning `self` frame do_reindex_base = not base_index.equals(joined_index) @@ -3443,7 +3443,7 @@ def _copartition(self, axis, other, how, sort, force_repartition=False): # define conditions for reindexing and repartitioning `other` frames do_reindex_others = [ - not o.axes[axis].equals(joined_index) for o in other_frames + not o.get_axis(axis).equals(joined_index) for o in other_frames ] do_repartition_others = [None] * len(other_frames) @@ -3924,7 +3924,7 @@ def groupby_reduce( self._propagate_index_objs(axis=0) if apply_indices is not None: - numeric_indices = self.axes[axis ^ 1].get_indexer_for(apply_indices) + numeric_indices = self.get_axis(axis ^ 1).get_indexer_for(apply_indices) apply_indices = list( self._get_dict_of_block_index(axis ^ 1, numeric_indices).keys() ) diff --git a/modin/pandas/base.py b/modin/pandas/base.py index d2921f49959..aedeed9555c 100644 --- a/modin/pandas/base.py +++ b/modin/pandas/base.py @@ -340,7 +340,7 @@ def _validate_other( elif is_dict_like(other): other_dtypes = [ type(other[label]) - for label in self._query_compiler.get_axis(axis) + for label in self._get_axis(axis) # The binary operation is applied for intersection of axis labels # and dictionary keys. So filtering out extra keys. if label in other @@ -359,9 +359,7 @@ def _validate_other( # dictionary. self_dtypes = [ dtype - for label, dtype in zip( - self._query_compiler.get_axis(axis), self._get_dtypes() - ) + for label, dtype in zip(self._get_axis(axis), self._get_dtypes()) if label in other ] @@ -405,7 +403,7 @@ def error_raiser(msg, exception=Exception): [self._validate_function(fn, on_invalid) for fn in func.values()] return # We also could validate this, but it may be quite expensive for lazy-frames - # if not all(idx in self.axes[axis] for idx in func.keys()): + # if not all(idx in self._get_axis(axis) for idx in func.keys()): # error_raiser("Invalid dict keys", KeyError) if not is_list_like(func): @@ -626,6 +624,22 @@ def _get_index(self): index = property(_get_index, _set_index) + def _get_axis(self, axis): + """ + Return index labels of the specified axis. + + Parameters + ---------- + axis : {0, 1} + Axis to return labels on. + 0 is for index, when 1 is for columns. + + Returns + ------- + pandas.Index + """ + return self.index if axis == 0 else self.columns + def add( self, other, axis="columns", level=None, fill_value=None ): # noqa: PR01, RT01, D200 @@ -2466,7 +2480,7 @@ def reorder_levels(self, order, axis=0): # noqa: PR01, RT01, D200 Rearrange index levels using input order. """ axis = self._get_axis_number(axis) - new_labels = self.axes[axis].reorder_levels(order) + new_labels = self._get_axis(axis).reorder_levels(order) return self.set_axis(new_labels, axis=axis) def resample( @@ -2727,7 +2741,7 @@ def sample( # Index of the weights Series should correspond to the index of the # Dataframe in order to sample if isinstance(weights, BasePandasDataset): - weights = weights.reindex(self.axes[axis]) + weights = weights.reindex(self._get_axis(axis)) # If weights arg is a string, the weights used for sampling will # the be values in the column corresponding to that string if isinstance(weights, str): @@ -3509,15 +3523,15 @@ def truncate( """ axis = self._get_axis_number(axis) if ( - not self.axes[axis].is_monotonic_increasing - and not self.axes[axis].is_monotonic_decreasing + not self._get_axis(axis).is_monotonic_increasing + and not self._get_axis(axis).is_monotonic_decreasing ): raise ValueError("truncate requires a sorted index") if before is not None and after is not None and before > after: raise ValueError(f"Truncate: {after} must be after {before}") - s = slice(*self.axes[axis].slice_locs(before, after)) + s = slice(*self._get_axis(axis).slice_locs(before, after)) slice_obj = s if axis == 0 else (slice(None), s) return self.iloc[slice_obj] diff --git a/modin/pandas/dataframe.py b/modin/pandas/dataframe.py index aa1fc8841ed..a98199ee195 100644 --- a/modin/pandas/dataframe.py +++ b/modin/pandas/dataframe.py @@ -516,7 +516,7 @@ def groupby( (hashable(o) and (o in self)) or isinstance(o, Series) or (isinstance(o, pandas.Grouper) and o.key in self) - or (is_list_like(o) and len(o) == len(self.axes[axis])) + or (is_list_like(o) and len(o) == len(self._get_axis(axis))) ) for o in by ): @@ -546,7 +546,7 @@ def groupby( drop = True else: - mismatch = len(by) != len(self.axes[axis]) + mismatch = len(by) != len(self._get_axis(axis)) if mismatch and all( hashable(obj) and ( From d117e99f151284d8596fceb9c27078747c3fa3da Mon Sep 17 00:00:00 2001 From: Anatoly Myachev Date: Tue, 7 Nov 2023 10:51:41 +0100 Subject: [PATCH 070/201] PERF-#6714: Assign 'qc._shape_hint = column' in 'columnarize' func (#6715) Signed-off-by: Anatoly Myachev --- modin/core/storage_formats/base/query_compiler.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/modin/core/storage_formats/base/query_compiler.py b/modin/core/storage_formats/base/query_compiler.py index bc311201d91..ada3f3db3d3 100644 --- a/modin/core/storage_formats/base/query_compiler.py +++ b/modin/core/storage_formats/base/query_compiler.py @@ -1242,11 +1242,13 @@ def columnarize(self): if self._shape_hint == "column": return self + result = self if len(self.columns) != 1 or ( len(self.index) == 1 and self.index[0] == MODIN_UNNAMED_SERIES_LABEL ): - return self.transpose() - return self + result = self.transpose() + result._shape_hint = "column" + return result def is_series_like(self): """ From fb75744d9967af03a4ac3cbcf0807e1e0819ad3a Mon Sep 17 00:00:00 2001 From: Anatoly Myachev Date: Tue, 7 Nov 2023 14:05:38 +0100 Subject: [PATCH 071/201] FIX-#6703: don't use 'set_index_name(None)' (#6698) Signed-off-by: Anatoly Myachev --- modin/pandas/groupby.py | 149 +++++++++++++++------------------------- 1 file changed, 55 insertions(+), 94 deletions(-) diff --git a/modin/pandas/groupby.py b/modin/pandas/groupby.py index fb496cd979c..61854faaabd 100644 --- a/modin/pandas/groupby.py +++ b/modin/pandas/groupby.py @@ -58,7 +58,6 @@ "_axis", "_by", "_check_index", - "_check_index_name", "_columns", "_compute_index_grouped", "_default_to_pandas", @@ -487,14 +486,12 @@ def _shift(data, periods, freq, axis, fill_value, is_set_nan_rows=True): else: result = result.sort_index() else: - result = self._check_index_name( - self._wrap_aggregation( - type(self._query_compiler).groupby_shift, - numeric_only=False, - agg_kwargs=dict( - periods=periods, freq=freq, axis=axis, fill_value=fill_value - ), - ) + result = self._wrap_aggregation( + type(self._query_compiler).groupby_shift, + numeric_only=False, + agg_kwargs=dict( + periods=periods, freq=freq, axis=axis, fill_value=fill_value + ), ) return result @@ -528,12 +525,10 @@ def cumsum(self, axis=lib.no_default, *args, **kwargs): self._deprecate_axis(axis, "cumsum") else: axis = 0 - return self._check_index_name( - self._wrap_aggregation( - type(self._query_compiler).groupby_cumsum, - agg_args=args, - agg_kwargs=dict(axis=axis, **kwargs), - ) + return self._wrap_aggregation( + type(self._query_compiler).groupby_cumsum, + agg_args=args, + agg_kwargs=dict(axis=axis, **kwargs), ) _indices_cache = lib.no_default @@ -606,17 +601,15 @@ def pct_change( ): raise TypeError(f"unsupported operand type for -: got {dtype}") - return self._check_index_name( - self._wrap_aggregation( - type(self._query_compiler).groupby_pct_change, - agg_kwargs=dict( - periods=periods, - fill_method=fill_method, - limit=limit, - freq=freq, - axis=axis, - ), - ) + return self._wrap_aggregation( + type(self._query_compiler).groupby_pct_change, + agg_kwargs=dict( + periods=periods, + fill_method=fill_method, + limit=limit, + freq=freq, + axis=axis, + ), ) def filter(self, func, dropna=True, *args, **kwargs): @@ -646,12 +639,10 @@ def cummax(self, axis=lib.no_default, numeric_only=False, **kwargs): self._deprecate_axis(axis, "cummax") else: axis = 0 - return self._check_index_name( - self._wrap_aggregation( - type(self._query_compiler).groupby_cummax, - agg_kwargs=dict(axis=axis, **kwargs), - numeric_only=numeric_only, - ) + return self._wrap_aggregation( + type(self._query_compiler).groupby_cummax, + agg_kwargs=dict(axis=axis, **kwargs), + numeric_only=numeric_only, ) def apply(self, func, *args, **kwargs): @@ -831,12 +822,10 @@ def cummin(self, axis=lib.no_default, numeric_only=False, **kwargs): self._deprecate_axis(axis, "cummin") else: axis = 0 - return self._check_index_name( - self._wrap_aggregation( - type(self._query_compiler).groupby_cummin, - agg_kwargs=dict(axis=axis, **kwargs), - numeric_only=numeric_only, - ) + return self._wrap_aggregation( + type(self._query_compiler).groupby_cummin, + agg_kwargs=dict(axis=axis, **kwargs), + numeric_only=numeric_only, ) def bfill(self, limit=None): @@ -1034,8 +1023,6 @@ def rank( ), numeric_only=False, ) - # pandas does not name the index on rank - result._query_compiler.set_index_name(None) return result @property @@ -1218,12 +1205,10 @@ def cumprod(self, axis=lib.no_default, *args, **kwargs): self._deprecate_axis(axis, "cumprod") else: axis = 0 - return self._check_index_name( - self._wrap_aggregation( - type(self._query_compiler).groupby_cumprod, - agg_args=args, - agg_kwargs=dict(axis=axis, **kwargs), - ) + return self._wrap_aggregation( + type(self._query_compiler).groupby_cumprod, + agg_args=args, + agg_kwargs=dict(axis=axis, **kwargs), ) def __iter__(self): @@ -1244,15 +1229,13 @@ def transform(self, func, *args, engine=None, engine_kwargs=None, **kwargs): ) ) - return self._check_index_name( - self._wrap_aggregation( - qc_method=type(self._query_compiler).groupby_agg, - numeric_only=False, - agg_func=func, - agg_args=args, - agg_kwargs=kwargs, - how="transform", - ) + return self._wrap_aggregation( + qc_method=type(self._query_compiler).groupby_agg, + numeric_only=False, + agg_func=func, + agg_args=args, + agg_kwargs=kwargs, + how="transform", ) def corr(self, method="pearson", min_periods=1, numeric_only=False): @@ -1297,19 +1280,17 @@ def fillna( drop=self._drop, **new_groupby_kwargs, ) - return work_object._check_index_name( - work_object._wrap_aggregation( - type(self._query_compiler).groupby_fillna, - agg_kwargs=dict( - value=value, - method=method, - axis=axis, - inplace=inplace, - limit=limit, - downcast=downcast, - ), - numeric_only=False, - ) + return work_object._wrap_aggregation( + type(self._query_compiler).groupby_fillna, + agg_kwargs=dict( + value=value, + method=method, + axis=axis, + inplace=inplace, + limit=limit, + downcast=downcast, + ), + numeric_only=False, ) def count(self): @@ -1437,14 +1418,12 @@ def diff(self, periods=1, axis=lib.no_default): ): raise TypeError(f"unsupported operand type for -: got {dtype}") - return self._check_index_name( - self._wrap_aggregation( - type(self._query_compiler).groupby_diff, - agg_kwargs=dict( - periods=periods, - axis=axis, - ), - ) + return self._wrap_aggregation( + type(self._query_compiler).groupby_diff, + agg_kwargs=dict( + periods=periods, + axis=axis, + ), ) def take(self, indices, axis=lib.no_default, **kwargs): @@ -1697,24 +1676,6 @@ def _check_index(self, result): return result - def _check_index_name(self, result): - """ - Check the result of groupby aggregation on the need of resetting index name. - - Parameters - ---------- - result : DataFrame - Group by aggregation result. - - Returns - ------- - DataFrame - """ - if self._by is not None: - # pandas does not name the index for this case - result._query_compiler.set_index_name(None) - return result - def _default_to_pandas(self, f, *args, **kwargs): """ Execute function `f` in default-to-pandas way. From f436a305b56cb342b821f7193eb362106adcbe9b Mon Sep 17 00:00:00 2001 From: Anatoly Myachev Date: Tue, 7 Nov 2023 15:41:34 +0100 Subject: [PATCH 072/201] PERF-#6716: Avoid materializing axes in `_filter_empties` (#6717) Signed-off-by: Anatoly Myachev Co-authored-by: Dmitry Chigarev --- modin/core/dataframe/pandas/dataframe/dataframe.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/modin/core/dataframe/pandas/dataframe/dataframe.py b/modin/core/dataframe/pandas/dataframe/dataframe.py index 233bca9f71c..35f17c029d3 100644 --- a/modin/core/dataframe/pandas/dataframe/dataframe.py +++ b/modin/core/dataframe/pandas/dataframe/dataframe.py @@ -755,15 +755,19 @@ def _filter_empties(self, compute_metadata=True): Trigger the computations for partition sizes and labels if they're not done already. """ if not compute_metadata and ( - not self.has_materialized_index - or not self.has_materialized_columns - or self._row_lengths_cache is None - or self._column_widths_cache is None + self._row_lengths_cache is None or self._column_widths_cache is None ): # do not trigger the computations return - if len(self.get_axis(0)) == 0 or len(self.get_axis(1)) == 0: + if ( + self.has_materialized_index + and len(self.index) == 0 + or self.has_materialized_columns + and len(self.columns) == 0 + or sum(self.row_lengths) == 0 + or sum(self.column_widths) == 0 + ): # This is the case for an empty frame. We don't want to completely remove # all metadata and partitions so for the moment, we won't prune if the frame # is empty. From 90ec6793b82471b4fec90a7e648eb9ccfb4632bf Mon Sep 17 00:00:00 2001 From: Anatoly Myachev Date: Tue, 7 Nov 2023 15:42:37 +0100 Subject: [PATCH 073/201] PERF-#6718: Use '_get_axis_lengths' func instead of '_axes_lengths' property (#6719) Signed-off-by: Anatoly Myachev --- .../core/dataframe/pandas/dataframe/dataframe.py | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/modin/core/dataframe/pandas/dataframe/dataframe.py b/modin/core/dataframe/pandas/dataframe/dataframe.py index 35f17c029d3..79728b22591 100644 --- a/modin/core/dataframe/pandas/dataframe/dataframe.py +++ b/modin/core/dataframe/pandas/dataframe/dataframe.py @@ -223,18 +223,6 @@ def column_widths(self): self._column_widths_cache = [] return self._column_widths_cache - @property - def _axes_lengths(self): - """ - Get a pair of row partitions lengths and column partitions widths. - - Returns - ------- - list - The pair of row partitions lengths and column partitions widths. - """ - return [self.row_lengths, self.column_widths] - def _set_axis_lengths_cache(self, value, axis=0): """ Set the row/column lengths cache for the specified axis. @@ -1973,7 +1961,7 @@ def _compute_tree_reduce_metadata(self, axis, new_parts, dtypes=None): new_axes[axis ^ 1] = self.get_axis(axis ^ 1) new_axes_lengths[axis] = [1] - new_axes_lengths[axis ^ 1] = self._axes_lengths[axis ^ 1] + new_axes_lengths[axis ^ 1] = self._get_axis_lengths(axis ^ 1) if dtypes == "copy": dtypes = self.copy_dtypes_cache() @@ -3443,7 +3431,7 @@ def _copartition(self, axis, other, how, sort, force_repartition=False): reindexed_base = base_frame._partitions base_lengths = base_frame.column_widths if axis else base_frame.row_lengths - others_lengths = [o._axes_lengths[axis] for o in other_frames] + others_lengths = [o._get_axis_lengths(axis) for o in other_frames] # define conditions for reindexing and repartitioning `other` frames do_reindex_others = [ From bb94bbeedc452f63d3c678a63027cecb233faf3e Mon Sep 17 00:00:00 2001 From: Anatoly Myachev Date: Tue, 7 Nov 2023 17:32:04 +0100 Subject: [PATCH 074/201] FIX-#6693: revert creating an additional copy in 'astype' op (#6692) Signed-off-by: Anatoly Myachev --- modin/core/dataframe/pandas/dataframe/dataframe.py | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/modin/core/dataframe/pandas/dataframe/dataframe.py b/modin/core/dataframe/pandas/dataframe/dataframe.py index 79728b22591..33ae3e3249b 100644 --- a/modin/core/dataframe/pandas/dataframe/dataframe.py +++ b/modin/core/dataframe/pandas/dataframe/dataframe.py @@ -28,7 +28,7 @@ from pandas.core.dtypes.common import is_dtype_equal, is_list_like, is_numeric_dtype from pandas.core.indexes.api import Index, RangeIndex -from modin.config import Engine, IsRayCluster, NPartitions +from modin.config import IsRayCluster, NPartitions from modin.core.dataframe.base.dataframe.dataframe import ModinDataframe from modin.core.dataframe.base.dataframe.utils import Axis, JoinType from modin.core.dataframe.pandas.dataframe.utils import ( @@ -1504,7 +1504,6 @@ def astype(self, col_dtypes, errors: str = "raise"): # will store the encoded table. That can lead to higher memory footprint. # TODO: Revisit if this hurts users. use_full_axis_cast = False - has_categorical_cast = False for column, dtype in col_dtypes.items(): if not is_dtype_equal(dtype, self_dtypes[column]): if new_dtypes is None: @@ -1529,7 +1528,7 @@ def astype(self, col_dtypes, errors: str = "raise"): columns=[column] )[column], ) - use_full_axis_cast = has_categorical_cast = True + use_full_axis_cast = True else: new_dtypes[column] = new_dtype @@ -1538,14 +1537,7 @@ def astype(self, col_dtypes, errors: str = "raise"): def astype_builder(df): """Compute new partition frame with dtypes updated.""" - # TODO(https://github.com/modin-project/modin/issues/6266): Remove this - # copy, which is a workaround for https://github.com/pandas-dev/pandas/issues/53658 - df_for_astype = ( - df.copy(deep=True) - if Engine.get() == "Ray" and has_categorical_cast - else df - ) - return df_for_astype.astype( + return df.astype( {k: v for k, v in col_dtypes.items() if k in df}, errors=errors ) From 3a236237daca0f98043453f19c37b01e9dea36b9 Mon Sep 17 00:00:00 2001 From: Anatoly Myachev Date: Thu, 9 Nov 2023 10:44:31 +0100 Subject: [PATCH 075/201] PERF-#6727: Remove remaining 'result.name = None' in groupby code (#6726) Signed-off-by: Anatoly Myachev --- modin/pandas/groupby.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/modin/pandas/groupby.py b/modin/pandas/groupby.py index 61854faaabd..7f6f9193c26 100644 --- a/modin/pandas/groupby.py +++ b/modin/pandas/groupby.py @@ -1092,8 +1092,6 @@ def size(self): ) elif isinstance(self._df, Series): result.name = self._df.name - else: - result.name = None return result def sum(self, numeric_only=False, min_count=0, engine=None, engine_kwargs=None): @@ -1163,8 +1161,6 @@ def ngroup(self, ascending=True): if not isinstance(result, Series): # The result should always be a Series with name None and type int64 result = result.squeeze(axis=1) - # TODO: this might not hold in the future - result.name = None return result def nunique(self, dropna=True): @@ -1311,7 +1307,6 @@ def cumcount(self, ascending=True): if not isinstance(result, Series): # The result should always be a Series with name None and type int64 result = result.squeeze(axis=1) - result.name = None return result def tail(self, n=5): From eceb566e87359db705835506d699c7e5aa9238c4 Mon Sep 17 00:00:00 2001 From: Anatoly Myachev Date: Thu, 9 Nov 2023 10:44:54 +0100 Subject: [PATCH 076/201] FIX-#6664: Use '@lazy_metadata_decorator' for 'PandasDataFrame.finalize' (#6720) Signed-off-by: Anatoly Myachev --- modin/core/dataframe/pandas/dataframe/dataframe.py | 1 + 1 file changed, 1 insertion(+) diff --git a/modin/core/dataframe/pandas/dataframe/dataframe.py b/modin/core/dataframe/pandas/dataframe/dataframe.py index 33ae3e3249b..fcdc75de27b 100644 --- a/modin/core/dataframe/pandas/dataframe/dataframe.py +++ b/modin/core/dataframe/pandas/dataframe/dataframe.py @@ -4105,6 +4105,7 @@ def transpose(self): dtypes=new_dtypes, ) + @lazy_metadata_decorator(apply_axis="both") def finalize(self): """ Perform all deferred calls on partitions. From 28b3697f5e18c71ad20e6e69819e6178365594e0 Mon Sep 17 00:00:00 2001 From: Anatoly Myachev Date: Thu, 9 Nov 2023 14:07:42 +0100 Subject: [PATCH 077/201] PERF-#6723: Use `_shape_hint = "column"` in `DataFrame.squeeze` (#6724) Signed-off-by: Anatoly Myachev Co-authored-by: Dmitry Chigarev --- modin/pandas/dataframe.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/modin/pandas/dataframe.py b/modin/pandas/dataframe.py index a98199ee195..e87c6b780c1 100644 --- a/modin/pandas/dataframe.py +++ b/modin/pandas/dataframe.py @@ -1972,9 +1972,12 @@ def squeeze(self, axis=None): # noqa: PR01, RT01, D200 if axis is None and (len(self.columns) == 1 or len(self.index) == 1): return Series(query_compiler=self._query_compiler).squeeze() if axis == 1 and len(self.columns) == 1: + self._query_compiler._shape_hint = "column" return Series(query_compiler=self._query_compiler) if axis == 0 and len(self.index) == 1: - return Series(query_compiler=self.T._query_compiler) + qc = self.T._query_compiler + qc._shape_hint = "column" + return Series(query_compiler=qc) else: return self.copy() From 8a332c1597c54d36f7ccbbd544e186b689f9ceb1 Mon Sep 17 00:00:00 2001 From: Anatoly Myachev Date: Thu, 9 Nov 2023 18:40:43 +0100 Subject: [PATCH 078/201] PERF-#6721: Use 'keep_partitioning=True', for 'duplicated' implementation (#6722) Signed-off-by: Anatoly Myachev --- modin/core/storage_formats/pandas/query_compiler.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modin/core/storage_formats/pandas/query_compiler.py b/modin/core/storage_formats/pandas/query_compiler.py index 99dcb2f77e4..820e355c43b 100644 --- a/modin/core/storage_formats/pandas/query_compiler.py +++ b/modin/core/storage_formats/pandas/query_compiler.py @@ -3047,7 +3047,7 @@ def _compute_duplicated(df): # pragma: no cover new_index=self._modin_frame.copy_index_cache(), new_columns=[MODIN_UNNAMED_SERIES_LABEL], dtypes=np.bool_, - keep_partitioning=False, + keep_partitioning=True, ) return self.__constructor__(new_modin_frame, shape_hint="column") From 41ecc925c9f7b11e385a16f6b664fa2b15b34edd Mon Sep 17 00:00:00 2001 From: Andrey Pavlenko Date: Mon, 13 Nov 2023 17:22:22 +0100 Subject: [PATCH 079/201] PERF-#6696: Use cached dtypes in fillna when possible. (#6697) Signed-off-by: Andrey Pavlenko --- .../storage_formats/pandas/query_compiler.py | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/modin/core/storage_formats/pandas/query_compiler.py b/modin/core/storage_formats/pandas/query_compiler.py index 820e355c43b..8c06810a90c 100644 --- a/modin/core/storage_formats/pandas/query_compiler.py +++ b/modin/core/storage_formats/pandas/query_compiler.py @@ -2420,6 +2420,7 @@ def fillna(self, **kwargs): method = kwargs.get("method", None) limit = kwargs.get("limit", None) full_axis = method is not None or limit is not None + new_dtypes = None if isinstance(value, BaseQueryCompiler): if squeeze_self: # Self is a Series type object @@ -2487,7 +2488,25 @@ def fillna(df): } return df.fillna(value=func_dict, **kwargs) + if self._modin_frame.has_materialized_dtypes: + dtypes = self._modin_frame.dtypes + value_dtypes = pandas.DataFrame( + {k: [v] for (k, v) in value.items()} + ).dtypes + if all( + find_common_type([dtypes[col], dtype]) == dtypes[col] + for (col, dtype) in value_dtypes.items() + if col in dtypes + ): + new_dtypes = dtypes + else: + if self._modin_frame.has_materialized_dtypes: + dtype = pandas.Series(value).dtype + if all( + find_common_type([t, dtype]) == t for t in self._modin_frame.dtypes + ): + new_dtypes = self._modin_frame.dtypes def fillna(df): return df.fillna(value=value, **kwargs) @@ -2495,7 +2514,7 @@ def fillna(df): if full_axis: new_modin_frame = self._modin_frame.fold(axis, fillna) else: - new_modin_frame = self._modin_frame.map(fillna) + new_modin_frame = self._modin_frame.map(fillna, dtypes=new_dtypes) return self.__constructor__(new_modin_frame) def quantile_for_list_of_values(self, **kwargs): From 7de7b92834c5e96cc4c74f1d1563a60a467ec4f8 Mon Sep 17 00:00:00 2001 From: Anatoly Myachev Date: Tue, 14 Nov 2023 20:51:59 +0100 Subject: [PATCH 080/201] FIX-#6594: fix usage of Modin objects inside UDFs for `apply` (#6673) Signed-off-by: Anatoly Myachev --- .../dataframe/pandas/dataframe/dataframe.py | 10 +++++++ .../pandas/partitioning/partition_manager.py | 23 +++++++++++++-- .../pandas_on_dask/dataframe/dataframe.py | 23 +++++++++++++++ .../pandas_on_unidist/dataframe/dataframe.py | 4 +++ .../storage_formats/base/query_compiler.py | 10 +++++++ .../storage_formats/hdk/query_compiler.py | 3 ++ modin/pandas/dataframe.py | 29 +++++++++++++++---- modin/pandas/series.py | 29 +++++++++++++++---- modin/pandas/test/dataframe/test_pickle.py | 29 ++++++++++++++++++- modin/pandas/test/test_series.py | 27 +++++++++++++++++ 10 files changed, 174 insertions(+), 13 deletions(-) diff --git a/modin/core/dataframe/pandas/dataframe/dataframe.py b/modin/core/dataframe/pandas/dataframe/dataframe.py index fcdc75de27b..8dd791dc1a4 100644 --- a/modin/core/dataframe/pandas/dataframe/dataframe.py +++ b/modin/core/dataframe/pandas/dataframe/dataframe.py @@ -4119,6 +4119,16 @@ def wait_computations(self): """Wait for all computations to complete without materializing data.""" self._partition_mgr_cls.wait_partitions(self._partitions.flatten()) + def support_materialization_in_worker_process(self) -> bool: + """ + Whether it's possible to call function `to_pandas` during the pickling process, at the moment of recreating the object. + + Returns + ------- + bool + """ + return True + def __dataframe__(self, nan_as_null: bool = False, allow_copy: bool = True): """ Get a Modin DataFrame that implements the dataframe exchange protocol. diff --git a/modin/core/dataframe/pandas/partitioning/partition_manager.py b/modin/core/dataframe/pandas/partitioning/partition_manager.py index fea0c686a96..2b780e79b8f 100644 --- a/modin/core/dataframe/pandas/partitioning/partition_manager.py +++ b/modin/core/dataframe/pandas/partitioning/partition_manager.py @@ -27,7 +27,13 @@ import pandas from pandas._libs.lib import no_default -from modin.config import BenchmarkMode, Engine, NPartitions, ProgressBar +from modin.config import ( + BenchmarkMode, + Engine, + NPartitions, + PersistentPickle, + ProgressBar, +) from modin.core.dataframe.pandas.utils import concatenate from modin.core.storage_formats.pandas.utils import compute_chunksize from modin.error_message import ErrorMessage @@ -121,7 +127,20 @@ def preprocess_func(cls, map_func): `map_func` if the `apply` method of the `PandasDataframePartition` object you are using does not require any modification to a given function. """ - return cls._partition_class.preprocess_func(map_func) + old_value = PersistentPickle.get() + # When performing a function with Modin objects, it is more profitable to + # do the conversion to pandas once on the main process than several times + # on worker processes. Details: https://github.com/modin-project/modin/pull/6673/files#r1391086755 + # For Dask, otherwise there may be an error: `coroutine 'Client._gather' was never awaited` + need_update = not PersistentPickle.get() and Engine.get() != "Dask" + if need_update: + PersistentPickle.put(True) + try: + result = cls._partition_class.preprocess_func(map_func) + finally: + if need_update: + PersistentPickle.put(old_value) + return result # END Abstract Methods diff --git a/modin/core/execution/dask/implementations/pandas_on_dask/dataframe/dataframe.py b/modin/core/execution/dask/implementations/pandas_on_dask/dataframe/dataframe.py index 77c78e29f9c..0920d963840 100644 --- a/modin/core/execution/dask/implementations/pandas_on_dask/dataframe/dataframe.py +++ b/modin/core/execution/dask/implementations/pandas_on_dask/dataframe/dataframe.py @@ -41,3 +41,26 @@ class PandasOnDaskDataframe(PandasDataframe): """ _partition_mgr_cls = PandasOnDaskDataframePartitionManager + + @classmethod + def reconnect(cls, address, attributes): # noqa: GL08 + # The main goal is to configure the client for the worker process + # using the address passed by the custom `__reduce__` function + try: + from distributed import default_client + + default_client() + except ValueError: + from distributed import Client + + # setup `default_client` for worker process + _ = Client(address) + obj = cls.__new__(cls) + obj.__dict__.update(attributes) + return obj + + def __reduce__(self): # noqa: GL08 + from distributed import default_client + + address = default_client().scheduler_info()["address"] + return self.reconnect, (address, self.__dict__) diff --git a/modin/core/execution/unidist/implementations/pandas_on_unidist/dataframe/dataframe.py b/modin/core/execution/unidist/implementations/pandas_on_unidist/dataframe/dataframe.py index 17e58d76b13..3241e9299e8 100644 --- a/modin/core/execution/unidist/implementations/pandas_on_unidist/dataframe/dataframe.py +++ b/modin/core/execution/unidist/implementations/pandas_on_unidist/dataframe/dataframe.py @@ -41,3 +41,7 @@ class PandasOnUnidistDataframe(PandasDataframe): """ _partition_mgr_cls = PandasOnUnidistDataframePartitionManager + + def support_materialization_in_worker_process(self) -> bool: + # more details why this is not `True` in https://github.com/modin-project/modin/pull/6673 + return False diff --git a/modin/core/storage_formats/base/query_compiler.py b/modin/core/storage_formats/base/query_compiler.py index ada3f3db3d3..4f240c6d479 100644 --- a/modin/core/storage_formats/base/query_compiler.py +++ b/modin/core/storage_formats/base/query_compiler.py @@ -325,6 +325,16 @@ def execute(self): """Wait for all computations to complete without materializing data.""" pass + def support_materialization_in_worker_process(self) -> bool: + """ + Whether it's possible to call function `to_pandas` during the pickling process, at the moment of recreating the object. + + Returns + ------- + bool + """ + return self._modin_frame.support_materialization_in_worker_process() + # END Data Management Methods # To/From Pandas diff --git a/modin/experimental/core/storage_formats/hdk/query_compiler.py b/modin/experimental/core/storage_formats/hdk/query_compiler.py index b4a580478cd..31bf0c7a460 100644 --- a/modin/experimental/core/storage_formats/hdk/query_compiler.py +++ b/modin/experimental/core/storage_formats/hdk/query_compiler.py @@ -191,6 +191,9 @@ def force_import(self): # HDK-specific method self._modin_frame.force_import() + def support_materialization_in_worker_process(self) -> bool: + return True + def to_pandas(self): return self._modin_frame.to_pandas() diff --git a/modin/pandas/dataframe.py b/modin/pandas/dataframe.py index e87c6b780c1..4251e4b8f55 100644 --- a/modin/pandas/dataframe.py +++ b/modin/pandas/dataframe.py @@ -18,6 +18,7 @@ import datetime import functools import itertools +import os import re import sys import warnings @@ -3107,7 +3108,7 @@ def _getitem(self, key): # Persistance support methods - BEGIN @classmethod - def _inflate_light(cls, query_compiler): + def _inflate_light(cls, query_compiler, source_pid): """ Re-creates the object from previously-serialized lightweight representation. @@ -3117,16 +3118,23 @@ def _inflate_light(cls, query_compiler): ---------- query_compiler : BaseQueryCompiler Query compiler to use for object re-creation. + source_pid : int + Determines whether a Modin or pandas object needs to be created. + Modin objects are created only on the main process. Returns ------- DataFrame New ``DataFrame`` based on the `query_compiler`. """ + if os.getpid() != source_pid: + return query_compiler.to_pandas() + # The current logic does not involve creating Modin objects + # and manipulation with them in worker processes return cls(query_compiler=query_compiler) @classmethod - def _inflate_full(cls, pandas_df): + def _inflate_full(cls, pandas_df, source_pid): """ Re-creates the object from previously-serialized disk-storable representation. @@ -3134,18 +3142,29 @@ def _inflate_full(cls, pandas_df): ---------- pandas_df : pandas.DataFrame Data to use for object re-creation. + source_pid : int + Determines whether a Modin or pandas object needs to be created. + Modin objects are created only on the main process. Returns ------- DataFrame New ``DataFrame`` based on the `pandas_df`. """ + if os.getpid() != source_pid: + return pandas_df + # The current logic does not involve creating Modin objects + # and manipulation with them in worker processes return cls(data=from_pandas(pandas_df)) def __reduce__(self): self._query_compiler.finalize() - if PersistentPickle.get(): - return self._inflate_full, (self._to_pandas(),) - return self._inflate_light, (self._query_compiler,) + pid = os.getpid() + if ( + PersistentPickle.get() + or not self._query_compiler.support_materialization_in_worker_process() + ): + return self._inflate_full, (self._to_pandas(), pid) + return self._inflate_light, (self._query_compiler, pid) # Persistance support methods - END diff --git a/modin/pandas/series.py b/modin/pandas/series.py index 18cb621cdae..849bd0ff656 100644 --- a/modin/pandas/series.py +++ b/modin/pandas/series.py @@ -15,6 +15,7 @@ from __future__ import annotations +import os import warnings from typing import IO, TYPE_CHECKING, Hashable, Optional, Union @@ -2497,7 +2498,7 @@ def _repartition(self): # Persistance support methods - BEGIN @classmethod - def _inflate_light(cls, query_compiler, name): + def _inflate_light(cls, query_compiler, name, source_pid): """ Re-creates the object from previously-serialized lightweight representation. @@ -2509,16 +2510,23 @@ def _inflate_light(cls, query_compiler, name): Query compiler to use for object re-creation. name : str The name to give to the new object. + source_pid : int + Determines whether a Modin or pandas object needs to be created. + Modin objects are created only on the main process. Returns ------- Series New Series based on the `query_compiler`. """ + if os.getpid() != source_pid: + return query_compiler.to_pandas() + # The current logic does not involve creating Modin objects + # and manipulation with them in worker processes return cls(query_compiler=query_compiler, name=name) @classmethod - def _inflate_full(cls, pandas_series): + def _inflate_full(cls, pandas_series, source_pid): """ Re-creates the object from previously-serialized disk-storable representation. @@ -2526,18 +2534,29 @@ def _inflate_full(cls, pandas_series): ---------- pandas_series : pandas.Series Data to use for object re-creation. + source_pid : int + Determines whether a Modin or pandas object needs to be created. + Modin objects are created only on the main process. Returns ------- Series New Series based on the `pandas_series`. """ + if os.getpid() != source_pid: + return pandas_series + # The current logic does not involve creating Modin objects + # and manipulation with them in worker processes return cls(data=pandas_series) def __reduce__(self): self._query_compiler.finalize() - if PersistentPickle.get(): - return self._inflate_full, (self._to_pandas(),) - return self._inflate_light, (self._query_compiler, self.name) + pid = os.getpid() + if ( + PersistentPickle.get() + or not self._query_compiler.support_materialization_in_worker_process() + ): + return self._inflate_full, (self._to_pandas(), pid) + return self._inflate_light, (self._query_compiler, self.name, pid) # Persistance support methods - END diff --git a/modin/pandas/test/dataframe/test_pickle.py b/modin/pandas/test/dataframe/test_pickle.py index a3ee843daa3..aed6b710b4b 100644 --- a/modin/pandas/test/dataframe/test_pickle.py +++ b/modin/pandas/test/dataframe/test_pickle.py @@ -18,7 +18,7 @@ import modin.pandas as pd from modin.config import PersistentPickle -from modin.pandas.test.utils import df_equals +from modin.pandas.test.utils import create_test_dfs, df_equals @pytest.fixture @@ -47,6 +47,33 @@ def test_dataframe_pickle(modin_df, persistent): df_equals(modin_df, other) +def test__reduce__(): + # `DataFrame.__reduce__` will be called implicitly when lambda expressions are + # pre-processed for the distributed engine. + dataframe_data = ["Major League Baseball", "National Basketball Association"] + abbr_md, abbr_pd = create_test_dfs(dataframe_data, index=["MLB", "NBA"]) + # breakpoint() + + dataframe_data = { + "name": ["Mariners", "Lakers"] * 500, + "league_abbreviation": ["MLB", "NBA"] * 500, + } + teams_md, teams_pd = create_test_dfs(dataframe_data) + + result_md = ( + teams_md.set_index("name") + .league_abbreviation.apply(lambda abbr: abbr_md[0].loc[abbr]) + .rename("league") + ) + + result_pd = ( + teams_pd.set_index("name") + .league_abbreviation.apply(lambda abbr: abbr_pd[0].loc[abbr]) + .rename("league") + ) + df_equals(result_md, result_pd) + + def test_column_pickle(modin_column, modin_df, persistent): dmp = pickle.dumps(modin_column) other = pickle.loads(dmp) diff --git a/modin/pandas/test/test_series.py b/modin/pandas/test/test_series.py index f193a8efb05..1b2c5eb0a5a 100644 --- a/modin/pandas/test/test_series.py +++ b/modin/pandas/test/test_series.py @@ -47,6 +47,7 @@ bool_arg_keys, bool_arg_values, categories_equals, + create_test_dfs, default_to_pandas_ignore_string, df_equals, df_equals_with_non_stable_indices, @@ -4794,3 +4795,29 @@ def test_binary_numpy_universal_function_issue_6483(): *create_test_series(test_data["float_nan_data"]), lambda series: np.arctan2(series, np.sin(series)), ) + + +def test__reduce__(): + # `Series.__reduce__` will be called implicitly when lambda expressions are + # pre-processed for the distributed engine. + series_data = ["Major League Baseball", "National Basketball Association"] + abbr_md, abbr_pd = create_test_series(series_data, index=["MLB", "NBA"]) + + dataframe_data = { + "name": ["Mariners", "Lakers"] * 500, + "league_abbreviation": ["MLB", "NBA"] * 500, + } + teams_md, teams_pd = create_test_dfs(dataframe_data) + + result_md = ( + teams_md.set_index("name") + .league_abbreviation.apply(lambda abbr: abbr_md.loc[abbr]) + .rename("league") + ) + + result_pd = ( + teams_pd.set_index("name") + .league_abbreviation.apply(lambda abbr: abbr_pd.loc[abbr]) + .rename("league") + ) + df_equals(result_md, result_pd) From aaaeabbe44fd64c0bb1977f62a97972de55f360e Mon Sep 17 00:00:00 2001 From: Anatoly Myachev Date: Thu, 16 Nov 2023 13:20:31 +0100 Subject: [PATCH 081/201] PERF-#6728: In the case of narrow dataframes, it is cheaper to convert partitions to numpy in the main process. (#6704) Signed-off-by: Anatoly Myachev --- .../generic/partitioning/partition_manager.py | 28 +++++++++++-------- .../generic/partitioning/partition_manager.py | 20 ++++++++----- 2 files changed, 29 insertions(+), 19 deletions(-) diff --git a/modin/core/execution/ray/generic/partitioning/partition_manager.py b/modin/core/execution/ray/generic/partitioning/partition_manager.py index 759b3bdb3e4..f5795d0778d 100644 --- a/modin/core/execution/ray/generic/partitioning/partition_manager.py +++ b/modin/core/execution/ray/generic/partitioning/partition_manager.py @@ -40,15 +40,19 @@ def to_numpy(cls, partitions, **kwargs): ------- NumPy array """ - parts = RayWrapper.materialize( - [ - obj.apply(lambda df, **kwargs: df.to_numpy(**kwargs)).list_of_blocks[0] - for row in partitions - for obj in row - ] - ) - n = partitions.shape[1] - parts = [parts[i * n : (i + 1) * n] for i in list(range(partitions.shape[0]))] - - arr = np.block(parts) - return arr + if partitions.shape[1] == 1: + parts = cls.get_objects_from_partitions(partitions.flatten()) + parts = [part.to_numpy() for part in parts] + else: + parts = RayWrapper.materialize( + [ + obj.apply( + lambda df, **kwargs: df.to_numpy(**kwargs) + ).list_of_blocks[0] + for row in partitions + for obj in row + ] + ) + rows, cols = partitions.shape + parts = [parts[i * cols : (i + 1) * cols] for i in range(rows)] + return np.block(parts) diff --git a/modin/core/execution/unidist/generic/partitioning/partition_manager.py b/modin/core/execution/unidist/generic/partitioning/partition_manager.py index 01c8b47d90c..666152a0376 100644 --- a/modin/core/execution/unidist/generic/partitioning/partition_manager.py +++ b/modin/core/execution/unidist/generic/partitioning/partition_manager.py @@ -40,13 +40,19 @@ def to_numpy(cls, partitions, **kwargs): ------- NumPy array """ - parts = UnidistWrapper.materialize( - [ - obj.apply(lambda df, **kwargs: df.to_numpy(**kwargs)).list_of_blocks[0] - for row in partitions - for obj in row - ] - ) + if partitions.shape[1] == 1: + parts = cls.get_objects_from_partitions(partitions.flatten()) + parts = [part.to_numpy() for part in parts] + else: + parts = UnidistWrapper.materialize( + [ + obj.apply( + lambda df, **kwargs: df.to_numpy(**kwargs) + ).list_of_blocks[0] + for row in partitions + for obj in row + ] + ) rows, cols = partitions.shape parts = [parts[i * cols : (i + 1) * cols] for i in range(rows)] return np.block(parts) From 2c6472c7c37698a2ba67fcb0538370302571e3ba Mon Sep 17 00:00:00 2001 From: Dmitry Chigarev Date: Thu, 16 Nov 2023 20:07:42 +0100 Subject: [PATCH 082/201] FIX-#6745: Pin 'unidist <= 0.4.1' (#6746) Signed-off-by: Dmitry Chigarev --- requirements/env_unidist_linux.yml | 2 +- requirements/env_unidist_win.yml | 2 +- setup.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/requirements/env_unidist_linux.yml b/requirements/env_unidist_linux.yml index 7589560c2b9..85c394656de 100644 --- a/requirements/env_unidist_linux.yml +++ b/requirements/env_unidist_linux.yml @@ -7,7 +7,7 @@ dependencies: # required dependencies - pandas>=2.1,<2.2 - numpy>=1.22.4 - - unidist-mpi>=0.2.1 + - unidist-mpi>=0.2.1,<=0.4.1 - mpich - fsspec>=2022.05.0 - packaging>=21.0 diff --git a/requirements/env_unidist_win.yml b/requirements/env_unidist_win.yml index f3b3459dab6..84faaef5fef 100644 --- a/requirements/env_unidist_win.yml +++ b/requirements/env_unidist_win.yml @@ -7,7 +7,7 @@ dependencies: # required dependencies - pandas>=2.1,<2.2 - numpy>=1.22.4 - - unidist-mpi>=0.2.1 + - unidist-mpi>=0.2.1,<=0.4.1 - msmpi - fsspec>=2022.05.0 - packaging>=21.0 diff --git a/setup.py b/setup.py index b1b6f39fbf0..f5c9ab72eb6 100644 --- a/setup.py +++ b/setup.py @@ -9,7 +9,7 @@ # ray==2.5.0 broken: https://github.com/conda-forge/ray-packages-feedstock/issues/100 # pydantic<2: https://github.com/modin-project/modin/issues/6336 ray_deps = ["ray[default]>=1.13.0,!=2.5.0", "pyarrow>=7.0.0", "pydantic<2"] -unidist_deps = ["unidist[mpi]>=0.2.1"] +unidist_deps = ["unidist[mpi]>=0.2.1,<=0.4.1"] spreadsheet_deps = ["modin-spreadsheet>=0.1.0"] all_deps = dask_deps + ray_deps + unidist_deps + spreadsheet_deps From 1b36f4c1f6ecfdbfc1868147b3518b897966f5b4 Mon Sep 17 00:00:00 2001 From: Dmitry Chigarev Date: Thu, 16 Nov 2023 21:35:19 +0100 Subject: [PATCH 083/201] FIX-#6732: Fix inferring result dtypes for binary operations (#6737) Signed-off-by: Dmitry Chigarev --- modin/core/dataframe/algebra/binary.py | 108 +++++++++--------- .../storage_formats/pandas/query_compiler.py | 33 +++--- modin/pandas/test/dataframe/test_binary.py | 60 +++++++++- 3 files changed, 133 insertions(+), 68 deletions(-) diff --git a/modin/core/dataframe/algebra/binary.py b/modin/core/dataframe/algebra/binary.py index 1dd263167be..22cd2c3ef88 100644 --- a/modin/core/dataframe/algebra/binary.py +++ b/modin/core/dataframe/algebra/binary.py @@ -24,36 +24,12 @@ from .operator import Operator -def coerce_int_to_float64(dtype: np.dtype) -> np.dtype: - """ - Coerce dtype to float64 if it is a variant of integer. - - If dtype is integer, function returns float64 datatype. - If not, returns the datatype argument itself. - - Parameters - ---------- - dtype : np.dtype - NumPy datatype. - - Returns - ------- - dtype : np.dtype - Returns float64 for all int datatypes or returns the datatype itself - for other types. - - Notes - ----- - Used to precompute datatype in case of division in pandas. - """ - if dtype in np.sctypes["int"] + np.sctypes["uint"]: - return np.dtype(np.float64) - else: - return dtype - - def maybe_compute_dtypes_common_cast( - first, second, trigger_computations=False, axis=0 + first, + second, + trigger_computations=False, + axis=0, + func=None, ) -> Optional[pandas.Series]: """ Precompute data types for binary operations by finding common type between operands. @@ -70,6 +46,9 @@ def maybe_compute_dtypes_common_cast( have materialized dtypes. axis : int, default: 0 Axis to perform the binary operation along. + func : callable(pandas.DataFrame, pandas.DataFrame) -> pandas.DataFrame, optional + If specified, will use this function to perform the "try_sample" method + (see ``Binary.register()`` docs for more details). Returns ------- @@ -138,18 +117,33 @@ def maybe_compute_dtypes_common_cast( # If at least one column doesn't match, the result of the non matching column would be nan. nan_dtype = np.dtype(type(np.nan)) - dtypes = pandas.Series( - [ - pandas.core.dtypes.cast.find_common_type( - [ - dtypes_first[x], - dtypes_second[x], - ] + dtypes = None + if func is not None: + try: + df1 = pandas.DataFrame([[1] * len(common_columns)]).astype( + {i: dtypes_first[col] for i, col in enumerate(common_columns)} ) - for x in common_columns - ], - index=common_columns, - ) + df2 = pandas.DataFrame([[1] * len(common_columns)]).astype( + {i: dtypes_second[col] for i, col in enumerate(common_columns)} + ) + dtypes = func(df1, df2).dtypes.set_axis(common_columns) + # it sometimes doesn't work correctly with strings, so falling back to + # the "common_cast" method in this case + except TypeError: + pass + if dtypes is None: + dtypes = pandas.Series( + [ + pandas.core.dtypes.cast.find_common_type( + [ + dtypes_first[x], + dtypes_second[x], + ] + ) + for x in common_columns + ], + index=common_columns, + ) dtypes = pandas.concat( [ dtypes, @@ -211,7 +205,9 @@ def maybe_build_dtypes_series( return dtypes -def try_compute_new_dtypes(first, second, infer_dtypes=None, result_dtype=None, axis=0): +def try_compute_new_dtypes( + first, second, infer_dtypes=None, result_dtype=None, axis=0, func=None +): """ Precompute resulting dtypes of the binary operation if possible. @@ -225,12 +221,14 @@ def try_compute_new_dtypes(first, second, infer_dtypes=None, result_dtype=None, First operand of the binary operation. second : PandasQueryCompiler, list-like or scalar Second operand of the binary operation. - infer_dtypes : {"common_cast", "float", "bool", None}, default: None + infer_dtypes : {"common_cast", "try_sample", "bool", None}, default: None How dtypes should be infered (see ``Binary.register`` doc for more info). result_dtype : np.dtype, optional NumPy dtype of the result. If not specified it will be inferred from the `infer_dtypes` parameter. axis : int, default: 0 Axis to perform the binary operation along. + func : callable(pandas.DataFrame, pandas.DataFrame) -> pandas.DataFrame, optional + A callable to be used for the "try_sample" method. Returns ------- @@ -243,11 +241,17 @@ def try_compute_new_dtypes(first, second, infer_dtypes=None, result_dtype=None, if infer_dtypes == "bool" or is_bool_dtype(result_dtype): dtypes = maybe_build_dtypes_series(first, second, dtype=np.dtype(bool)) elif infer_dtypes == "common_cast": - dtypes = maybe_compute_dtypes_common_cast(first, second, axis=axis) - elif infer_dtypes == "float": - dtypes = maybe_compute_dtypes_common_cast(first, second, axis=axis) - if dtypes is not None: - dtypes = dtypes.apply(coerce_int_to_float64) + dtypes = maybe_compute_dtypes_common_cast( + first, second, axis=axis, func=None + ) + elif infer_dtypes == "try_sample": + if func is None: + raise ValueError( + "'func' must be specified if dtypes infering method is 'try_sample'" + ) + dtypes = maybe_compute_dtypes_common_cast( + first, second, axis=axis, func=func + ) else: # For now we only know how to handle `result_dtype == bool` as that's # the only value that is being passed here right now, it's unclear @@ -283,12 +287,12 @@ def register( labels : {"keep", "replace", "drop"}, default: "replace" Whether keep labels from left Modin DataFrame, replace them with labels from joined DataFrame or drop altogether to make them be computed lazily later. - infer_dtypes : {"common_cast", "float", "bool", None}, default: None + infer_dtypes : {"common_cast", "try_sample", "bool", None}, default: None How dtypes should be inferred. * If "common_cast", casts to common dtype of operand columns. - * If "float", performs type casting by finding common dtype. - If the common dtype is any of the integer types, perform type casting to float. - Used in case of truediv. + * If "try_sample", creates small pandas DataFrames with dtypes of operands and + runs the `func` on them to determine output dtypes. If a ``TypeError`` is raised + during this process, fallback to "common_cast" method. * If "bool", dtypes would be a boolean series with same size as that of operands. * If ``None``, do not infer new dtypes (they will be computed manually once accessed). @@ -339,7 +343,7 @@ def caller( other = other.transpose() if dtypes != "copy": dtypes = try_compute_new_dtypes( - query_compiler, other, infer_dtypes, dtypes, axis + query_compiler, other, infer_dtypes, dtypes, axis, func ) shape_hint = None diff --git a/modin/core/storage_formats/pandas/query_compiler.py b/modin/core/storage_formats/pandas/query_compiler.py index 8c06810a90c..75a75ded8e1 100644 --- a/modin/core/storage_formats/pandas/query_compiler.py +++ b/modin/core/storage_formats/pandas/query_compiler.py @@ -388,9 +388,12 @@ def to_numpy(self, **kwargs): # such that columns/rows that don't have an index on the other DataFrame # result in NaN values. - add = Binary.register(pandas.DataFrame.add, infer_dtypes="common_cast") + add = Binary.register(pandas.DataFrame.add, infer_dtypes="try_sample") + # 'combine' and 'combine_first' are working with UDFs, so it's better not so sample them combine = Binary.register(pandas.DataFrame.combine, infer_dtypes="common_cast") - combine_first = Binary.register(pandas.DataFrame.combine_first, infer_dtypes="bool") + combine_first = Binary.register( + pandas.DataFrame.combine_first, infer_dtypes="common_cast" + ) eq = Binary.register(pandas.DataFrame.eq, infer_dtypes="bool") equals = Binary.register( lambda df, other: pandas.DataFrame([[df.equals(other)]]), @@ -398,24 +401,24 @@ def to_numpy(self, **kwargs): labels="drop", infer_dtypes="bool", ) - floordiv = Binary.register(pandas.DataFrame.floordiv, infer_dtypes="common_cast") + floordiv = Binary.register(pandas.DataFrame.floordiv, infer_dtypes="try_sample") ge = Binary.register(pandas.DataFrame.ge, infer_dtypes="bool") gt = Binary.register(pandas.DataFrame.gt, infer_dtypes="bool") le = Binary.register(pandas.DataFrame.le, infer_dtypes="bool") lt = Binary.register(pandas.DataFrame.lt, infer_dtypes="bool") - mod = Binary.register(pandas.DataFrame.mod, infer_dtypes="common_cast") - mul = Binary.register(pandas.DataFrame.mul, infer_dtypes="common_cast") - rmul = Binary.register(pandas.DataFrame.rmul, infer_dtypes="common_cast") + mod = Binary.register(pandas.DataFrame.mod, infer_dtypes="try_sample") + mul = Binary.register(pandas.DataFrame.mul, infer_dtypes="try_sample") + rmul = Binary.register(pandas.DataFrame.rmul, infer_dtypes="try_sample") ne = Binary.register(pandas.DataFrame.ne, infer_dtypes="bool") - pow = Binary.register(pandas.DataFrame.pow, infer_dtypes="common_cast") - radd = Binary.register(pandas.DataFrame.radd, infer_dtypes="common_cast") - rfloordiv = Binary.register(pandas.DataFrame.rfloordiv, infer_dtypes="common_cast") - rmod = Binary.register(pandas.DataFrame.rmod, infer_dtypes="common_cast") - rpow = Binary.register(pandas.DataFrame.rpow, infer_dtypes="common_cast") - rsub = Binary.register(pandas.DataFrame.rsub, infer_dtypes="common_cast") - rtruediv = Binary.register(pandas.DataFrame.rtruediv, infer_dtypes="float") - sub = Binary.register(pandas.DataFrame.sub, infer_dtypes="common_cast") - truediv = Binary.register(pandas.DataFrame.truediv, infer_dtypes="float") + pow = Binary.register(pandas.DataFrame.pow, infer_dtypes="try_sample") + radd = Binary.register(pandas.DataFrame.radd, infer_dtypes="try_sample") + rfloordiv = Binary.register(pandas.DataFrame.rfloordiv, infer_dtypes="try_sample") + rmod = Binary.register(pandas.DataFrame.rmod, infer_dtypes="try_sample") + rpow = Binary.register(pandas.DataFrame.rpow, infer_dtypes="try_sample") + rsub = Binary.register(pandas.DataFrame.rsub, infer_dtypes="try_sample") + rtruediv = Binary.register(pandas.DataFrame.rtruediv, infer_dtypes="try_sample") + sub = Binary.register(pandas.DataFrame.sub, infer_dtypes="try_sample") + truediv = Binary.register(pandas.DataFrame.truediv, infer_dtypes="try_sample") __and__ = Binary.register(pandas.DataFrame.__and__, infer_dtypes="bool") __or__ = Binary.register(pandas.DataFrame.__or__, infer_dtypes="bool") __rand__ = Binary.register(pandas.DataFrame.__rand__, infer_dtypes="bool") diff --git a/modin/pandas/test/dataframe/test_binary.py b/modin/pandas/test/dataframe/test_binary.py index a0540a3f39e..62a20d5fdea 100644 --- a/modin/pandas/test/dataframe/test_binary.py +++ b/modin/pandas/test/dataframe/test_binary.py @@ -17,7 +17,7 @@ import pytest import modin.pandas as pd -from modin.config import NPartitions, StorageFormat +from modin.config import Engine, NPartitions, StorageFormat from modin.core.dataframe.pandas.partitioning.axis_partition import ( PandasDataframeAxisPartition, ) @@ -433,3 +433,61 @@ def test_non_commutative_multiply(): integer = NonCommutativeMultiplyInteger(2) eval_general(modin_df, pandas_df, lambda s: integer * s) eval_general(modin_df, pandas_df, lambda s: s * integer) + + +@pytest.mark.parametrize( + "op", + [ + *("add", "radd", "sub", "rsub", "mod", "rmod", "pow", "rpow"), + *("truediv", "rtruediv", "mul", "rmul", "floordiv", "rfloordiv"), + ], +) +@pytest.mark.parametrize( + "val1", + [ + pytest.param([10, 20], id="int"), + pytest.param([10, True], id="obj"), + pytest.param( + [True, True], + id="bool", + marks=pytest.mark.skipif( + condition=Engine.get() == "Native", reason="Fails on HDK" + ), + ), + pytest.param([3.5, 4.5], id="float"), + ], +) +@pytest.mark.parametrize( + "val2", + [ + pytest.param([10, 20], id="int"), + pytest.param([10, True], id="obj"), + pytest.param( + [True, True], + id="bool", + marks=pytest.mark.skipif( + condition=Engine.get() == "Native", reason="Fails on HDK" + ), + ), + pytest.param([3.5, 4.5], id="float"), + pytest.param(2, id="int scalar"), + pytest.param( + True, + id="bool scalar", + marks=pytest.mark.skipif( + condition=Engine.get() == "Native", reason="Fails on HDK" + ), + ), + pytest.param(3.5, id="float scalar"), + ], +) +def test_arithmetic_with_tricky_dtypes(val1, val2, op): + modin_df1, pandas_df1 = create_test_dfs(val1) + modin_df2, pandas_df2 = ( + create_test_dfs(val2) if isinstance(val2, list) else (val2, val2) + ) + eval_general( + (modin_df1, modin_df2), + (pandas_df1, pandas_df2), + lambda dfs: getattr(dfs[0], op)(dfs[1]), + ) From b7bf9b51107d1d82235067aa1be226725fadfe95 Mon Sep 17 00:00:00 2001 From: Dmitry Chigarev Date: Fri, 17 Nov 2023 13:39:36 +0100 Subject: [PATCH 084/201] FEAT-#5836: Introduce 'partial' dtypes cache (#6663) Signed-off-by: Dmitry Chigarev --- .../dataframe/pandas/dataframe/dataframe.py | 108 ++- .../dataframe/pandas/metadata/__init__.py | 4 +- .../core/dataframe/pandas/metadata/dtypes.py | 658 +++++++++++++++++- modin/core/dataframe/pandas/metadata/index.py | 16 + .../storage_formats/base/query_compiler.py | 10 + .../storage_formats/pandas/query_compiler.py | 7 +- .../hdk_on_native/dataframe/dataframe.py | 7 +- modin/pandas/dataframe.py | 5 +- .../storage_formats/pandas/test_internals.py | 461 +++++++++++- 9 files changed, 1230 insertions(+), 46 deletions(-) diff --git a/modin/core/dataframe/pandas/dataframe/dataframe.py b/modin/core/dataframe/pandas/dataframe/dataframe.py index 8dd791dc1a4..7031b269554 100644 --- a/modin/core/dataframe/pandas/dataframe/dataframe.py +++ b/modin/core/dataframe/pandas/dataframe/dataframe.py @@ -36,6 +36,7 @@ lazy_metadata_decorator, ) from modin.core.dataframe.pandas.metadata import ( + DtypesDescriptor, LazyProxyCategoricalDtype, ModinDtypes, ModinIndex, @@ -314,14 +315,17 @@ def _maybe_update_proxies(self, dtypes, new_parent=None): new_parent : object, optional A new parent to link the proxies to. If not specified will consider the `self` to be a new parent. + + Returns + ------- + pandas.Series, ModinDtypes or callable """ new_parent = new_parent or self - if isinstance(dtypes, pandas.Series) or ( - isinstance(dtypes, ModinDtypes) and dtypes.is_materialized - ): - for key, value in dtypes.items(): - if isinstance(value, LazyProxyCategoricalDtype): - dtypes[key] = value._update_proxy(new_parent, column_name=key) + if isinstance(dtypes, ModinDtypes): + dtypes = dtypes.maybe_specify_new_frame_ref(new_parent) + if isinstance(dtypes, pandas.Series): + LazyProxyCategoricalDtype.update_dtypes(dtypes, new_parent) + return dtypes def set_dtypes_cache(self, dtypes): """ @@ -329,10 +333,21 @@ def set_dtypes_cache(self, dtypes): Parameters ---------- - dtypes : pandas.Series, ModinDtypes or callable - """ - self._maybe_update_proxies(dtypes) - if isinstance(dtypes, ModinDtypes) or dtypes is None: + dtypes : pandas.Series, ModinDtypes, callable or None + """ + dtypes = self._maybe_update_proxies(dtypes) + if dtypes is None and self.has_materialized_columns: + # try to set a descriptor instead of 'None' to be more flexible in + # dtypes computing + try: + self._dtypes = ModinDtypes( + DtypesDescriptor( + cols_with_unknown_dtypes=self.columns.tolist(), parent_df=self + ) + ) + except NotImplementedError: + self._dtypes = None + elif isinstance(dtypes, ModinDtypes) or dtypes is None: self._dtypes = dtypes else: self._dtypes = ModinDtypes(dtypes) @@ -354,6 +369,18 @@ def dtypes(self): self.set_dtypes_cache(dtypes) return dtypes + def get_dtypes_set(self): + """ + Get a set of dtypes that are in this dataframe. + + Returns + ------- + set + """ + if isinstance(self._dtypes, ModinDtypes): + return self._dtypes.get_dtypes_set() + return set(self.dtypes.values) + def _compute_dtypes(self, columns=None): """ Compute the data types via TreeReduce pattern for the specified columns. @@ -376,7 +403,13 @@ def dtype_builder(df): if columns is not None: # Sorting positions to request columns in the order they're stored (it's more efficient) numeric_indices = sorted(self.columns.get_indexer_for(columns)) - obj = self._take_2d_positional(col_positions=numeric_indices) + if any(pos < 0 for pos in numeric_indices): + raise KeyError( + f"Some of the columns are not in index: subset={columns}; columns={self.columns}" + ) + obj = self.take_2d_labels_or_positional( + col_labels=self.columns[numeric_indices].tolist() + ) else: obj = self @@ -675,8 +708,11 @@ def _set_columns(self, new_columns): ): return new_columns = self._validate_set_axis(new_columns, self._columns_cache) - if self.has_materialized_dtypes: - self.dtypes.index = new_columns + if isinstance(self._dtypes, ModinDtypes): + new_value = self._dtypes.set_index(new_columns) + self.set_dtypes_cache(new_value) + elif isinstance(self._dtypes, pandas.Series): + self.dtypes.index = new_columns self.set_columns_cache(new_columns) self.synchronize_labels(axis=1) @@ -1146,6 +1182,14 @@ def _take_2d_positional( if self.has_materialized_dtypes: new_dtypes = self.dtypes.iloc[monotonic_col_idx] + elif isinstance(self._dtypes, ModinDtypes): + try: + new_dtypes = self._dtypes.lazy_get( + monotonic_col_idx, numeric_index=True + ) + # can raise either on missing cache or on duplicated labels + except (ValueError, NotImplementedError): + new_dtypes = None else: new_dtypes = None else: @@ -1441,6 +1485,12 @@ def _reorder_labels(self, row_positions=None, col_positions=None): col_idx = self.columns[col_positions] if self.has_materialized_dtypes: new_dtypes = self.dtypes.iloc[col_positions] + elif isinstance(self._dtypes, ModinDtypes): + try: + new_dtypes = self._dtypes.lazy_get(col_idx) + # can raise on duplicated labels + except NotImplementedError: + new_dtypes = None if len(col_idx) != len(self.columns): # The frame was re-partitioned along the 1 axis during reordering using @@ -3253,22 +3303,24 @@ def broadcast_apply_full_axis( kw = {"row_lengths": None, "column_widths": None} if isinstance(dtypes, str) and dtypes == "copy": kw["dtypes"] = self.copy_dtypes_cache() + elif isinstance(dtypes, DtypesDescriptor): + kw["dtypes"] = ModinDtypes(dtypes) elif dtypes is not None: if isinstance(dtypes, (pandas.Series, ModinDtypes)): kw["dtypes"] = dtypes.copy() else: if new_columns is None: - ( - new_columns, - kw["column_widths"], - ) = self._compute_axis_labels_and_lengths(1, new_partitions) - kw["dtypes"] = ( - pandas.Series(dtypes, index=new_columns) - if is_list_like(dtypes) - else pandas.Series( - [np.dtype(dtypes)] * len(new_columns), index=new_columns + kw["dtypes"] = ModinDtypes( + DtypesDescriptor(remaining_dtype=np.dtype(dtypes)) + ) + else: + kw["dtypes"] = ( + pandas.Series(dtypes, index=new_columns) + if is_list_like(dtypes) + else pandas.Series( + [np.dtype(dtypes)] * len(new_columns), index=new_columns + ) ) - ) if not keep_partitioning: if kw["row_lengths"] is None and new_index is not None: @@ -3662,10 +3714,12 @@ def _compute_new_widths(): if all(obj.has_materialized_columns for obj in (self, *others)): new_columns = self.columns.append([other.columns for other in others]) new_index = joined_index - if self.has_materialized_dtypes and all( - o.has_materialized_dtypes for o in others - ): - new_dtypes = pandas.concat([self.dtypes] + [o.dtypes for o in others]) + try: + new_dtypes = ModinDtypes.concat( + [self.copy_dtypes_cache()] + [o.copy_dtypes_cache() for o in others] + ) + except NotImplementedError: + new_dtypes = None # If we have already cached the width of each column in at least one # of the column's partitions, we can build new_widths for the new # frame. Typically, if we know the width for any partition in a diff --git a/modin/core/dataframe/pandas/metadata/__init__.py b/modin/core/dataframe/pandas/metadata/__init__.py index 1836a0d5ffa..4caf23833a6 100644 --- a/modin/core/dataframe/pandas/metadata/__init__.py +++ b/modin/core/dataframe/pandas/metadata/__init__.py @@ -13,7 +13,7 @@ """Utilities and classes to handle work with metadata.""" -from .dtypes import LazyProxyCategoricalDtype, ModinDtypes +from .dtypes import DtypesDescriptor, LazyProxyCategoricalDtype, ModinDtypes from .index import ModinIndex -__all__ = ["ModinDtypes", "ModinIndex", "LazyProxyCategoricalDtype"] +__all__ = ["ModinDtypes", "ModinIndex", "LazyProxyCategoricalDtype", "DtypesDescriptor"] diff --git a/modin/core/dataframe/pandas/metadata/dtypes.py b/modin/core/dataframe/pandas/metadata/dtypes.py index 4cd0463f630..fc6cc211fac 100644 --- a/modin/core/dataframe/pandas/metadata/dtypes.py +++ b/modin/core/dataframe/pandas/metadata/dtypes.py @@ -12,13 +12,515 @@ # governing permissions and limitations under the License. """Module contains class ``ModinDtypes``.""" -from typing import Union +from typing import TYPE_CHECKING, Callable, Optional, Union + +import numpy as np import pandas +from pandas._typing import IndexLabel + +if TYPE_CHECKING: + from modin.core.dataframe.pandas.dataframe.dataframe import PandasDataframe + from .index import ModinIndex from modin.error_message import ErrorMessage +class DtypesDescriptor: + """ + Describes partial dtypes. + + Parameters + ---------- + known_dtypes : dict[IndexLabel, np.dtype] or pandas.Series, optional + Columns that we know dtypes for. + cols_with_unknown_dtypes : list[IndexLabel], optional + Column names that have unknown dtypes. If specified together with `remaining_dtype`, must describe all + columns with unknown dtypes, otherwise, the missing columns will be assigned to `remaining_dtype`. + If `cols_with_unknown_dtypes` is incomplete, you must specify `know_all_names=False`. + remaining_dtype : np.dtype, optional + Dtype for columns that are not present neither in `known_dtypes` nor in `cols_with_unknown_dtypes`. + This parameter is intended to describe columns that we known dtypes for, but don't know their + names yet. Note, that this parameter DOESN'T describe dtypes for columns from `cols_with_unknown_dtypes`. + parent_df : PandasDataframe, optional + Dataframe object for which we describe dtypes. This dataframe will be used to compute + missing dtypes on ``.materialize()``. + columns_order : dict[int, IndexLabel], optional + Order of columns in the dataframe. If specified, must describe all the columns of the dataframe. + know_all_names : bool, default: True + Whether `known_dtypes` and `cols_with_unknown_dtypes` contain all column names for this dataframe besides those, + that are being described by `remaining_dtype`. + One can't pass `know_all_names=False` together with `remaining_dtype` as this creates ambiguity + on how to interpret missing columns (whether they belong to `remaining_dtype` or not). + _schema_is_known : bool, optional + Whether `known_dtypes` describe all columns in the dataframe. This parameter intended mostly + for internal use. + """ + + def __init__( + self, + known_dtypes: Optional[Union[dict[IndexLabel, np.dtype], pandas.Series]] = None, + cols_with_unknown_dtypes: Optional[list[IndexLabel]] = None, + remaining_dtype: Optional[np.dtype] = None, + parent_df: Optional["PandasDataframe"] = None, + columns_order: Optional[dict[int, IndexLabel]] = None, + know_all_names: bool = True, + _schema_is_known: Optional[bool] = None, + ): + if not know_all_names and remaining_dtype is not None: + raise ValueError( + "It's not allowed to pass 'remaining_dtype' and 'know_all_names=False' at the same time." + ) + # columns with known dtypes + self._known_dtypes: dict[IndexLabel, np.dtype] = ( + {} if known_dtypes is None else dict(known_dtypes) + ) + if known_dtypes is not None and len(self._known_dtypes) != len(known_dtypes): + raise NotImplementedError( + "Duplicated column names are not yet supported by DtypesDescriptor" + ) + # columns with unknown dtypes (they're not described by 'remaining_dtype') + if cols_with_unknown_dtypes is not None and len( + set(cols_with_unknown_dtypes) + ) != len(cols_with_unknown_dtypes): + raise NotImplementedError( + "Duplicated column names are not yet supported by DtypesDescriptor" + ) + self._cols_with_unknown_dtypes: list[IndexLabel] = ( + [] if cols_with_unknown_dtypes is None else cols_with_unknown_dtypes + ) + # whether 'known_dtypes' describe all columns in the dataframe + self._schema_is_known: Optional[bool] = _schema_is_known + if self._schema_is_known is None: + self._schema_is_known = False + if ( + # if 'cols_with_unknown_dtypes' was explicitly specified as an empty list and + # we don't have any 'remaining_dtype', then we assume that 'known_dtypes' are complete + cols_with_unknown_dtypes is not None + and know_all_names + and remaining_dtype is None + and len(self._known_dtypes) > 0 + ): + self._schema_is_known = len(cols_with_unknown_dtypes) == 0 + + self._know_all_names: bool = know_all_names + # a common dtype for columns that are not present in 'known_dtypes' nor in 'cols_with_unknown_dtypes' + self._remaining_dtype: Optional[np.dtype] = remaining_dtype + self._parent_df: Optional["PandasDataframe"] = parent_df + if columns_order is None: + self._columns_order: Optional[dict[int, IndexLabel]] = None + # try to compute '._columns_order' using 'parent_df' + self.columns_order + else: + if remaining_dtype is not None: + raise ValueError( + "Passing 'columns_order' and 'remaining_dtype' is ambiguous. You have to manually " + + "complete 'known_dtypes' using the information from 'columns_order' and 'remaining_dtype'." + ) + elif not self._know_all_names: + raise ValueError( + "Passing 'columns_order' and 'know_all_names=False' is ambiguous. You have to manually " + + "complete 'cols_with_unknown_dtypes' using the information from 'columns_order' " + + "and pass 'know_all_names=True'." + ) + elif len(columns_order) != ( + len(self._cols_with_unknown_dtypes) + len(self._known_dtypes) + ): + raise ValueError( + "The length of 'columns_order' doesn't match to 'known_dtypes' and 'cols_with_unknown_dtypes'" + ) + self._columns_order: Optional[dict[int, IndexLabel]] = columns_order + + def update_parent(self, new_parent: "PandasDataframe"): + """ + Set new parent dataframe. + + Parameters + ---------- + new_parent : PandasDataframe + """ + self._parent_df = new_parent + LazyProxyCategoricalDtype.update_dtypes(self._known_dtypes, new_parent) + # try to compute '._columns_order' using 'new_parent' + self.columns_order + + @property + def columns_order(self) -> Optional[dict[int, IndexLabel]]: + """ + Get order of columns for the described dataframe if available. + + Returns + ------- + dict[int, IndexLabel] or None + """ + if self._columns_order is not None: + return self._columns_order + if self._parent_df is None or not self._parent_df.has_materialized_columns: + return None + + self._columns_order = {i: col for i, col in enumerate(self._parent_df.columns)} + # we got information about new columns and thus can potentially + # extend our knowledge about missing dtypes + if len(self._columns_order) > ( + len(self._known_dtypes) + len(self._cols_with_unknown_dtypes) + ): + new_cols = [ + col + for col in self._columns_order.values() + if col not in self._known_dtypes + and col not in self._cols_with_unknown_dtypes + ] + if self._remaining_dtype is not None: + self._known_dtypes.update( + {col: self._remaining_dtype for col in new_cols} + ) + self._remaining_dtype = None + if len(self._cols_with_unknown_dtypes) == 0: + self._schema_is_known = True + else: + self._cols_with_unknown_dtypes.extend(new_cols) + self._know_all_names = True + return self._columns_order + + def __repr__(self): # noqa: GL08 + return ( + f"DtypesDescriptor:\n\tknown dtypes: {self._known_dtypes};\n\t" + + f"remaining dtype: {self._remaining_dtype};\n\t" + + f"cols with unknown dtypes: {self._cols_with_unknown_dtypes};\n\t" + + f"schema is known: {self._schema_is_known};\n\t" + + f"has parent df: {self._parent_df is not None};\n\t" + + f"columns order: {self._columns_order};\n\t" + + f"know all names: {self._know_all_names}" + ) + + def __str__(self): # noqa: GL08 + return self.__repr__() + + def lazy_get( + self, ids: list[Union[IndexLabel, int]], numeric_index: bool = False + ) -> "DtypesDescriptor": + """ + Get dtypes descriptor for a subset of columns without triggering any computations. + + Parameters + ---------- + ids : list of index labels or positional indexers + Columns for the subset. + numeric_index : bool, default: False + Whether `ids` are positional indixes or column labels to take. + + Returns + ------- + DtypesDescriptor + Descriptor that describes dtypes for columns specified in `ids`. + """ + if len(set(ids)) != len(ids): + raise NotImplementedError( + "Duplicated column names are not yet supported by DtypesDescriptor" + ) + + if numeric_index: + if self.columns_order is not None: + ids = [self.columns_order[i] for i in ids] + else: + raise ValueError( + "Can't lazily get columns by positional indixers if the columns order is unknown" + ) + + result = {} + unknown_cols = [] + columns_order = {} + for i, col in enumerate(ids): + columns_order[i] = col + if col in self._cols_with_unknown_dtypes: + unknown_cols.append(col) + continue + dtype = self._known_dtypes.get(col) + if dtype is None and self._remaining_dtype is None: + unknown_cols.append(col) + elif dtype is None and self._remaining_dtype is not None: + result[col] = self._remaining_dtype + else: + result[col] = dtype + remaining_dtype = self._remaining_dtype if len(unknown_cols) != 0 else None + return DtypesDescriptor( + result, + unknown_cols, + remaining_dtype, + self._parent_df, + columns_order=columns_order, + ) + + def copy(self) -> "DtypesDescriptor": + """ + Get a copy of this descriptor. + + Returns + ------- + DtypesDescriptor + """ + return type(self)( + self._known_dtypes.copy(), + self._cols_with_unknown_dtypes.copy(), + self._remaining_dtype, + self._parent_df, + columns_order=None + if self.columns_order is None + else self.columns_order.copy(), + know_all_names=self._know_all_names, + _schema_is_known=self._schema_is_known, + ) + + def set_index( + self, new_index: Union[pandas.Index, "ModinIndex"] + ) -> "DtypesDescriptor": + """ + Set new column names for this descriptor. + + Parameters + ---------- + new_index : pandas.Index or ModinIndex + + Returns + ------- + DtypesDescriptor + New descriptor with updated column names. + + Notes + ----- + Calling this method on a descriptor that returns ``None`` for ``.columns_order`` + will result into information lose. + """ + if self.columns_order is None: + # we can't map new columns to old columns and lost all dtypes :( + return DtypesDescriptor( + cols_with_unknown_dtypes=new_index, + columns_order={i: col for i, col in enumerate(new_index)}, + parent_df=self._parent_df, + know_all_names=True, + ) + + new_self = self.copy() + renamer = {old_c: new_index[i] for i, old_c in new_self.columns_order.items()} + new_self._known_dtypes = { + renamer[old_col]: value for old_col, value in new_self._known_dtypes.items() + } + new_self._cols_with_unknown_dtypes = [ + renamer[old_col] for old_col in new_self._cols_with_unknown_dtypes + ] + new_self._columns_order = { + i: renamer[old_col] for i, old_col in new_self._columns_order.items() + } + return new_self + + def equals(self, other: "DtypesDescriptor") -> bool: + """ + Compare two descriptors for equality. + + Parameters + ---------- + other : DtypesDescriptor + + Returns + ------- + bool + """ + return ( + self._known_dtypes == other._known_dtypes + and set(self._cols_with_unknown_dtypes) + == set(other._cols_with_unknown_dtypes) + and self._remaining_dtype == other._remaining_dtype + and self._schema_is_known == other._schema_is_known + and self.columns_order == other.columns_order + and self._know_all_names == other._know_all_names + ) + + @property + def is_materialized(self) -> bool: + """ + Whether this descriptor contains information about all dtypes in the dataframe. + + Returns + ------- + bool + """ + return self._schema_is_known + + def _materialize_all_names(self): + """Materialize missing column names.""" + if self._know_all_names: + return + + all_cols = self._parent_df.columns + for col in all_cols: + if ( + col not in self._known_dtypes + and col not in self._cols_with_unknown_dtypes + ): + self._cols_with_unknown_dtypes.append(col) + + self._know_all_names = True + + def _materialize_cols_with_unknown_dtypes(self): + """Compute dtypes for cols specified in `._cols_with_unknown_dtypes`.""" + if ( + len(self._known_dtypes) == 0 + and len(self._cols_with_unknown_dtypes) == 0 + and not self._know_all_names + ): + # here we have to compute dtypes for all columns in the dataframe, + # so avoiding columns materialization by setting 'subset=None' + subset = None + else: + if not self._know_all_names: + self._materialize_all_names() + subset = self._cols_with_unknown_dtypes + + if subset is None or len(subset) > 0: + self._known_dtypes.update(self._parent_df._compute_dtypes(subset)) + + self._know_all_names = True + self._cols_with_unknown_dtypes = [] + + def materialize(self): + """Complete information about dtypes.""" + if self.is_materialized: + return + if self._parent_df is None: + raise RuntimeError( + "It's not allowed to call '.materialize()' before '._parent_df' is specified." + ) + + self._materialize_cols_with_unknown_dtypes() + + if self._remaining_dtype is not None: + cols = self._parent_df.columns + self._known_dtypes.update( + { + col: self._remaining_dtype + for col in cols + if col not in self._known_dtypes + } + ) + + # we currently not guarantee for dtypes to be in a proper order: + # https://github.com/modin-project/modin/blob/8a332c1597c54d36f7ccbbd544e186b689f9ceb1/modin/pandas/test/utils.py#L644-L646 + # so restoring the order only if it's possible + if self.columns_order is not None: + assert len(self.columns_order) == len(self._known_dtypes) + self._known_dtypes = { + self.columns_order[i]: self._known_dtypes[self.columns_order[i]] + for i in range(len(self.columns_order)) + } + + self._schema_is_known = True + self._remaining_dtype = None + self._parent_df = None + + def to_series(self) -> pandas.Series: + """ + Convert descriptor to a pandas Series. + + Returns + ------- + pandas.Series + """ + self.materialize() + return pandas.Series(self._known_dtypes) + + def get_dtypes_set(self) -> set[np.dtype]: + """ + Get a set of dtypes from the descriptor. + + Returns + ------- + set[np.dtype] + """ + if len(self._cols_with_unknown_dtypes) > 0 or not self._know_all_names: + self._materialize_cols_with_unknown_dtypes() + known_dtypes: set[np.dtype] = set(self._known_dtypes.values()) + if self._remaining_dtype is not None: + known_dtypes.add(self._remaining_dtype) + return known_dtypes + + @classmethod + def concat( + cls, values: list[Union["DtypesDescriptor", pandas.Series, None]] + ) -> "DtypesDescriptor": # noqa: GL08 + """ + Concatenate dtypes descriptors into a single descriptor. + + Parameters + ---------- + values : list of DtypesDescriptors and pandas.Series + + Returns + ------- + DtypesDescriptor + """ + known_dtypes = {} + cols_with_unknown_dtypes = [] + schema_is_known = True + # some default value to not mix it with 'None' + remaining_dtype = "default" + know_all_names = True + + for val in values: + if isinstance(val, cls): + all_new_cols = ( + list(val._known_dtypes.keys()) + val._cols_with_unknown_dtypes + ) + if any( + col in known_dtypes or col in cols_with_unknown_dtypes + for col in all_new_cols + ): + raise NotImplementedError( + "Duplicated column names are not yet supported by DtypesDescriptor" + ) + know_all_names &= val._know_all_names + known_dtypes.update(val._known_dtypes) + cols_with_unknown_dtypes.extend(val._cols_with_unknown_dtypes) + if know_all_names: + if ( + remaining_dtype == "default" + and val._remaining_dtype is not None + ): + remaining_dtype = val._remaining_dtype + elif ( + remaining_dtype != "default" + and val._remaining_dtype is not None + and remaining_dtype != val._remaining_dtype + ): + remaining_dtype = None + know_all_names = False + else: + remaining_dtype = None + schema_is_known &= val._schema_is_known + elif isinstance(val, pandas.Series): + if any( + col in known_dtypes or col in cols_with_unknown_dtypes + for col in val.index + ): + raise NotImplementedError( + "Duplicated column names are not yet supported by DtypesDescriptor" + ) + known_dtypes.update(val) + elif val is None: + remaining_dtype = None + schema_is_known = False + know_all_names = False + else: + raise NotImplementedError(type(val)) + return cls( + known_dtypes, + cols_with_unknown_dtypes, + None if remaining_dtype == "default" else remaining_dtype, + parent_df=None, + _schema_is_known=schema_is_known, + know_all_names=know_all_names, + ) + + class ModinDtypes: """ A class that hides the various implementations of the dtypes needed for optimization. @@ -28,10 +530,19 @@ class ModinDtypes: value : pandas.Series or callable """ - def __init__(self, value): - if value is None: + def __init__(self, value: Union[Callable, pandas.Series, DtypesDescriptor]): + if callable(value) or isinstance(value, pandas.Series): + self._value = value + elif isinstance(value, DtypesDescriptor): + self._value = value.to_series() if value.is_materialized else value + else: raise ValueError(f"ModinDtypes doesn't work with '{value}'") - self._value = value + + def __repr__(self): # noqa: GL08 + return f"ModinDtypes:\n\tvalue type: {type(self._value)};\n\tvalue:\n\t{self._value}" + + def __str__(self): # noqa: GL08 + return self.__repr__() @property def is_materialized(self) -> bool: @@ -44,6 +555,127 @@ def is_materialized(self) -> bool: """ return isinstance(self._value, pandas.Series) + def get_dtypes_set(self) -> set[np.dtype]: + """ + Get a set of dtypes from the descriptor. + + Returns + ------- + set[np.dtype] + """ + if isinstance(self._value, DtypesDescriptor): + return self._value.get_dtypes_set() + if not self.is_materialized: + self.get() + return set(self._value.values) + + def maybe_specify_new_frame_ref( + self, new_parent: "PandasDataframe" + ) -> "ModinDtypes": + """ + Set a new parent for the stored value if needed. + + Parameters + ---------- + new_parent : PandasDataframe + + Returns + ------- + ModinDtypes + A copy of ``ModinDtypes`` with updated parent. + """ + new_self = self.copy() + if new_self.is_materialized: + LazyProxyCategoricalDtype.update_dtypes(new_self._value, new_parent) + return new_self + if isinstance(self._value, DtypesDescriptor): + new_self._value.update_parent(new_parent) + return new_self + return new_self + + def lazy_get(self, ids: list, numeric_index: bool = False) -> "ModinDtypes": + """ + Get new ``ModinDtypes`` for a subset of columns without triggering any computations. + + Parameters + ---------- + ids : list of index labels or positional indexers + Columns for the subset. + numeric_index : bool, default: False + Whether `ids` are positional indixes or column labels to take. + + Returns + ------- + ModinDtypes + ``ModinDtypes`` that describes dtypes for columns specified in `ids`. + """ + if isinstance(self._value, DtypesDescriptor): + res = self._value.lazy_get(ids, numeric_index) + return ModinDtypes(res) + elif callable(self._value): + new_self = self.copy() + old_value = new_self._value + new_self._value = ( + lambda: old_value().iloc[ids] if numeric_index else old_value()[ids] + ) + return new_self + ErrorMessage.catch_bugs_and_request_email( + failure_condition=not self.is_materialized + ) + return ModinDtypes(self._value.iloc[ids] if numeric_index else self._value[ids]) + + @classmethod + def concat(cls, values: list) -> "ModinDtypes": + """ + Concatenate dtypes.. + + Parameters + ---------- + values : list of DtypesDescriptors, pandas.Series, ModinDtypes and Nones + + Returns + ------- + ModinDtypes + """ + preprocessed_vals = [] + for val in values: + if isinstance(val, cls): + val = val._value + if isinstance(val, (DtypesDescriptor, pandas.Series)) or val is None: + preprocessed_vals.append(val) + else: + raise NotImplementedError(type(val)) + + desc = DtypesDescriptor.concat(preprocessed_vals) + return ModinDtypes(desc) + + def set_index(self, new_index: Union[pandas.Index, "ModinIndex"]) -> "ModinDtypes": + """ + Set new column names for stored dtypes. + + Parameters + ---------- + new_index : pandas.Index or ModinIndex + + Returns + ------- + ModinDtypes + New ``ModinDtypes`` with updated column names. + """ + new_self = self.copy() + if self.is_materialized: + new_self._value.index = new_index + return new_self + elif callable(self._value): + old_val = new_self._value + new_self._value = lambda: old_val().set_axis(new_index) + return new_self + elif isinstance(new_self._value, DtypesDescriptor): + new_self._value = new_self._value.set_index(new_index) + return new_self + else: + raise NotImplementedError() + def get(self) -> pandas.Series: """ Get the materialized internal representation. @@ -57,6 +689,8 @@ def get(self) -> pandas.Series: self._value = self._value() if self._value is None: self._value = pandas.Series([]) + elif isinstance(self._value, DtypesDescriptor): + self._value = self._value.to_series() else: raise NotImplementedError(type(self._value)) return self._value @@ -179,6 +813,22 @@ def __init__(self, categories=None, ordered=False): ) super().__init__(categories, ordered) + @staticmethod + def update_dtypes(dtypes, new_parent): + """ + Update a parent for categorical proxies in a dtype object. + + Parameters + ---------- + dtypes : dict-like + A dict-like object describing dtypes. The method will walk through every dtype + an update parents for categorical proxies inplace. + new_parent : object + """ + for key, value in dtypes.items(): + if isinstance(value, LazyProxyCategoricalDtype): + dtypes[key] = value._update_proxy(new_parent, column_name=key) + def _update_proxy(self, parent, column_name): """ Create a new proxy, if either parent or column name are different. diff --git a/modin/core/dataframe/pandas/metadata/index.py b/modin/core/dataframe/pandas/metadata/index.py index 0d4ec3ed28c..6242b61e2f8 100644 --- a/modin/core/dataframe/pandas/metadata/index.py +++ b/modin/core/dataframe/pandas/metadata/index.py @@ -249,6 +249,22 @@ def __reduce__(self): }, ) + def __getitem__(self, key): + """ + Get an index value at the position of `key`. + + Parameters + ---------- + key : int + + Returns + ------- + label + """ + if not self.is_materialized: + self.get() + return self._value[key] + def __getattr__(self, name): """ Redirect access to non-existent attributes to the internal representation. diff --git a/modin/core/storage_formats/base/query_compiler.py b/modin/core/storage_formats/base/query_compiler.py index 4f240c6d479..1e4233e1248 100644 --- a/modin/core/storage_formats/base/query_compiler.py +++ b/modin/core/storage_formats/base/query_compiler.py @@ -4536,6 +4536,16 @@ def set_index_names(self, names, axis=0): """ self.get_axis(axis).names = names + def get_dtypes_set(self): + """ + Get a set of dtypes that are in this query compiler. + + Returns + ------- + set + """ + return set(self.dtypes.values) + # DateTime methods def between_time(self, **kwargs): # noqa: PR01 """ diff --git a/modin/core/storage_formats/pandas/query_compiler.py b/modin/core/storage_formats/pandas/query_compiler.py index 75a75ded8e1..ba907a8b036 100644 --- a/modin/core/storage_formats/pandas/query_compiler.py +++ b/modin/core/storage_formats/pandas/query_compiler.py @@ -313,6 +313,9 @@ def from_dataframe(cls, df, data_cls): def dtypes(self): return self._modin_frame.dtypes + def get_dtypes_set(self): + return self._modin_frame.get_dtypes_set() + # END Index, columns, and dtypes objects # Metadata modification methods @@ -4322,13 +4325,13 @@ def map_fn(df): # pragma: no cover # than it would be to reuse the code for specific columns. if len(columns) == len(self.columns): new_modin_frame = self._modin_frame.apply_full_axis( - 0, map_fn, new_index=self.index + 0, map_fn, new_index=self.index, dtypes=bool ) untouched_frame = None else: new_modin_frame = self._modin_frame.take_2d_labels_or_positional( col_labels=columns - ).apply_full_axis(0, map_fn, new_index=self.index) + ).apply_full_axis(0, map_fn, new_index=self.index, dtypes=bool) untouched_frame = self.drop(columns=columns) # If we mapped over all the data we are done. If not, we need to # prepend the `new_modin_frame` with the raw data from the columns that were diff --git a/modin/experimental/core/execution/native/implementations/hdk_on_native/dataframe/dataframe.py b/modin/experimental/core/execution/native/implementations/hdk_on_native/dataframe/dataframe.py index faeb24e8c48..03aae68184a 100644 --- a/modin/experimental/core/execution/native/implementations/hdk_on_native/dataframe/dataframe.py +++ b/modin/experimental/core/execution/native/implementations/hdk_on_native/dataframe/dataframe.py @@ -505,12 +505,13 @@ def _dtypes_for_exprs(self, exprs): @_inherit_docstrings(PandasDataframe._maybe_update_proxies) def _maybe_update_proxies(self, dtypes, new_parent=None): if new_parent is not None: - super()._maybe_update_proxies(dtypes, new_parent) + return super()._maybe_update_proxies(dtypes, new_parent) if self._partitions is None: - return + return dtypes table = self._partitions[0][0].get() if isinstance(table, pyarrow.Table): - super()._maybe_update_proxies(dtypes, new_parent=table) + return super()._maybe_update_proxies(dtypes, new_parent=table) + return dtypes def groupby_agg(self, by, axis, agg, groupby_args, **kwargs): """ diff --git a/modin/pandas/dataframe.py b/modin/pandas/dataframe.py index 4251e4b8f55..2484b3932e2 100644 --- a/modin/pandas/dataframe.py +++ b/modin/pandas/dataframe.py @@ -2879,8 +2879,9 @@ def _validate_dtypes(self, numeric_only=False): # Series.__getitem__ treating keys as positions is deprecated. In a future version, # integer keys will always be treated as labels (consistent with DataFrame behavior). # To access a value by position, use `ser.iloc[pos]` - dtype = self.dtypes.iloc[0] - for t in self.dtypes: + dtypes = self._query_compiler.get_dtypes_set() + dtype = next(iter(dtypes)) + for t in dtypes: if numeric_only and not is_numeric_dtype(t): raise TypeError("{0} is not a numeric data type".format(t)) elif not numeric_only and t != dtype: diff --git a/modin/test/storage_formats/pandas/test_internals.py b/modin/test/storage_formats/pandas/test_internals.py index 71310f3ee5d..b459f050263 100644 --- a/modin/test/storage_formats/pandas/test_internals.py +++ b/modin/test/storage_formats/pandas/test_internals.py @@ -22,6 +22,11 @@ import modin.pandas as pd from modin.config import Engine, ExperimentalGroupbyImpl, MinPartitionSize, NPartitions from modin.core.dataframe.pandas.dataframe.utils import ColumnInfo, ShuffleSortFunctions +from modin.core.dataframe.pandas.metadata import ( + DtypesDescriptor, + LazyProxyCategoricalDtype, + ModinDtypes, +) from modin.core.storage_formats.pandas.utils import split_result_of_axis_func_pandas from modin.distributed.dataframe.pandas import from_partitions from modin.pandas.test.utils import create_test_dfs, df_equals, test_data_values @@ -1011,7 +1016,7 @@ def test_merge_preserves_metadata(has_cols_metadata, has_dtypes_metadata): if has_dtypes_metadata: # Verify that there were initially materialized metadata - assert modin_frame.has_dtypes_cache + assert modin_frame.has_materialized_dtypes else: modin_frame.set_dtypes_cache(None) @@ -1020,18 +1025,18 @@ def test_merge_preserves_metadata(has_cols_metadata, has_dtypes_metadata): if has_cols_metadata: assert res.has_materialized_columns if has_dtypes_metadata: - assert res.has_dtypes_cache + assert res.has_materialized_dtypes else: # Verify that no materialization was triggered - assert not res.has_dtypes_cache - assert not modin_frame.has_dtypes_cache + assert not res.has_materialized_dtypes + assert not modin_frame.has_materialized_dtypes else: # Verify that no materialization was triggered assert not res.has_materialized_columns - assert not res.has_dtypes_cache + assert not res.has_materialized_dtypes assert not modin_frame.has_materialized_columns if not has_dtypes_metadata: - assert not modin_frame.has_dtypes_cache + assert not modin_frame.has_materialized_dtypes def test_binary_op_preserve_dtypes(): @@ -1511,3 +1516,447 @@ def assert_materialized(obj): assert call_queue == reconstructed_queue assert_everything_materialized(reconstructed_queue) + + +class TestModinDtypes: + """Test ``ModinDtypes`` and ``DtypesDescriptor`` classes.""" + + schema = pandas.Series( + { + "a": np.dtype(int), + "b": np.dtype(float), + "c": np.dtype(bool), + "d": np.dtype(bool), + "e": np.dtype("object"), + } + ) + + def get_columns_order(self, cols): + """Return a value to be passed as ``DtypesDescriptor(columns_order=...)`` parameter.""" + return {i: col for i, col in enumerate(cols)} + + class DummyDf: + def __init__(self, schema): + self._schema = pandas.Series(schema) + # record calls to verify that we haven't materialized more than needed + self.history = [] + + def _compute_dtypes(self, subset=None): + self.history.append(("_compute_dtypes", subset)) + return self._schema if subset is None else self._schema[subset] + + @property + def columns(self): + self.history.append(("columns",)) + return self._schema.index + + @property + def has_materialized_columns(self): + # False, to make descriptor avoid materialization at all cost + return False + + def test_get_dtypes_set_modin_dtypes(self): + """Test that ``ModinDtypes.get_dtypes_set()`` correctly propagates this request to the underlying value.""" + res = ModinDtypes(lambda: self.schema).get_dtypes_set() + exp = set(self.schema.values) + assert res == exp + + res = ModinDtypes(self.schema).get_dtypes_set() + exp = set(self.schema.values) + assert res == exp + + res = ModinDtypes( + DtypesDescriptor( + self.schema[["a", "b", "e"]], remaining_dtype=np.dtype(bool) + ) + ).get_dtypes_set() + exp = set(self.schema.values) + assert res == exp + + def test_get_dtypes_set_desc(self): + """ + Test that ``DtypesDescriptor.get_dtypes_set()`` returns valid values and doesn't + trigger unnecessary computations. + """ + df = self.DummyDf(self.schema) + desc = DtypesDescriptor( + self.schema[["a", "b"]], know_all_names=False, parent_df=df + ) + res = desc.get_dtypes_set() + exp = self.schema.values + assert res == set(exp) + # since 'know_all_names=False', we first have to retrieve columns + # in order to determine missing dtypes and then call '._compute_dtypes()' + # only on a subset + assert len(df.history) == 2 and df.history == [ + ("columns",), + ("_compute_dtypes", ["c", "d", "e"]), + ] + + df = self.DummyDf(self.schema) + desc = DtypesDescriptor( + self.schema[["a", "b"]], + cols_with_unknown_dtypes=["c", "d", "e"], + parent_df=df, + ) + res = desc.get_dtypes_set() + exp = self.schema.values + assert res == set(exp) + # here we already know names for cols with unknown dtypes, so only + # calling '._compute_dtypes()' on a subset + assert len(df.history) == 1 and df.history[0] == ( + "_compute_dtypes", + ["c", "d", "e"], + ) + + df = self.DummyDf(self.schema[["a", "b", "c", "d"]]) + desc = DtypesDescriptor( + self.schema[["a", "b"]], remaining_dtype=np.dtype(bool), parent_df=df + ) + res = desc.get_dtypes_set() + exp = self.schema[["a", "b", "c", "d"]].values + assert res == set(exp) + # we don't need to access 'parent_df' in order to get dtypes set, as we + # can infer it from 'known_dtypes' and 'remaining_dtype' + assert len(df.history) == 0 + + df = self.DummyDf(self.schema) + desc = DtypesDescriptor(know_all_names=False, parent_df=df) + res = desc.get_dtypes_set() + exp = self.schema.values + assert res == set(exp) + # compute dtypes for all columns + assert len(df.history) == 1 and df.history[0] == ("_compute_dtypes", None) + + df = self.DummyDf(self.schema) + desc = DtypesDescriptor( + cols_with_unknown_dtypes=self.schema.index.tolist(), parent_df=df + ) + res = desc.get_dtypes_set() + exp = self.schema.values + assert res == set(exp) + # compute dtypes for all columns + assert len(df.history) == 1 and df.history[0] == ( + "_compute_dtypes", + self.schema.index.tolist(), + ) + + df = self.DummyDf(self.schema) + desc = DtypesDescriptor( + cols_with_unknown_dtypes=["a", "b", "e"], + remaining_dtype=np.dtype(bool), + parent_df=df, + ) + res = desc.get_dtypes_set() + exp = self.schema.values + assert res == set(exp) + # here we already know names for cols with unknown dtypes, so only + # calling '._compute_dtypes()' on a subset + assert len(df.history) == 1 and df.history[0] == ( + "_compute_dtypes", + ["a", "b", "e"], + ) + + def test_lazy_get_modin_dtypes(self): + """Test that ``ModinDtypes.lazy_get()`` correctly propagates this request to the underlying value.""" + res = ModinDtypes(self.schema).lazy_get(["b", "c", "a"]) + exp = self.schema[["b", "c", "a"]] + assert res._value.equals(exp) + + res = ModinDtypes(lambda: self.schema).lazy_get(["b", "c", "a"]) + exp = self.schema[["b", "c", "a"]] + assert callable(res._value) + assert res._value().equals(exp) + + res = ModinDtypes( + DtypesDescriptor( + self.schema[["a", "b"]], cols_with_unknown_dtypes=["c", "d", "e"] + ) + ).lazy_get(["b", "c", "a"]) + exp = DtypesDescriptor( + self.schema[["a", "b"]], + cols_with_unknown_dtypes=["c"], + columns_order={0: "b", 1: "c", 2: "a"}, + ) + assert res._value.equals(exp) + + def test_lazy_get_desc(self): + """ + Test that ``DtypesDescriptor.lazy_get()`` work properly. + + In this test we never specify `parent_df` for a descriptor, verifying that + ``.lazy_get()`` never triggers any computations. + """ + desc = DtypesDescriptor(self.schema[["a", "b"]]) + subset = ["a", "c", "e"] + res = desc.lazy_get(subset) + exp = DtypesDescriptor( + self.schema[subset[:1]], + cols_with_unknown_dtypes=subset[1:], + columns_order=self.get_columns_order(subset), + ) + assert res.equals(exp) + + desc = DtypesDescriptor(self.schema[["a", "b"]], remaining_dtype=np.dtype(bool)) + subset = ["a", "c", "d"] + res = desc.lazy_get(subset) + exp = DtypesDescriptor( + # dtypes for 'c' and 'b' were infered from 'remaining_dtype' parameter + self.schema[subset], + columns_order=self.get_columns_order(subset), + _schema_is_known=True, + ) + assert res.equals(exp) + + desc = DtypesDescriptor() + subset = ["a", "c", "d"] + res = desc.lazy_get(subset) + exp = DtypesDescriptor( + cols_with_unknown_dtypes=subset, + columns_order=self.get_columns_order(subset), + ) + assert res.equals(exp) + + desc = DtypesDescriptor(remaining_dtype=np.dtype(bool)) + subset = ["c", "d"] + res = desc.lazy_get(subset) + exp = DtypesDescriptor( + # dtypes for 'c' and 'd' were infered from 'remaining_dtype' parameter + self.schema[subset], + columns_order=self.get_columns_order(subset), + _schema_is_known=True, + ) + assert res.equals(exp) + + def test_concat(self): + res = DtypesDescriptor.concat( + [ + DtypesDescriptor(self.schema[["a", "b"]]), + DtypesDescriptor(self.schema[["c", "d"]]), + ] + ) + # simply concat known schemas + exp = DtypesDescriptor(self.schema[["a", "b", "c", "d"]]) + assert res.equals(exp) + + res = DtypesDescriptor.concat( + [ + DtypesDescriptor(self.schema[["a", "b"]]), + DtypesDescriptor(remaining_dtype=np.dtype(bool)), + ] + ) + # none of the descriptors had missing column names, so we can preserve 'remaining_dtype' + exp = DtypesDescriptor(self.schema[["a", "b"]], remaining_dtype=np.dtype(bool)) + assert res.equals(exp) + + res = DtypesDescriptor.concat( + [ + DtypesDescriptor(self.schema[["a", "b"]], know_all_names=False), + DtypesDescriptor(remaining_dtype=np.dtype(bool)), + ] + ) + # can't preserve 'remaining_dtype' since first descriptor has unknown column names + exp = DtypesDescriptor(self.schema[["a", "b"]], know_all_names=False) + assert res.equals(exp) + + res = DtypesDescriptor.concat( + [ + DtypesDescriptor(self.schema[["a", "b"]]), + DtypesDescriptor( + cols_with_unknown_dtypes=["d", "e"], know_all_names=False + ), + DtypesDescriptor(remaining_dtype=np.dtype(bool)), + ] + ) + # can't preserve 'remaining_dtype' since second descriptor has unknown column names + exp = DtypesDescriptor( + self.schema[["a", "b"]], + cols_with_unknown_dtypes=["d", "e"], + know_all_names=False, + ) + assert res.equals(exp) + + res = DtypesDescriptor.concat( + [ + DtypesDescriptor( + self.schema[["a", "b"]], + ), + DtypesDescriptor( + cols_with_unknown_dtypes=["d", "e"], + ), + DtypesDescriptor(remaining_dtype=np.dtype(bool)), + ] + ) + # none of the descriptors had missing column names, so we can preserve 'remaining_dtype' + exp = DtypesDescriptor( + self.schema[["a", "b"]], + cols_with_unknown_dtypes=["d", "e"], + remaining_dtype=np.dtype(bool), + ) + assert res.equals(exp) + + res = DtypesDescriptor.concat( + [ + DtypesDescriptor( + self.schema[["a", "b"]], remaining_dtype=np.dtype(bool) + ), + DtypesDescriptor( + cols_with_unknown_dtypes=["d", "e"], remaining_dtype=np.dtype(float) + ), + DtypesDescriptor(remaining_dtype=np.dtype(bool)), + ] + ) + # remaining dtypes don't match, so we drop them and set 'know_all_names=False' + exp = DtypesDescriptor( + self.schema[["a", "b"]], + cols_with_unknown_dtypes=["d", "e"], + know_all_names=False, + ) + assert res.equals(exp) + + def test_update_parent(self): + """ + Test that updating parents in ``DtypesDescriptor`` also propagates to stored lazy categoricals. + """ + # 'df1' will have a materialized 'pandas.Series' as dtypes cache + df1 = pd.DataFrame({"a": [1, 1, 2], "b": [3, 4, 5]}).astype({"a": "category"}) + assert isinstance(df1.dtypes["a"], LazyProxyCategoricalDtype) + + # 'df2' will have a 'DtypesDescriptor' with unknown dtypes for a column 'c' + df2 = pd.DataFrame({"c": [2, 3, 4]}) + df2._query_compiler._modin_frame.set_dtypes_cache(None) + dtypes_cache = df2._query_compiler._modin_frame._dtypes + assert isinstance( + dtypes_cache._value, DtypesDescriptor + ) and dtypes_cache._value._cols_with_unknown_dtypes == ["c"] + + # concatenating 'df1' and 'df2' to get a 'DtypesDescriptor' storing lazy categories + # in its 'known_dtypes' field + res = pd.concat([df1, df2], axis=1) + old_parent = df1._query_compiler._modin_frame + new_parent = res._query_compiler._modin_frame + dtypes_cache = new_parent._dtypes._value + + # verifying that the reference for lazy categories to a new parent was updated + assert dtypes_cache._parent_df is new_parent + assert dtypes_cache._known_dtypes["a"]._parent is new_parent + assert old_parent._dtypes["a"]._parent is old_parent + + @pytest.mark.parametrize( + "initial_dtypes, result_dtypes", + [ + [ + DtypesDescriptor( + {"a": np.dtype(int), "b": np.dtype(float), "c": np.dtype(float)} + ), + DtypesDescriptor( + cols_with_unknown_dtypes=["col1", "col2", "col3"], + columns_order={0: "col1", 1: "col2", 2: "col3"}, + ), + ], + [ + DtypesDescriptor( + {"a": np.dtype(int), "b": np.dtype(float), "c": np.dtype(float)}, + columns_order={0: "a", 1: "b", 2: "c"}, + ), + DtypesDescriptor( + { + "col1": np.dtype(int), + "col2": np.dtype(float), + "col3": np.dtype(float), + }, + columns_order={0: "col1", 1: "col2", 2: "col3"}, + ), + ], + [ + DtypesDescriptor( + {"a": np.dtype(int), "b": np.dtype(float)}, + cols_with_unknown_dtypes=["c"], + columns_order={0: "a", 1: "b", 2: "c"}, + ), + DtypesDescriptor( + {"col1": np.dtype(int), "col2": np.dtype(float)}, + cols_with_unknown_dtypes=["col3"], + columns_order={0: "col1", 1: "col2", 2: "col3"}, + ), + ], + [ + DtypesDescriptor( + {"a": np.dtype(int)}, + cols_with_unknown_dtypes=["c"], + know_all_names=False, + ), + DtypesDescriptor( + cols_with_unknown_dtypes=["col1", "col2", "col3"], + columns_order={0: "col1", 1: "col2", 2: "col3"}, + ), + ], + [ + DtypesDescriptor({"a": np.dtype(int)}, remaining_dtype=np.dtype(float)), + DtypesDescriptor( + cols_with_unknown_dtypes=["col1", "col2", "col3"], + columns_order={0: "col1", 1: "col2", 2: "col3"}, + ), + ], + [ + lambda: pandas.Series( + [np.dtype(int), np.dtype(float), np.dtype(float)], + index=["a", "b", "c"], + ), + lambda: pandas.Series( + [np.dtype(int), np.dtype(float), np.dtype(float)], + index=["col1", "col2", "col3"], + ), + ], + [ + pandas.Series( + [np.dtype(int), np.dtype(float), np.dtype(float)], + index=["a", "b", "c"], + ), + pandas.Series( + [np.dtype(int), np.dtype(float), np.dtype(float)], + index=["col1", "col2", "col3"], + ), + ], + ], + ) + def test_set_index_dataframe(self, initial_dtypes, result_dtypes): + """Test that changing labels for a dataframe also updates labels of dtypes.""" + df = pd.DataFrame( + {"a": [1, 2, 3], "b": [3.0, 4.0, 5.0], "c": [3.2, 4.5, 5.4]} + )._query_compiler._modin_frame + df.set_columns_cache(None) + if isinstance(initial_dtypes, DtypesDescriptor): + initial_dtypes = ModinDtypes(initial_dtypes) + + df.set_dtypes_cache(initial_dtypes) + df.columns = ["col1", "col2", "col3"] + + if result_dtypes is not None: + if callable(result_dtypes): + assert callable(df._dtypes._value) + assert df._dtypes._value().equals(result_dtypes()) + else: + assert df._dtypes._value.equals(result_dtypes) + assert df.dtypes.index.equals(pandas.Index(["col1", "col2", "col3"])) + + +class TestZeroComputationDtypes: + """ + Test cases that shouldn't trigger dtypes computation during their execution. + """ + + def test_get_dummies_case(self): + from modin.core.dataframe.pandas.dataframe.dataframe import PandasDataframe + + with mock.patch.object(PandasDataframe, "_compute_dtypes") as patch: + df = pd.DataFrame( + {"items": [1, 2, 3, 4], "b": [3, 3, 4, 4], "c": [1, 0, 0, 1]} + ) + res = pd.get_dummies(df, columns=["b", "c"]) + cols = [col for col in res.columns if col != "items"] + res[cols] = res[cols] / res[cols].mean() + + assert res._query_compiler._modin_frame.has_materialized_dtypes + + patch.assert_not_called() From bee2c28a3cededa4c5c4b61e9e59c77401ae39a8 Mon Sep 17 00:00:00 2001 From: Anatoly Myachev Date: Fri, 17 Nov 2023 14:00:19 +0100 Subject: [PATCH 085/201] REFACTOR-#6739: Use execution_wrapper instead of directly addressing DaskWrapper (#6740) Signed-off-by: Anatoly Myachev --- .../pandas_on_dask/partitioning/partition.py | 28 +++++++++++-------- .../partitioning/partition_manager.py | 2 +- 2 files changed, 17 insertions(+), 13 deletions(-) diff --git a/modin/core/execution/dask/implementations/pandas_on_dask/partitioning/partition.py b/modin/core/execution/dask/implementations/pandas_on_dask/partitioning/partition.py index 8dcc87b5699..a8d10bf60f7 100644 --- a/modin/core/execution/dask/implementations/pandas_on_dask/partitioning/partition.py +++ b/modin/core/execution/dask/implementations/pandas_on_dask/partitioning/partition.py @@ -93,7 +93,7 @@ def apply(self, func, *args, **kwargs): self._is_debug(log) and log.debug( f"SUBMIT::_apply_list_of_funcs::{self._identity}" ) - futures = DaskWrapper.deploy( + futures = self.execution_wrapper.deploy( func=apply_list_of_funcs, f_args=(call_queue, self._data), num_returns=2, @@ -103,7 +103,7 @@ def apply(self, func, *args, **kwargs): # We handle `len(call_queue) == 1` in a different way because # this improves performance a bit. func, f_args, f_kwargs = call_queue[0] - futures = DaskWrapper.deploy( + futures = self.execution_wrapper.deploy( func=apply_func, f_args=(self._data, func, *f_args), f_kwargs=f_kwargs, @@ -127,7 +127,7 @@ def drain_call_queue(self): self._is_debug(log) and log.debug( f"SUBMIT::_apply_list_of_funcs::{self._identity}" ) - futures = DaskWrapper.deploy( + futures = self.execution_wrapper.deploy( func=apply_list_of_funcs, f_args=(call_queue, self._data), num_returns=2, @@ -138,7 +138,7 @@ def drain_call_queue(self): # this improves performance a bit. func, f_args, f_kwargs = call_queue[0] self._is_debug(log) and log.debug(f"SUBMIT::_apply_func::{self._identity}") - futures = DaskWrapper.deploy( + futures = self.execution_wrapper.deploy( func=apply_func, f_args=(self._data, func, *f_args), f_kwargs=f_kwargs, @@ -155,7 +155,7 @@ def drain_call_queue(self): def wait(self): """Wait completing computations on the object wrapped by the partition.""" self.drain_call_queue() - DaskWrapper.wait(self._data) + self.execution_wrapper.wait(self._data) def mask(self, row_labels, col_labels): """ @@ -181,7 +181,7 @@ def mask(self, row_labels, col_labels): # fast path - full axis take new_obj._length_cache = self._length_cache else: - new_obj._length_cache = DaskWrapper.deploy( + new_obj._length_cache = self.execution_wrapper.deploy( func=compute_sliced_len, f_args=(row_labels, self._length_cache) ) if isinstance(col_labels, slice) and isinstance(self._width_cache, Future): @@ -189,7 +189,7 @@ def mask(self, row_labels, col_labels): # fast path - full axis take new_obj._width_cache = self._width_cache else: - new_obj._width_cache = DaskWrapper.deploy( + new_obj._width_cache = self.execution_wrapper.deploy( func=compute_sliced_len, f_args=(col_labels, self._width_cache) ) self._is_debug(log) and log.debug(f"EXIT::Partition.mask::{self._identity}") @@ -227,7 +227,11 @@ def put(cls, obj): PandasOnDaskDataframePartition A new ``PandasOnDaskDataframePartition`` object. """ - return cls(DaskWrapper.put(obj, hash=False), len(obj.index), len(obj.columns)) + return cls( + cls.execution_wrapper.put(obj, hash=False), + len(obj.index), + len(obj.columns), + ) @classmethod def preprocess_func(cls, func): @@ -244,7 +248,7 @@ def preprocess_func(cls, func): callable An object that can be accepted by ``apply``. """ - return DaskWrapper.put(func, hash=False, broadcast=True) + return cls.execution_wrapper.put(func, hash=False, broadcast=True) def length(self, materialize=True): """ @@ -265,7 +269,7 @@ def length(self, materialize=True): if self._length_cache is None: self._length_cache = self.apply(len)._data if isinstance(self._length_cache, Future) and materialize: - self._length_cache = DaskWrapper.materialize(self._length_cache) + self._length_cache = self.execution_wrapper.materialize(self._length_cache) return self._length_cache def width(self, materialize=True): @@ -287,7 +291,7 @@ def width(self, materialize=True): if self._width_cache is None: self._width_cache = self.apply(lambda df: len(df.columns))._data if isinstance(self._width_cache, Future) and materialize: - self._width_cache = DaskWrapper.materialize(self._width_cache) + self._width_cache = self.execution_wrapper.materialize(self._width_cache) return self._width_cache def ip(self, materialize=True): @@ -309,7 +313,7 @@ def ip(self, materialize=True): if self._ip_cache is None: self._ip_cache = self.apply(lambda df: pandas.DataFrame([]))._ip_cache if materialize and isinstance(self._ip_cache, Future): - self._ip_cache = DaskWrapper.materialize(self._ip_cache) + self._ip_cache = self.execution_wrapper.materialize(self._ip_cache) return self._ip_cache diff --git a/modin/core/execution/dask/implementations/pandas_on_dask/partitioning/partition_manager.py b/modin/core/execution/dask/implementations/pandas_on_dask/partitioning/partition_manager.py index f045c6ef392..a1a46dc0f12 100644 --- a/modin/core/execution/dask/implementations/pandas_on_dask/partitioning/partition_manager.py +++ b/modin/core/execution/dask/implementations/pandas_on_dask/partitioning/partition_manager.py @@ -46,6 +46,6 @@ def wait_partitions(cls, partitions): partitions : np.ndarray NumPy array with ``PandasDataframePartition``-s. """ - DaskWrapper.wait( + cls._execution_wrapper.wait( [block for partition in partitions for block in partition.list_of_blocks] ) From 97d88b210954d1e8e9406401a9769349bdc5d8ec Mon Sep 17 00:00:00 2001 From: Iaroslav Igoshev Date: Fri, 17 Nov 2023 20:49:32 +0100 Subject: [PATCH 086/201] FEAT-#6735: Make Modin on MPI through unidist component more obvious (#6736) Signed-off-by: Igoshev, Iaroslav --- .github/workflows/ci-notebooks.yml | 24 ++++++++------ .github/workflows/ci.yml | 8 ++++- README.md | 22 +++++++++---- docs/conf.py | 21 ++++++++++++- docs/development/architecture.rst | 9 +++--- docs/development/index.rst | 2 +- docs/development/partition_api.rst | 2 +- ...on_unidist.rst => using_pandas_on_mpi.rst} | 27 +++++++++++++--- .../pandas_on_unidist/index.rst | 3 +- docs/getting_started/faq.rst | 2 +- docs/getting_started/installation.rst | 31 +++++++++++++------ docs/index.rst | 4 +-- examples/tutorial/jupyter/README.md | 2 +- .../execution/pandas_on_unidist/Dockerfile | 3 +- .../pandas_on_unidist/jupyter_unidist_env.yml | 11 +++++++ .../pandas_on_unidist/local/exercise_1.ipynb | 2 +- .../pandas_on_unidist/requirements.txt | 5 --- modin/core/execution/unidist/common/utils.py | 2 +- requirements/env_unidist_linux.yml | 2 +- requirements/env_unidist_win.yml | 2 +- setup.py | 10 ++++-- 21 files changed, 138 insertions(+), 56 deletions(-) rename docs/development/{using_pandas_on_unidist.rst => using_pandas_on_mpi.rst} (58%) create mode 100644 examples/tutorial/jupyter/execution/pandas_on_unidist/jupyter_unidist_env.yml delete mode 100644 examples/tutorial/jupyter/execution/pandas_on_unidist/requirements.txt diff --git a/.github/workflows/ci-notebooks.yml b/.github/workflows/ci-notebooks.yml index b632604aa28..8b264853cef 100644 --- a/.github/workflows/ci-notebooks.yml +++ b/.github/workflows/ci-notebooks.yml @@ -8,6 +8,7 @@ on: - setup.cfg - setup.py - requirements/env_hdk.yml + - requirements/env_unidist_linux.yml concurrency: # Cancel other jobs in the same branch. We don't care whether CI passes # on old commits. @@ -28,12 +29,17 @@ jobs: steps: - uses: actions/checkout@v3 - uses: ./.github/actions/python-only - if: matrix.execution != 'hdk_on_native' + if: matrix.execution != 'hdk_on_native' && matrix.execution != 'pandas_on_unidist' - uses: ./.github/actions/mamba-env with: environment-file: requirements/env_hdk.yml activate-environment: modin_on_hdk if: matrix.execution == 'hdk_on_native' + - uses: ./.github/actions/mamba-env + with: + environment-file: requirements/env_unidist_linux.yml + activate-environment: modin_on_unidist + if: matrix.execution == 'pandas_on_unidist' - name: Cache datasets uses: actions/cache@v2 with: @@ -43,29 +49,29 @@ jobs: # replace modin with . in the tutorial requirements file for `pandas_on_ray` and # `pandas_on_dask` since we need Modin built from sources - run: sed -i 's/modin/./g' examples/tutorial/jupyter/execution/${{ matrix.execution }}/requirements.txt - if: matrix.execution != 'hdk_on_native' + if: matrix.execution != 'hdk_on_native' && matrix.execution != 'pandas_on_unidist' # install dependencies required for notebooks execution for `pandas_on_ray` and `pandas_on_dask` # Override modin-spreadsheet install for now - run: | pip install -r examples/tutorial/jupyter/execution/${{ matrix.execution }}/requirements.txt pip install git+https://github.com/modin-project/modin-spreadsheet.git@49ffd89f683f54c311867d602c55443fb11bf2a5 - if: matrix.execution != 'hdk_on_native' - # Build Modin from sources for `hdk_on_native` + if: matrix.execution != 'hdk_on_native' && matrix.execution != 'pandas_on_unidist' + # Build Modin from sources for `hdk_on_native` and `pandas_on_unidist` - run: pip install -e . - if: matrix.execution == 'hdk_on_native' + if: matrix.execution == 'hdk_on_native' || matrix.execution == 'pandas_on_unidist' # install test dependencies # NOTE: If you are changing the set of packages installed here, make sure that # the dev requirements match them. - run: pip install pytest pytest-cov black flake8 flake8-print flake8-no-implicit-concat - if: matrix.execution != 'hdk_on_native' + if: matrix.execution != 'hdk_on_native' && matrix.execution != 'pandas_on_unidist' - run: pip install flake8-print jupyter nbformat nbconvert - if: matrix.execution == 'hdk_on_native' + if: matrix.execution == 'hdk_on_native' || matrix.execution == 'pandas_on_unidist' - run: pip list - if: matrix.execution != 'hdk_on_native' + if: matrix.execution != 'hdk_on_native' && matrix.execution != 'pandas_on_unidist' - run: | conda info conda list - if: matrix.execution == 'hdk_on_native' + if: matrix.execution == 'hdk_on_native' || matrix.execution == 'pandas_on_unidist' # setup kernel configuration for `pandas_on_unidist` execution with mpi backend - run: python examples/tutorial/jupyter/execution/${{ matrix.execution }}/setup_kernel.py if: matrix.execution == 'pandas_on_unidist' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e121396a0fa..b938e08b7c6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -93,11 +93,17 @@ jobs: - uses: actions/checkout@v3 - uses: ./.github/actions/python-only - run: python -m pip install -e ".[all]" - - name: Ensure all engines start up + - name: Ensure Ray and Dask engines start up run: | MODIN_ENGINE=dask python -c "import modin.pandas as pd; print(pd.DataFrame([1,2,3]))" MODIN_ENGINE=ray python -c "import modin.pandas as pd; print(pd.DataFrame([1,2,3]))" + - name: Ensure MPI engine start up + # Install a working MPI implementation beforehand so mpi4py can link to it + run: | + sudo apt install libmpich-dev + python -m pip install -e ".[mpi]" MODIN_ENGINE=unidist UNIDIST_BACKEND=mpi mpiexec -n 1 python -c "import modin.pandas as pd; print(pd.DataFrame([1,2,3]))" + if: matrix.os == 'ubuntu' test-internals: needs: [lint-flake8, lint-black-isort] diff --git a/README.md b/README.md index cdfa62332e7..b11e68ba769 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,7 @@ The charts below show the speedup you get by replacing pandas with Modin based o Modin can be installed with `pip` on Linux, Windows and MacOS: ```bash -pip install "modin[all]" # (Recommended) Install Modin with all of Modin's currently supported engines. +pip install "modin[all]" # (Recommended) Install Modin with Ray and Dask engines. ``` If you want to install Modin with a specific engine, we recommend: @@ -65,16 +65,22 @@ If you want to install Modin with a specific engine, we recommend: ```bash pip install "modin[ray]" # Install Modin dependencies and Ray. pip install "modin[dask]" # Install Modin dependencies and Dask. -pip install "modin[unidist]" # Install Modin dependencies and Unidist. +pip install "modin[mpi]" # Install Modin dependencies and MPI through unidist. ``` +To get Modin on MPI through unidist (as of unidist 0.5.0) fully working +it is required to have a working MPI implementation installed beforehand. +Otherwise, installation of `modin[mpi]` may fail. Refer to +[Installing with pip](https://unidist.readthedocs.io/en/latest/installation.html#installing-with-pip) +section of the unidist documentation for more details about installation. + Modin automatically detects which engine(s) you have installed and uses that for scheduling computation. #### From conda-forge Installing from [conda forge](https://github.com/conda-forge/modin-feedstock) using `modin-all` will install Modin and four engines: [Ray](https://github.com/ray-project/ray), [Dask](https://github.com/dask/dask), -[Unidist](https://github.com/modin-project/unidist) and [HDK](https://github.com/intel-ai/hdk). +[MPI through unidist](https://github.com/modin-project/unidist) and [HDK](https://github.com/intel-ai/hdk). ```bash conda install -c conda-forge modin-all @@ -85,10 +91,14 @@ Each engine can also be installed individually (and also as a combination of sev ```bash conda install -c conda-forge modin-ray # Install Modin dependencies and Ray. conda install -c conda-forge modin-dask # Install Modin dependencies and Dask. -conda install -c conda-forge modin-unidist # Install Modin dependencies and Unidist. +conda install -c conda-forge modin-mpi # Install Modin dependencies and MPI through unidist. conda install -c conda-forge modin-hdk # Install Modin dependencies and HDK. ``` +Refer to +[Installing with conda](https://unidist.readthedocs.io/en/latest/installation.html#installing-with-conda) +section of the unidist documentation for more details on how to install a specific MPI implementation to run on. + To speed up conda installation we recommend using libmamba solver. To do this install it in a base environment: ```bash @@ -119,7 +129,7 @@ export MODIN_ENGINE=unidist # Modin will use Unidist ``` If you want to choose the Unidist engine, you should set the additional environment -variable ``UNIDIST_BACKEND``, because currently Modin only supports Unidist on MPI: +variable ``UNIDIST_BACKEND``. Currently, Modin only supports MPI through unidist: ```bash export UNIDIST_BACKEND=mpi # Unidist will use MPI backend @@ -144,7 +154,7 @@ _Note: You should not change the engine after your first operation with Modin as #### Which engine should I use? -On Linux, MacOS, and Windows you can install and use either Ray, Dask or Unidist. There is no knowledge required +On Linux, MacOS, and Windows you can install and use either Ray, Dask or MPI through unidist. There is no knowledge required to use either of these engines as Modin abstracts away all of the complexity, so feel free to pick either! diff --git a/docs/conf.py b/docs/conf.py index 72a204c8d09..7993d8cf254 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -26,7 +26,16 @@ def noop_decorator(*args, **kwargs): ray.remote = noop_decorator # fake modules if they're missing -for mod_name in ("cudf", "cupy", "pyarrow.gandiva", "pyhdk", "pyhdk.hdk", "xgboost"): +for mod_name in ( + "cudf", + "cupy", + "pyarrow.gandiva", + "pyhdk", + "pyhdk.hdk", + "xgboost", + "unidist", + "unidist.config", +): try: __import__(mod_name) except ImportError: @@ -52,6 +61,16 @@ def noop_decorator(*args, **kwargs): sys.modules["pyhdk"].__version__ = "999" if not hasattr(sys.modules["xgboost"], "Booster"): sys.modules["xgboost"].Booster = type("Booster", (object,), {}) +if not hasattr(sys.modules["unidist"], "remote"): + sys.modules["unidist"].remote = noop_decorator +if not hasattr(sys.modules["unidist"], "core"): + sys.modules["unidist"].core = type("core", (object,), {}) +if not hasattr(sys.modules["unidist"].core, "base"): + sys.modules["unidist"].core.base = type("base", (object,), {}) +if not hasattr(sys.modules["unidist"].core.base, "object_ref"): + sys.modules["unidist"].core.base.object_ref = type("object_ref", (object,), {}) +if not hasattr(sys.modules["unidist"].core.base.object_ref, "ObjectRef"): + sys.modules["unidist"].core.base.object_ref.ObjectRef = type("ObjectRef", (object,), {}) sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) import modin diff --git a/docs/development/architecture.rst b/docs/development/architecture.rst index 22fc7f91496..61bff9c6819 100644 --- a/docs/development/architecture.rst +++ b/docs/development/architecture.rst @@ -216,8 +216,8 @@ documentation page on :doc:`contributing `. - Uses the `Dask Futures`_ execution framework. - The storage format is `pandas` and the in-memory partition type is a pandas DataFrame. - For more information on the execution path, see the :doc:`pandas on Dask ` page. -- :doc:`pandas on Unidist ` - - Uses the Unidist_ execution framework. +- :doc:`pandas on MPI ` + - Uses MPI_ through the Unidist_ execution framework. - The storage format is `pandas` and the in-memory partition type is a pandas DataFrame. - For more information on the execution path, see the :doc:`pandas on Unidist ` page. - :doc:`pandas on Python ` @@ -228,8 +228,8 @@ documentation page on :doc:`contributing `. - Uses the Ray_ execution framework. - The storage format is `pandas` and the in-memory partition type is a pandas DataFrame. - For more information on the execution path, see the :doc:`experimental pandas on Ray ` page. -- pandas on Unidist (experimental) - - Uses the Unidist_ execution framework. +- pandas on MPI (experimental) + - Uses MPI_ through the Unidist_ execution framework. - The storage format is `pandas` and the in-memory partition type is a pandas DataFrame. - For more information on the execution path, see the :doc:`experimental pandas on Unidist ` page. - pandas on Dask (experimental) @@ -375,6 +375,7 @@ details. The documentation covers most modules, with more docs being added every .. _Arrow tables: https://arrow.apache.org/docs/python/generated/pyarrow.Table.html .. _Ray: https://github.com/ray-project/ray .. _Unidist: https://github.com/modin-project/unidist +.. _MPI: https://www.mpi-forum.org/ .. _code: https://github.com/modin-project/modin/blob/master/modin/core/dataframe .. _Dask: https://github.com/dask/dask .. _Dask Futures: https://docs.dask.org/en/latest/futures.html diff --git a/docs/development/index.rst b/docs/development/index.rst index ab820cb17bd..b1e5c3f1212 100644 --- a/docs/development/index.rst +++ b/docs/development/index.rst @@ -10,7 +10,7 @@ Development using_pandas_on_ray using_pandas_on_dask using_pandas_on_python - using_pandas_on_unidist + using_pandas_on_mpi using_hdk using_pyarrow_on_ray diff --git a/docs/development/partition_api.rst b/docs/development/partition_api.rst index 40fb7532140..3844fdb5ab4 100644 --- a/docs/development/partition_api.rst +++ b/docs/development/partition_api.rst @@ -36,7 +36,7 @@ in the worker process that processes a function (please, refer to `Dask document Unidist engine -------------- -Currently, Modin only supports unidist on MPI backend. There is no mentioned above issue for +Currently, Modin only supports MPI through unidist. There is no mentioned above issue for Modin on ``Unidist`` engine using ``MPI`` backend with ``pandas`` in-memory format because ``Unidist`` saves any objects in the MPI worker process that processes a function (please, refer to `Unidist documentation`_ for more information). diff --git a/docs/development/using_pandas_on_unidist.rst b/docs/development/using_pandas_on_mpi.rst similarity index 58% rename from docs/development/using_pandas_on_unidist.rst rename to docs/development/using_pandas_on_mpi.rst index 9acc3126268..c06164344aa 100644 --- a/docs/development/using_pandas_on_unidist.rst +++ b/docs/development/using_pandas_on_mpi.rst @@ -1,14 +1,14 @@ -pandas on Unidist -================= +pandas on MPI through unidist +============================= -This section describes usage related documents for the pandas on Unidist component of Modin. +This section describes usage related documents for the pandas on MPI through unidist component of Modin. Modin uses pandas as a primary memory format of the underlying partitions and optimizes queries ingested from the API layer in a specific way to this format. Thus, there is no need to care of choosing it but you can explicitly specify it anyway as shown below. -One of the execution engines that Modin uses is Unidist. Currently, Modin only supports Unidist on MPI backend. -To enable the pandas on Unidist execution using MPI backend you should set the following environment variables: +One of the execution engines that Modin uses is MPI through unidist. +To enable the pandas on MPI through unidist execution you should set the following environment variables: .. code-block:: bash @@ -36,4 +36,21 @@ To run a python application you should use ``mpiexec -n 1 python `` c For more information on how to run a python application with unidist on MPI backend please refer to `Unidist on MPI`_ section of the unidist documentation. +As of unidist 0.5.0 there is support for a shared object store for MPI backend. +The feature allows to improve performance in the workloads, +where workers use same data multiple times by reducing data copies. +You can enable the feature by setting the following environment variable: + +.. code-block:: bash + + export UNIDIST_MPI_SHARED_OBJECT_STORE=True + +or turn it on in source code: + +.. code-block:: python + + import unidist.config as unidist_cfg + + unidist_cfg.MpiSharedObjectStore.put(True) + .. _`Unidist on MPI`: https://unidist.readthedocs.io/en/latest/using_unidist/unidist_on_mpi.html \ No newline at end of file diff --git a/docs/flow/modin/core/execution/unidist/implementations/pandas_on_unidist/index.rst b/docs/flow/modin/core/execution/unidist/implementations/pandas_on_unidist/index.rst index 85e6a8177e5..0a80865f376 100644 --- a/docs/flow/modin/core/execution/unidist/implementations/pandas_on_unidist/index.rst +++ b/docs/flow/modin/core/execution/unidist/implementations/pandas_on_unidist/index.rst @@ -6,7 +6,8 @@ PandasOnUnidist Execution Queries that perform data transformation, data ingress or data egress using the `pandas on Unidist` execution pass through the Modin components detailed below. -To enable `pandas on Unidist` execution, please refer to the usage section in :doc:`pandas on Unidist `. +To enable `pandas on MPI through unidist` execution, +please refer to the usage section in :doc:`pandas on MPI through unidist `. Data Transformation ''''''''''''''''''' diff --git a/docs/getting_started/faq.rst b/docs/getting_started/faq.rst index bca4e36f521..8e8257ccd09 100644 --- a/docs/getting_started/faq.rst +++ b/docs/getting_started/faq.rst @@ -119,7 +119,7 @@ and Modin will do computation with that engine: pip install "modin[dask]" # Install Modin dependencies and Dask to run on Dask export MODIN_ENGINE=dask # Modin will use Dask - pip install "modin[unidist]" # Install Modin dependencies and Unidist to run on Unidist. + pip install "modin[mpi]" # Install Modin dependencies and MPI to run on MPI through unidist. export MODIN_ENGINE=unidist # Modin will use Unidist export UNIDIST_BACKEND=mpi # Unidist will use MPI backend. diff --git a/docs/getting_started/installation.rst b/docs/getting_started/installation.rst index 90446735b6a..708df6c6a7b 100644 --- a/docs/getting_started/installation.rst +++ b/docs/getting_started/installation.rst @@ -24,14 +24,21 @@ To install the most recent stable release run the following: pip install -U modin # -U for upgrade in case you have an older version -Modin can be used with :doc:`Ray`, :doc:`Dask`, :doc:`Unidist`, or :doc:`HDK` engines. If you don't have Ray_, Dask_ or Unidist_ installed, you will need to install Modin with one of the targets: +Modin can be used with :doc:`Ray`, :doc:`Dask`, +:doc:`Unidist`, or :doc:`HDK` engines. +If you don't have Ray_, Dask_ or Unidist_ installed, you will need to install Modin with one of the targets: .. code-block:: bash pip install "modin[ray]" # Install Modin dependencies and Ray to run on Ray pip install "modin[dask]" # Install Modin dependencies and Dask to run on Dask - pip install "modin[unidist]" # Install Modin dependencies and Unidist to run on Unidist - pip install "modin[all]" # Install all of the above + pip install "modin[mpi]" # Install Modin dependencies and MPI to run on MPI through unidist + pip install "modin[all]" # Install Ray and Dask + +To get Modin on MPI through unidist (as of unidist 0.5.0) fully working +it is required to have a working MPI implementation installed beforehand. +Otherwise, installation of ``modin[mpi]`` may fail. Refer to +`Installing with pip`_ section of the unidist documentation for more details about installation. Modin will automatically detect which engine you have installed and use that for scheduling computation! See below for HDK engine installation. @@ -65,7 +72,7 @@ storage formats or for different functionalities of Modin. Here is a list of dep .. code-block:: bash - pip install "modin[unidist]" # If you want to use the Unidist execution engine + pip install "modin[mpi]" # If you want to use MPI through unidist execution engine Installing on Google Colab """"""""""""""""""""""""""" @@ -106,18 +113,18 @@ it is possible to install modin with chosen engine(s) alongside. Current options +---------------------------------+---------------------------+-----------------------------+ | modin-ray | Ray_ | Linux, Windows | +---------------------------------+---------------------------+-----------------------------+ -| modin-unidist | Unidist_ | Linux, Windows, MacOS | +| modin-mpi | MPI_ through unidist_ | Linux, Windows, MacOS | +---------------------------------+---------------------------+-----------------------------+ | modin-hdk | HDK_ | Linux | +---------------------------------+---------------------------+-----------------------------+ | modin-all | Dask, Ray, Unidist, HDK | Linux | +---------------------------------+---------------------------+-----------------------------+ -For installing Dask, Ray and Unidist engines into conda environment following command should be used: +For installing Dask, Ray and MPI through unidist engines into conda environment following command should be used: .. code-block:: bash - conda install -c conda-forge modin-ray modin-dask modin-unidist + conda install -c conda-forge modin-ray modin-dask modin-mpi All set of engines could be available in conda environment by specifying: @@ -129,7 +136,10 @@ or explicitly: .. code-block:: bash - conda install -c conda-forge modin-ray modin-dask modin-unidist modin-hdk + conda install -c conda-forge modin-ray modin-dask modin-mpi modin-hdk + +Refer to `Installing with conda`_ section of the unidist documentation +for more details on how to install a specific MPI implementation to run on. ``conda`` may be slow installing ``modin-all`` or combitations of execution engines so we currently recommend using libmamba solver for the installation process. To do this install it in a base environment: @@ -171,7 +181,7 @@ also use ``pip``. This will install directly from the repo without you having to manually clone it! Please be aware that these changes have not made it into a release and may not be completely stable. -If you would like to install Modin with a specific engine, you can use ``modin[ray]`` or ``modin[dask]`` or ``modin[unidist]`` instead of ``modin[all]`` in the command above. +If you would like to install Modin with a specific engine, you can use ``modin[ray]`` or ``modin[dask]`` or ``modin[mpi]`` instead of ``modin[all]`` in the command above. Windows ------- @@ -214,7 +224,10 @@ Once cloned, ``cd`` into the ``modin`` directory and use ``pip`` to install: .. _WSL: https://docs.microsoft.com/en-us/windows/wsl/install-win10 .. _Ray: http://ray.readthedocs.io .. _Dask: https://github.com/dask/dask +.. _MPI: https://www.mpi-forum.org/ .. _Unidist: https://github.com/modin-project/unidist +.. _`Installing with pip`: https://unidist.readthedocs.io/en/latest/installation.html#installing-with-pip +.. _`Installing with conda`: https://unidist.readthedocs.io/en/latest/installation.html#installing-with-conda .. _HDK: https://github.com/intel-ai/hdk .. _`Intel Distribution of Modin`: https://software.intel.com/content/www/us/en/develop/tools/oneapi/components/distribution-of-modin.html#gs.86stqv .. _`Intel Distribution of Modin Getting Started`: https://www.intel.com/content/www/us/en/developer/articles/technical/intel-distribution-of-modin-getting-started-guide.html diff --git a/docs/index.rst b/docs/index.rst index ae73833f6c6..ff5d56e476d 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -61,7 +61,7 @@ of the targets: pip install "modin[ray]" # Install Modin dependencies and Ray to run on Ray pip install "modin[dask]" # Install Modin dependencies and Dask to run on Dask - pip install "modin[unidist]" # Install Modin dependencies and Unidist to run on Unidist + pip install "modin[mpi]" # Install Modin dependencies and MPI to run on MPI through unidist pip install "modin[all]" # Install all of the above Modin will automatically detect which engine you have installed and use that for @@ -77,7 +77,7 @@ variable ``MODIN_ENGINE`` and Modin will do computation with that engine: export MODIN_ENGINE=unidist # Modin will use Unidist If you want to choose the Unidist engine, you should set the additional environment -variable ``UNIDIST_BACKEND``, because currently Modin only supports Unidist on MPI: +variable ``UNIDIST_BACKEND``, because currently Modin only supports MPI through unidist: .. code-block:: bash diff --git a/examples/tutorial/jupyter/README.md b/examples/tutorial/jupyter/README.md index 88cbb307c34..78fbb5116e1 100644 --- a/examples/tutorial/jupyter/README.md +++ b/examples/tutorial/jupyter/README.md @@ -4,7 +4,7 @@ Currently we provide tutorial notebooks for the following execution backends: - [PandasOnRay](https://modin.readthedocs.io/en/latest/development/using_pandas_on_ray.html) - [PandasOnDask](https://modin.readthedocs.io/en/latest/development/using_pandas_on_dask.html) -- [PandasOnUnidist](https://modin.readthedocs.io/en/latest/development/using_pandas_on_unidist.html) +- [PandasOnMPI through unidist](https://modin.readthedocs.io/en/latest/development/using_pandas_on_mpi.html) - [HdkOnNative](https://modin.readthedocs.io/en/latest/development/using_hdk.html) ## Creating a development environment diff --git a/examples/tutorial/jupyter/execution/pandas_on_unidist/Dockerfile b/examples/tutorial/jupyter/execution/pandas_on_unidist/Dockerfile index d2ead494987..f30032248d7 100644 --- a/examples/tutorial/jupyter/execution/pandas_on_unidist/Dockerfile +++ b/examples/tutorial/jupyter/execution/pandas_on_unidist/Dockerfile @@ -1,5 +1,4 @@ FROM continuumio/miniconda3 +RUN conda env create -f jupyter_unidist_env.yml RUN conda install -c conda-forge psutil setproctitle -RUN pip install -r requirements-dev.txt - diff --git a/examples/tutorial/jupyter/execution/pandas_on_unidist/jupyter_unidist_env.yml b/examples/tutorial/jupyter/execution/pandas_on_unidist/jupyter_unidist_env.yml new file mode 100644 index 00000000000..4141c870fbd --- /dev/null +++ b/examples/tutorial/jupyter/execution/pandas_on_unidist/jupyter_unidist_env.yml @@ -0,0 +1,11 @@ +name: jupyter_modin_on_unidist +channels: + - conda-forge +dependencies: + - pip + - fsspec>=2022.05.0 + - jupyterlab + - ipywidgets + - modin-mpi + - pip: + - modin[spreadsheet] diff --git a/examples/tutorial/jupyter/execution/pandas_on_unidist/local/exercise_1.ipynb b/examples/tutorial/jupyter/execution/pandas_on_unidist/local/exercise_1.ipynb index 1700a78b86a..cd8c1606e58 100644 --- a/examples/tutorial/jupyter/execution/pandas_on_unidist/local/exercise_1.ipynb +++ b/examples/tutorial/jupyter/execution/pandas_on_unidist/local/exercise_1.ipynb @@ -64,7 +64,7 @@ "\n", "Modin uses Ray as an execution engine by default so no additional action is required to start to use it. Alternatively, if you need to use another engine, it should be specified either by setting the Modin config or by setting Modin environment variable before the first operation with Modin as it is shown below. Also, note that the full list of Modin configs and corresponding environment variables can be found in the [Modin Configuration Settings](https://modin.readthedocs.io/en/stable/flow/modin/config.html#modin-configs-list) section of the Modin documentation.\n", "\n", - "One of the execution engines that Modin uses is Unidist. Currently, Modin only supports Unidist on MPI backend, so it should be specified either by setting the Unidist config or by setting Unidist environment variable. The full list of Unidist configs and corresponding environment variables can be found in the [Unidist Configuration Settings](https://unidist.readthedocs.io/en/latest/flow/unidist/config.html#unidist-configuration-settings-list) section of the Unidist documentation." + "One of the execution engines that Modin uses is Unidist. Currently, Modin only supports MPI through unidist, so it should be specified either by setting the Unidist config or by setting Unidist environment variable. The full list of Unidist configs and corresponding environment variables can be found in the [Unidist Configuration Settings](https://unidist.readthedocs.io/en/latest/flow/unidist/config.html#unidist-configuration-settings-list) section of the Unidist documentation." ] }, { diff --git a/examples/tutorial/jupyter/execution/pandas_on_unidist/requirements.txt b/examples/tutorial/jupyter/execution/pandas_on_unidist/requirements.txt deleted file mode 100644 index e093df33b8b..00000000000 --- a/examples/tutorial/jupyter/execution/pandas_on_unidist/requirements.txt +++ /dev/null @@ -1,5 +0,0 @@ -fsspec>=2022.05.0 -jupyterlab -ipywidgets -modin[unidist] -modin[spreadsheet] diff --git a/modin/core/execution/unidist/common/utils.py b/modin/core/execution/unidist/common/utils.py index db9648382b5..5159e05a3c3 100644 --- a/modin/core/execution/unidist/common/utils.py +++ b/modin/core/execution/unidist/common/utils.py @@ -29,7 +29,7 @@ def initialize_unidist(): if unidist_cfg.Backend.get() != "mpi": raise RuntimeError( - f"Modin only supports unidist on MPI for now, got unidist backend '{unidist_cfg.Backend.get()}'" + f"Modin only supports MPI through unidist for now, got unidist backend '{unidist_cfg.Backend.get()}'" ) if not unidist.is_initialized(): diff --git a/requirements/env_unidist_linux.yml b/requirements/env_unidist_linux.yml index 85c394656de..7589560c2b9 100644 --- a/requirements/env_unidist_linux.yml +++ b/requirements/env_unidist_linux.yml @@ -7,7 +7,7 @@ dependencies: # required dependencies - pandas>=2.1,<2.2 - numpy>=1.22.4 - - unidist-mpi>=0.2.1,<=0.4.1 + - unidist-mpi>=0.2.1 - mpich - fsspec>=2022.05.0 - packaging>=21.0 diff --git a/requirements/env_unidist_win.yml b/requirements/env_unidist_win.yml index 84faaef5fef..f3b3459dab6 100644 --- a/requirements/env_unidist_win.yml +++ b/requirements/env_unidist_win.yml @@ -7,7 +7,7 @@ dependencies: # required dependencies - pandas>=2.1,<2.2 - numpy>=1.22.4 - - unidist-mpi>=0.2.1,<=0.4.1 + - unidist-mpi>=0.2.1 - msmpi - fsspec>=2022.05.0 - packaging>=21.0 diff --git a/setup.py b/setup.py index f5c9ab72eb6..8e8036d872e 100644 --- a/setup.py +++ b/setup.py @@ -9,9 +9,13 @@ # ray==2.5.0 broken: https://github.com/conda-forge/ray-packages-feedstock/issues/100 # pydantic<2: https://github.com/modin-project/modin/issues/6336 ray_deps = ["ray[default]>=1.13.0,!=2.5.0", "pyarrow>=7.0.0", "pydantic<2"] -unidist_deps = ["unidist[mpi]>=0.2.1,<=0.4.1"] +mpi_deps = ["unidist[mpi]>=0.2.1"] spreadsheet_deps = ["modin-spreadsheet>=0.1.0"] -all_deps = dask_deps + ray_deps + unidist_deps + spreadsheet_deps +# Currently, Modin does not include `mpi` option in `all`. +# Otherwise, installation of modin[all] would fail because +# users need to have a working MPI implementation and +# certain software installed beforehand. +all_deps = dask_deps + ray_deps + spreadsheet_deps # Distribute 'modin-autoimport-pandas.pth' along with binary and source distributions. # This file provides the "import pandas before Ray init" feature if specific @@ -58,7 +62,7 @@ def make_distribution(self): # can be installed by pip install modin[dask] "dask": dask_deps, "ray": ray_deps, - "unidist": unidist_deps, + "mpi": mpi_deps, "spreadsheet": spreadsheet_deps, "all": all_deps, }, From adfd2bb3b1347a618039e28ae3504a7d808b792f Mon Sep 17 00:00:00 2001 From: Anatoly Myachev Date: Mon, 20 Nov 2023 13:31:01 +0100 Subject: [PATCH 087/201] PERF-#4777: Don't use `copy=True` parameter for `concat` calls inside `to_pandas` (#4778) Signed-off-by: Anatoly Myachev --- modin/core/dataframe/pandas/partitioning/partition_manager.py | 2 +- modin/core/dataframe/pandas/utils.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/modin/core/dataframe/pandas/partitioning/partition_manager.py b/modin/core/dataframe/pandas/partitioning/partition_manager.py index 2b780e79b8f..400e9fad177 100644 --- a/modin/core/dataframe/pandas/partitioning/partition_manager.py +++ b/modin/core/dataframe/pandas/partitioning/partition_manager.py @@ -755,7 +755,7 @@ def is_part_empty(part): ) df_rows = [ - pandas.concat([part for part in row], axis=axis) + pandas.concat([part for part in row], axis=axis, copy=False) for row in retrieved_objects if not all(is_part_empty(part) for part in row) ] diff --git a/modin/core/dataframe/pandas/utils.py b/modin/core/dataframe/pandas/utils.py index e3475ea855c..bb8d018e6e2 100644 --- a/modin/core/dataframe/pandas/utils.py +++ b/modin/core/dataframe/pandas/utils.py @@ -43,4 +43,5 @@ def concatenate(dfs): df.isetitem( i, pandas.Categorical(df.iloc[:, i], categories=union.categories) ) - return pandas.concat(dfs) + # `ValueError: buffer source array is read-only` if copy==False + return pandas.concat(dfs, copy=True) From 8ba32a88bd623ae9688b9ea84354864f7f3ae715 Mon Sep 17 00:00:00 2001 From: Anatoly Myachev Date: Mon, 20 Nov 2023 17:50:39 +0100 Subject: [PATCH 088/201] PERF-#6756: Don't materialize index when sorting (#6755) Signed-off-by: Anatoly Myachev --- modin/core/dataframe/pandas/dataframe/dataframe.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modin/core/dataframe/pandas/dataframe/dataframe.py b/modin/core/dataframe/pandas/dataframe/dataframe.py index 7031b269554..22cfb688de4 100644 --- a/modin/core/dataframe/pandas/dataframe/dataframe.py +++ b/modin/core/dataframe/pandas/dataframe/dataframe.py @@ -2587,7 +2587,7 @@ def sort_function(df): # pragma: no cover return df # If this df is empty, we don't want to try and shuffle or sort. - if len(self.get_axis(0)) == 0 or len(self.get_axis(1)) == 0: + if len(self.get_axis(1)) == 0 or len(self) == 0: return self.copy() axis = Axis(axis) From 0ba2a46218ecbc6f4b7d0d9e54c25e437e5e0b23 Mon Sep 17 00:00:00 2001 From: Dmitry Chigarev Date: Mon, 20 Nov 2023 21:15:51 +0100 Subject: [PATCH 089/201] PERF-#6749: Preserve partial dtype for the result of 'reset_index()' (#6751) Signed-off-by: Dmitry Chigarev --- .../dataframe/pandas/dataframe/dataframe.py | 12 +- .../core/dataframe/pandas/metadata/dtypes.py | 27 +++-- .../storage_formats/pandas/query_compiler.py | 21 +++- .../storage_formats/pandas/test_internals.py | 109 +++++++++++++++++- 4 files changed, 148 insertions(+), 21 deletions(-) diff --git a/modin/core/dataframe/pandas/dataframe/dataframe.py b/modin/core/dataframe/pandas/dataframe/dataframe.py index 22cfb688de4..b9a57271516 100644 --- a/modin/core/dataframe/pandas/dataframe/dataframe.py +++ b/modin/core/dataframe/pandas/dataframe/dataframe.py @@ -1323,11 +1323,13 @@ def from_labels(self) -> "PandasDataframe": if "index" not in self.columns else "level_{}".format(0) ] - new_dtypes = None - if self.has_materialized_dtypes: - names = tuple(level_names) if len(level_names) > 1 else level_names[0] - new_dtypes = self.index.to_frame(name=names).dtypes - new_dtypes = pandas.concat([new_dtypes, self.dtypes]) + names = tuple(level_names) if len(level_names) > 1 else level_names[0] + new_dtypes = self.index.to_frame(name=names).dtypes + try: + new_dtypes = ModinDtypes.concat([new_dtypes, self._dtypes]) + except NotImplementedError: + # can raise on duplicated labels + new_dtypes = None # We will also use the `new_column_names` in the calculation of the internal metadata, so this is a # lightweight way of ensuring the metadata matches. diff --git a/modin/core/dataframe/pandas/metadata/dtypes.py b/modin/core/dataframe/pandas/metadata/dtypes.py index fc6cc211fac..ffd81eb74b2 100644 --- a/modin/core/dataframe/pandas/metadata/dtypes.py +++ b/modin/core/dataframe/pandas/metadata/dtypes.py @@ -260,13 +260,15 @@ def copy(self) -> "DtypesDescriptor": DtypesDescriptor """ return type(self)( - self._known_dtypes.copy(), - self._cols_with_unknown_dtypes.copy(), - self._remaining_dtype, - self._parent_df, - columns_order=None - if self.columns_order is None - else self.columns_order.copy(), + # should access '.columns_order' first, as it may compute columns order + # and complete the metadata for 'self' + columns_order=( + None if self.columns_order is None else self.columns_order.copy() + ), + known_dtypes=self._known_dtypes.copy(), + cols_with_unknown_dtypes=self._cols_with_unknown_dtypes.copy(), + remaining_dtype=self._remaining_dtype, + parent_df=self._parent_df, know_all_names=self._know_all_names, _schema_is_known=self._schema_is_known, ) @@ -646,7 +648,16 @@ def concat(cls, values: list) -> "ModinDtypes": else: raise NotImplementedError(type(val)) - desc = DtypesDescriptor.concat(preprocessed_vals) + try: + desc = DtypesDescriptor.concat(preprocessed_vals) + except NotImplementedError as e: + # 'DtypesDescriptor' doesn't support duplicated labels, however, if all values are pandas Serieses, + # we still can perform concatenation using pure pandas + if "duplicated" not in e.args[0].lower() or not all( + isinstance(val, pandas.Series) for val in values + ): + raise e + desc = pandas.concat(values) return ModinDtypes(desc) def set_index(self, new_index: Union[pandas.Index, "ModinIndex"]) -> "ModinDtypes": diff --git a/modin/core/storage_formats/pandas/query_compiler.py b/modin/core/storage_formats/pandas/query_compiler.py index ba907a8b036..0565a7d19f4 100644 --- a/modin/core/storage_formats/pandas/query_compiler.py +++ b/modin/core/storage_formats/pandas/query_compiler.py @@ -58,6 +58,7 @@ SeriesGroupByDefault, ) from modin.core.dataframe.base.dataframe.utils import join_columns +from modin.core.dataframe.pandas.metadata import ModinDtypes from modin.core.storage_formats.base.query_compiler import BaseQueryCompiler from modin.error_message import ErrorMessage from modin.utils import ( @@ -719,10 +720,20 @@ def _reset(df, *axis_lengths, partition_idx): # pragma: no cover df.index = pandas.RangeIndex(start, stop) return df - if self._modin_frame.has_columns_cache and kwargs["drop"]: - new_columns = self._modin_frame.copy_columns_cache(copy_lengths=True) + new_columns = None + if kwargs["drop"]: + dtypes = self._modin_frame.copy_dtypes_cache() + if self._modin_frame.has_columns_cache: + new_columns = self._modin_frame.copy_columns_cache( + copy_lengths=True + ) else: - new_columns = None + # concat index dtypes (None, since they're unknown) with column dtypes + try: + dtypes = ModinDtypes.concat([None, self._modin_frame._dtypes]) + except NotImplementedError: + # may raise on duplicated names in materialized 'self.dtypes' + dtypes = None return self.__constructor__( self._modin_frame.apply_full_axis( @@ -730,9 +741,7 @@ def _reset(df, *axis_lengths, partition_idx): # pragma: no cover func=_reset, enumerate_partitions=True, new_columns=new_columns, - dtypes=( - self._modin_frame._dtypes if kwargs.get("drop", False) else None - ), + dtypes=dtypes, sync_labels=False, pass_axis_lengths_to_partitions=True, ) diff --git a/modin/test/storage_formats/pandas/test_internals.py b/modin/test/storage_formats/pandas/test_internals.py index b459f050263..5279614b510 100644 --- a/modin/test/storage_formats/pandas/test_internals.py +++ b/modin/test/storage_formats/pandas/test_internals.py @@ -21,6 +21,7 @@ import modin.pandas as pd from modin.config import Engine, ExperimentalGroupbyImpl, MinPartitionSize, NPartitions +from modin.core.dataframe.pandas.dataframe.dataframe import PandasDataframe from modin.core.dataframe.pandas.dataframe.utils import ColumnInfo, ShuffleSortFunctions from modin.core.dataframe.pandas.metadata import ( DtypesDescriptor, @@ -1814,6 +1815,24 @@ def test_concat(self): ) assert res.equals(exp) + def test_ModinDtypes_duplicated_concat(self): + # test that 'ModinDtypes' is able to perform dtypes concatenation on duplicated labels + # if all of them are Serieses + res = ModinDtypes.concat([pandas.Series([np.dtype(int)], index=["a"])] * 2) + assert isinstance(res._value, pandas.Series) + assert res._value.equals( + pandas.Series([np.dtype(int), np.dtype(int)], index=["a", "a"]) + ) + + # test that 'ModinDtypes.concat' with duplicated labels raises when not all dtypes are materialized + with pytest.raises(NotImplementedError): + res = ModinDtypes.concat( + [ + pandas.Series([np.dtype(int)], index=["a"]), + DtypesDescriptor(cols_with_unknown_dtypes=["a"]), + ] + ) + def test_update_parent(self): """ Test that updating parents in ``DtypesDescriptor`` also propagates to stored lazy categoricals. @@ -1947,8 +1966,6 @@ class TestZeroComputationDtypes: """ def test_get_dummies_case(self): - from modin.core.dataframe.pandas.dataframe.dataframe import PandasDataframe - with mock.patch.object(PandasDataframe, "_compute_dtypes") as patch: df = pd.DataFrame( {"items": [1, 2, 3, 4], "b": [3, 3, 4, 4], "c": [1, 0, 0, 1]} @@ -1960,3 +1977,91 @@ def test_get_dummies_case(self): assert res._query_compiler._modin_frame.has_materialized_dtypes patch.assert_not_called() + + @pytest.mark.parametrize("has_materialized_index", [True, False]) + @pytest.mark.parametrize("drop", [True, False]) + def test_preserve_dtypes_reset_index(self, drop, has_materialized_index): + with mock.patch.object(PandasDataframe, "_compute_dtypes") as patch: + # case 1: 'df' has complete dtype by default + df = pd.DataFrame({"a": [1, 2, 3]}) + if has_materialized_index: + assert df._query_compiler._modin_frame.has_materialized_index + else: + df._query_compiler._modin_frame.set_index_cache(None) + assert not df._query_compiler._modin_frame.has_materialized_index + assert df._query_compiler._modin_frame.has_materialized_dtypes + + res = df.reset_index(drop=drop) + if drop: + # we droped the index, so columns and dtypes shouldn't change + assert res._query_compiler._modin_frame.has_materialized_dtypes + assert res.dtypes.equals(df.dtypes) + else: + if has_materialized_index: + # we should have inserted index dtype into the descriptor, + # and since both of them are materialized, the result should be + # materialized too + assert res._query_compiler._modin_frame.has_materialized_dtypes + assert res.dtypes.equals( + pandas.Series( + [np.dtype(int), np.dtype(int)], index=["index", "a"] + ) + ) + else: + # we now know that there are cols with unknown name and dtype in our dataframe, + # so the resulting dtypes should contain information only about original column + expected_dtypes = DtypesDescriptor( + {"a": np.dtype(int)}, + know_all_names=False, + ) + assert res._query_compiler._modin_frame._dtypes._value.equals( + expected_dtypes + ) + + # case 2: 'df' has partial dtype by default + df = pd.DataFrame({"a": [1, 2, 3], "b": [3, 4, 5]}) + df._query_compiler._modin_frame.set_dtypes_cache( + ModinDtypes( + DtypesDescriptor( + {"a": np.dtype(int)}, cols_with_unknown_dtypes=["b"] + ) + ) + ) + if has_materialized_index: + assert df._query_compiler._modin_frame.has_materialized_index + else: + df._query_compiler._modin_frame.set_index_cache(None) + assert not df._query_compiler._modin_frame.has_materialized_index + + res = df.reset_index(drop=drop) + if drop: + # we droped the index, so columns and dtypes shouldn't change + assert res._query_compiler._modin_frame._dtypes._value.equals( + df._query_compiler._modin_frame._dtypes._value + ) + else: + if has_materialized_index: + # we should have inserted index dtype into the descriptor, + # the resulted dtype should have information about 'index' and 'a' columns, + # and miss dtype info for 'b' column + expected_dtypes = DtypesDescriptor( + {"index": np.dtype(int), "a": np.dtype(int)}, + cols_with_unknown_dtypes=["b"], + columns_order={0: "index", 1: "a", 2: "b"}, + ) + assert res._query_compiler._modin_frame._dtypes._value.equals( + expected_dtypes + ) + else: + # we miss info about the 'index' column since it wasn't materialized at + # the time of 'reset_index()' and we're still missing dtype info for 'b' column + expected_dtypes = DtypesDescriptor( + {"a": np.dtype(int)}, + cols_with_unknown_dtypes=["b"], + know_all_names=False, + ) + assert res._query_compiler._modin_frame._dtypes._value.equals( + expected_dtypes + ) + + patch.assert_not_called() From 257de201102a63e8dae30c11dd69752067bc5c3e Mon Sep 17 00:00:00 2001 From: Dmitry Chigarev Date: Tue, 21 Nov 2023 00:09:15 +0100 Subject: [PATCH 090/201] FIX-#6752: Preserve dtypes cache on '.insert()' (#6757) Signed-off-by: Dmitry Chigarev --- .../dataframe/pandas/dataframe/dataframe.py | 9 ++- .../storage_formats/pandas/query_compiler.py | 22 +++++++- .../storage_formats/pandas/test_internals.py | 55 +++++++++++++++++++ 3 files changed, 83 insertions(+), 3 deletions(-) diff --git a/modin/core/dataframe/pandas/dataframe/dataframe.py b/modin/core/dataframe/pandas/dataframe/dataframe.py index b9a57271516..88c97843dee 100644 --- a/modin/core/dataframe/pandas/dataframe/dataframe.py +++ b/modin/core/dataframe/pandas/dataframe/dataframe.py @@ -2804,6 +2804,7 @@ def apply_full_axis_select_indices( new_index=None, new_columns=None, keep_remaining=False, + new_dtypes=None, ): """ Apply a function across an entire axis for a subset of the data. @@ -2826,6 +2827,10 @@ def apply_full_axis_select_indices( advance, and if not provided it must be computed. keep_remaining : boolean, default: False Whether or not to drop the data that is not computed over. + new_dtypes : ModinDtypes or pandas.Series, optional + The data types of the result. This is an optimization + because there are functions that always result in a particular data + type, and allows us to avoid (re)computing it. Returns ------- @@ -2854,7 +2859,9 @@ def apply_full_axis_select_indices( new_index = self.index if axis == 1 else None if new_columns is None: new_columns = self.columns if axis == 0 else None - return self.__constructor__(new_partitions, new_index, new_columns, None, None) + return self.__constructor__( + new_partitions, new_index, new_columns, None, None, dtypes=new_dtypes + ) @lazy_metadata_decorator(apply_axis="both") def apply_select_indices( diff --git a/modin/core/storage_formats/pandas/query_compiler.py b/modin/core/storage_formats/pandas/query_compiler.py index 0565a7d19f4..a6b9fd1a826 100644 --- a/modin/core/storage_formats/pandas/query_compiler.py +++ b/modin/core/storage_formats/pandas/query_compiler.py @@ -58,7 +58,7 @@ SeriesGroupByDefault, ) from modin.core.dataframe.base.dataframe.utils import join_columns -from modin.core.dataframe.pandas.metadata import ModinDtypes +from modin.core.dataframe.pandas.metadata import DtypesDescriptor, ModinDtypes from modin.core.storage_formats.base.query_compiler import BaseQueryCompiler from modin.error_message import ErrorMessage from modin.utils import ( @@ -3112,6 +3112,23 @@ def insert(df, internal_indices=[]): # pragma: no cover df.insert(internal_idx, column, value) return df + if hasattr(value, "dtype"): + value_dtype = value.dtype + elif is_scalar(value): + value_dtype = np.dtype(type(value)) + else: + value_dtype = np.array(value).dtype + + new_columns = self.columns.insert(loc, column) + new_dtypes = ModinDtypes.concat( + [ + self._modin_frame._dtypes, + DtypesDescriptor({column: value_dtype}, cols_with_unknown_dtypes=[]), + ] + ).lazy_get( + new_columns + ) # get dtypes in a proper order + # TODO: rework by passing list-like values to `apply_select_indices` # as an item to distribute new_modin_frame = self._modin_frame.apply_full_axis_select_indices( @@ -3120,7 +3137,8 @@ def insert(df, internal_indices=[]): # pragma: no cover numeric_indices=[loc], keep_remaining=True, new_index=self.index, - new_columns=self.columns.insert(loc, column), + new_columns=new_columns, + new_dtypes=new_dtypes, ) return self.__constructor__(new_modin_frame) diff --git a/modin/test/storage_formats/pandas/test_internals.py b/modin/test/storage_formats/pandas/test_internals.py index 5279614b510..243a496c190 100644 --- a/modin/test/storage_formats/pandas/test_internals.py +++ b/modin/test/storage_formats/pandas/test_internals.py @@ -1965,6 +1965,61 @@ class TestZeroComputationDtypes: Test cases that shouldn't trigger dtypes computation during their execution. """ + @pytest.mark.parametrize("self_dtype", ["materialized", "partial", "unknown"]) + @pytest.mark.parametrize( + "value, value_dtype", + [ + [3.5, np.dtype(float)], + [[3.5, 2.4], np.dtype(float)], + [np.array([3.5, 2.4]), np.dtype(float)], + [pd.Series([3.5, 2.4]), np.dtype(float)], + ], + ) + def test_preserve_dtypes_insert(self, self_dtype, value, value_dtype): + with mock.patch.object(PandasDataframe, "_compute_dtypes") as patch: + df = pd.DataFrame({"a": [1, 2], "b": [3, 4]}) + if self_dtype == "materialized": + assert df._query_compiler._modin_frame.has_materialized_dtypes + elif self_dtype == "partial": + df._query_compiler._modin_frame.set_dtypes_cache( + ModinDtypes( + DtypesDescriptor( + {"a": np.dtype(int)}, cols_with_unknown_dtypes=["b"] + ) + ) + ) + elif self_dtype == "unknown": + df._query_compiler._modin_frame.set_dtypes_cache(None) + else: + raise NotImplementedError(self_dtype) + + df.insert(loc=0, column="c", value=value) + + if self_dtype == "materialized": + result_dtype = pandas.Series( + [value_dtype, np.dtype(int), np.dtype(int)], index=["c", "a", "b"] + ) + assert df._query_compiler._modin_frame.has_materialized_dtypes + assert df.dtypes.equals(result_dtype) + elif self_dtype == "partial": + result_dtype = DtypesDescriptor( + {"a": np.dtype(int), "c": value_dtype}, + cols_with_unknown_dtypes=["b"], + columns_order={0: "c", 1: "a", 2: "b"}, + ) + df._query_compiler._modin_frame._dtypes._value.equals(result_dtype) + elif self_dtype == "unknown": + result_dtype = DtypesDescriptor( + {"c": value_dtype}, + cols_with_unknown_dtypes=["a", "b"], + columns_order={0: "c", 1: "a", 2: "b"}, + ) + df._query_compiler._modin_frame._dtypes._value.equals(result_dtype) + else: + raise NotImplementedError(self_dtype) + + patch.assert_not_called() + def test_get_dummies_case(self): with mock.patch.object(PandasDataframe, "_compute_dtypes") as patch: df = pd.DataFrame( From 93275d9d3bb74158e80489fc9beb2761dd9b2a85 Mon Sep 17 00:00:00 2001 From: Dmitry Chigarev Date: Tue, 21 Nov 2023 10:38:57 +0100 Subject: [PATCH 091/201] PERF-#6747: Preserve columns/dtypes cache when merging on a single index level (#6748) Signed-off-by: Dmitry Chigarev Co-authored-by: Anatoly Myachev --- modin/core/dataframe/base/dataframe/utils.py | 13 +++++++ .../storage_formats/pandas/query_compiler.py | 37 +++++++++++++++---- modin/pandas/test/dataframe/test_join_sort.py | 30 +++++++++++++++ 3 files changed, 73 insertions(+), 7 deletions(-) diff --git a/modin/core/dataframe/base/dataframe/utils.py b/modin/core/dataframe/base/dataframe/utils.py index 7a1d5d98dd7..75c1c999689 100644 --- a/modin/core/dataframe/base/dataframe/utils.py +++ b/modin/core/dataframe/base/dataframe/utils.py @@ -96,6 +96,19 @@ def join_columns( Sequence[IndexLabel], [right_on] if is_scalar(right_on) else right_on ) + # handling a simple case of merging on one column and when the column is located in an index + if len(left_on) == 1 and len(right_on) == 1 and left_on[0] == right_on[0]: + if left_on[0] not in left and right_on[0] not in right: + # in this case the 'on' column will stay in the index, so we can simply + # drop the 'left/right_on' values and proceed as normal + left_on = [] + right_on = [] + # in other cases, we can simply add the index name to columns and proceed as normal + elif left_on[0] not in left: + left = left.insert(loc=0, item=left_on[0]) # type: ignore + elif right_on[0] not in right: + right = right.insert(loc=0, item=right_on[0]) # type: ignore + if any(col not in left for col in left_on) or any( col not in right for col in right_on ): diff --git a/modin/core/storage_formats/pandas/query_compiler.py b/modin/core/storage_formats/pandas/query_compiler.py index a6b9fd1a826..a34c8d4bbc4 100644 --- a/modin/core/storage_formats/pandas/query_compiler.py +++ b/modin/core/storage_formats/pandas/query_compiler.py @@ -573,13 +573,36 @@ def map_func( # is really complicated in this case, so we're not computing resulted columns for now. pass else: - if self._modin_frame.has_materialized_dtypes: - new_dtypes = [] - for old_col in left_renamer.keys(): - new_dtypes.append(self.dtypes[old_col]) - for old_col in right_renamer.keys(): - new_dtypes.append(right_pandas.dtypes[old_col]) - new_dtypes = pandas.Series(new_dtypes, index=new_columns) + # renamers may contain columns from 'index', so trying to merge index and column dtypes here + right_index_dtypes = ( + right_pandas.index.dtypes + if isinstance(right_pandas.index, pandas.MultiIndex) + else pandas.Series( + [right_pandas.index.dtype], index=[right_pandas.index.name] + ) + ) + right_dtypes = pandas.concat( + [right_pandas.dtypes, right_index_dtypes] + )[right_renamer.keys()].rename(right_renamer) + + left_index_dtypes = None + if self._modin_frame.has_materialized_index: + left_index_dtypes = ( + self.index.dtypes + if isinstance(self.index, pandas.MultiIndex) + else pandas.Series( + [self.index.dtype], index=[self.index.name] + ) + ) + + left_dtypes = ( + ModinDtypes.concat( + [self._modin_frame._dtypes, left_index_dtypes] + ) + .lazy_get(left_renamer.keys()) + .set_index(list(left_renamer.values())) + ) + new_dtypes = ModinDtypes.concat([left_dtypes, right_dtypes]) new_self = self.__constructor__( self._modin_frame.apply_full_axis( diff --git a/modin/pandas/test/dataframe/test_join_sort.py b/modin/pandas/test/dataframe/test_join_sort.py index 19f0f3d6b03..0d4ab89f22a 100644 --- a/modin/pandas/test/dataframe/test_join_sort.py +++ b/modin/pandas/test/dataframe/test_join_sort.py @@ -471,6 +471,36 @@ def setup_cache(): ) +@pytest.mark.parametrize( + "left_index", [[], ["key"], ["key", "b"], ["key", "b", "c"], ["b"], ["b", "c"]] +) +@pytest.mark.parametrize( + "right_index", [[], ["key"], ["key", "e"], ["key", "e", "f"], ["e"], ["e", "f"]] +) +def test_merge_on_single_index(left_index, right_index): + """ + Test ``.merge()`` method when merging on a single column, that is located in an index level of one of the frames. + """ + modin_df1, pandas_df1 = create_test_dfs( + {"b": [3, 4, 4, 5], "key": [1, 1, 2, 2], "c": [2, 3, 2, 2], "d": [2, 1, 3, 1]} + ) + if len(left_index): + modin_df1 = modin_df1.set_index(left_index) + pandas_df1 = pandas_df1.set_index(left_index) + + modin_df2, pandas_df2 = create_test_dfs( + {"e": [3, 4, 4, 5], "f": [2, 3, 2, 2], "key": [1, 1, 2, 2], "h": [2, 1, 3, 1]} + ) + if len(right_index): + modin_df2 = modin_df2.set_index(right_index) + pandas_df2 = pandas_df2.set_index(right_index) + eval_general( + (modin_df1, modin_df2), + (pandas_df1, pandas_df2), + lambda dfs: dfs[0].merge(dfs[1], on="key"), + ) + + @pytest.mark.parametrize("axis", [0, 1]) @pytest.mark.parametrize( "ascending", bool_arg_values, ids=arg_keys("ascending", bool_arg_keys) From ed2e67be614b105de3163e79f56932aba8ac1f92 Mon Sep 17 00:00:00 2001 From: Dmitry Chigarev Date: Tue, 21 Nov 2023 12:57:06 +0100 Subject: [PATCH 092/201] PERF-#6753: Preserve dtypes cache on '.__setitem__()' (#6758) Signed-off-by: Dmitry Chigarev --- .../dataframe/pandas/dataframe/dataframe.py | 2 +- .../dataframe/pandas/metadata/__init__.py | 15 ++++- .../core/dataframe/pandas/metadata/dtypes.py | 37 +++++++++++- .../storage_formats/pandas/query_compiler.py | 32 +++++++--- .../storage_formats/pandas/test_internals.py | 58 +++++++++++++++++++ 5 files changed, 131 insertions(+), 13 deletions(-) diff --git a/modin/core/dataframe/pandas/dataframe/dataframe.py b/modin/core/dataframe/pandas/dataframe/dataframe.py index 88c97843dee..e32c1693981 100644 --- a/modin/core/dataframe/pandas/dataframe/dataframe.py +++ b/modin/core/dataframe/pandas/dataframe/dataframe.py @@ -2915,7 +2915,7 @@ def apply_select_indices( new_index = self.index if axis == 1 else None if new_columns is None: new_columns = self.columns if axis == 0 else None - if new_columns is not None and new_dtypes is not None: + if new_columns is not None and isinstance(new_dtypes, pandas.Series): assert new_dtypes.index.equals( new_columns ), f"{new_dtypes=} doesn't have the same columns as in {new_columns=}" diff --git a/modin/core/dataframe/pandas/metadata/__init__.py b/modin/core/dataframe/pandas/metadata/__init__.py index 4caf23833a6..2ace6d8e41a 100644 --- a/modin/core/dataframe/pandas/metadata/__init__.py +++ b/modin/core/dataframe/pandas/metadata/__init__.py @@ -13,7 +13,18 @@ """Utilities and classes to handle work with metadata.""" -from .dtypes import DtypesDescriptor, LazyProxyCategoricalDtype, ModinDtypes +from .dtypes import ( + DtypesDescriptor, + LazyProxyCategoricalDtype, + ModinDtypes, + extract_dtype, +) from .index import ModinIndex -__all__ = ["ModinDtypes", "ModinIndex", "LazyProxyCategoricalDtype", "DtypesDescriptor"] +__all__ = [ + "ModinDtypes", + "ModinIndex", + "LazyProxyCategoricalDtype", + "DtypesDescriptor", + "extract_dtype", +] diff --git a/modin/core/dataframe/pandas/metadata/dtypes.py b/modin/core/dataframe/pandas/metadata/dtypes.py index ffd81eb74b2..680825fa0cc 100644 --- a/modin/core/dataframe/pandas/metadata/dtypes.py +++ b/modin/core/dataframe/pandas/metadata/dtypes.py @@ -529,14 +529,23 @@ class ModinDtypes: Parameters ---------- - value : pandas.Series or callable + value : pandas.Series, callable, DtypesDescriptor or ModinDtypes, optional """ - def __init__(self, value: Union[Callable, pandas.Series, DtypesDescriptor]): + def __init__( + self, + value: Optional[ + Union[Callable, pandas.Series, DtypesDescriptor, "ModinDtypes"] + ], + ): if callable(value) or isinstance(value, pandas.Series): self._value = value elif isinstance(value, DtypesDescriptor): self._value = value.to_series() if value.is_materialized else value + elif isinstance(value, type(self)): + self._value = value.copy()._value + elif isinstance(value, None): + self._value = DtypesDescriptor() else: raise ValueError(f"ModinDtypes doesn't work with '{value}'") @@ -985,3 +994,27 @@ def get_categories_dtype( if isinstance(cdt, LazyProxyCategoricalDtype) else cdt.categories.dtype ) + + +def extract_dtype(value): + """ + Extract dtype(s) from the passed `value`. + + Parameters + ---------- + value : object + + Returns + ------- + numpy.dtype or pandas.Series of numpy.dtypes + """ + from modin.pandas.utils import is_scalar + + if hasattr(value, "dtype"): + return value.dtype + elif hasattr(value, "dtypes"): + return value.dtypes + elif is_scalar(value): + return np.dtype(type(value)) + else: + return np.array(value).dtype diff --git a/modin/core/storage_formats/pandas/query_compiler.py b/modin/core/storage_formats/pandas/query_compiler.py index a34c8d4bbc4..14020e031c6 100644 --- a/modin/core/storage_formats/pandas/query_compiler.py +++ b/modin/core/storage_formats/pandas/query_compiler.py @@ -58,7 +58,11 @@ SeriesGroupByDefault, ) from modin.core.dataframe.base.dataframe.utils import join_columns -from modin.core.dataframe.pandas.metadata import DtypesDescriptor, ModinDtypes +from modin.core.dataframe.pandas.metadata import ( + DtypesDescriptor, + ModinDtypes, + extract_dtype, +) from modin.core.storage_formats.base.query_compiler import BaseQueryCompiler from modin.error_message import ErrorMessage from modin.utils import ( @@ -2943,6 +2947,22 @@ def setitem_builder(df, internal_indices=[]): # pragma: no cover idx = self.get_axis(axis ^ 1).get_indexer_for([key])[0] return self.insert_item(axis ^ 1, idx, value, how, replace=True) + if axis == 0: + value_dtype = extract_dtype(value) + + old_columns = self.columns.difference(pandas.Index([key])) + old_dtypes = ModinDtypes(self._modin_frame._dtypes).lazy_get(old_columns) + new_dtypes = ModinDtypes.concat( + [ + old_dtypes, + DtypesDescriptor({key: value_dtype}, cols_with_unknown_dtypes=[]), + ] + # get dtypes in a proper order + ).lazy_get(self.columns) + else: + # TODO: apply 'find_common_dtype' to the value's dtype and old column dtypes + new_dtypes = None + # TODO: rework by passing list-like values to `apply_select_indices` # as an item to distribute if is_list_like(value): @@ -2953,6 +2973,7 @@ def setitem_builder(df, internal_indices=[]): # pragma: no cover new_index=self.index, new_columns=self.columns, keep_remaining=True, + new_dtypes=new_dtypes, ) else: new_modin_frame = self._modin_frame.apply_select_indices( @@ -2961,6 +2982,7 @@ def setitem_builder(df, internal_indices=[]): # pragma: no cover [key], new_index=self.index, new_columns=self.columns, + new_dtypes=new_dtypes, keep_remaining=True, ) return self.__constructor__(new_modin_frame) @@ -3135,13 +3157,7 @@ def insert(df, internal_indices=[]): # pragma: no cover df.insert(internal_idx, column, value) return df - if hasattr(value, "dtype"): - value_dtype = value.dtype - elif is_scalar(value): - value_dtype = np.dtype(type(value)) - else: - value_dtype = np.array(value).dtype - + value_dtype = extract_dtype(value) new_columns = self.columns.insert(loc, column) new_dtypes = ModinDtypes.concat( [ diff --git a/modin/test/storage_formats/pandas/test_internals.py b/modin/test/storage_formats/pandas/test_internals.py index 243a496c190..f315aa0bd52 100644 --- a/modin/test/storage_formats/pandas/test_internals.py +++ b/modin/test/storage_formats/pandas/test_internals.py @@ -1965,6 +1965,64 @@ class TestZeroComputationDtypes: Test cases that shouldn't trigger dtypes computation during their execution. """ + @pytest.mark.parametrize("self_dtype", ["materialized", "partial", "unknown"]) + @pytest.mark.parametrize( + "value, value_dtype", + [ + [3.5, np.dtype(float)], + [[3.5, 2.4], np.dtype(float)], + [np.array([3.5, 2.4]), np.dtype(float)], + [pd.Series([3.5, 2.4]), np.dtype(float)], + ], + ) + def test_preserve_dtypes_setitem(self, self_dtype, value, value_dtype): + """ + Test that ``df[single_existing_column] = value`` preserves dtypes cache. + """ + with mock.patch.object(PandasDataframe, "_compute_dtypes") as patch: + df = pd.DataFrame({"a": [1, 2], "b": [3, 4], "c": [3, 4]}) + if self_dtype == "materialized": + assert df._query_compiler._modin_frame.has_materialized_dtypes + elif self_dtype == "partial": + df._query_compiler._modin_frame.set_dtypes_cache( + ModinDtypes( + DtypesDescriptor( + {"a": np.dtype(int)}, cols_with_unknown_dtypes=["b", "c"] + ) + ) + ) + elif self_dtype == "unknown": + df._query_compiler._modin_frame.set_dtypes_cache(None) + else: + raise NotImplementedError(self_dtype) + + df["b"] = value + + if self_dtype == "materialized": + result_dtype = pandas.Series( + [np.dtype(int), value_dtype, np.dtype(int)], index=["a", "b", "c"] + ) + assert df._query_compiler._modin_frame.has_materialized_dtypes + assert df.dtypes.equals(result_dtype) + elif self_dtype == "partial": + result_dtype = DtypesDescriptor( + {"a": np.dtype(int), "b": value_dtype}, + cols_with_unknown_dtypes=["c"], + columns_order={0: "a", 1: "b", 2: "c"}, + ) + df._query_compiler._modin_frame._dtypes._value.equals(result_dtype) + elif self_dtype == "unknown": + result_dtype = DtypesDescriptor( + {"b": value_dtype}, + cols_with_unknown_dtypes=["a", "b"], + columns_order={0: "a", 1: "b", 2: "c"}, + ) + df._query_compiler._modin_frame._dtypes._value.equals(result_dtype) + else: + raise NotImplementedError(self_dtype) + + patch.assert_not_called() + @pytest.mark.parametrize("self_dtype", ["materialized", "partial", "unknown"]) @pytest.mark.parametrize( "value, value_dtype", From 794ac6fa9a41cf378b8e92ea438b222fef61b3b3 Mon Sep 17 00:00:00 2001 From: Dmitry Chigarev Date: Tue, 21 Nov 2023 14:18:30 +0100 Subject: [PATCH 093/201] PERF-#6754: Merge partial dtype caches on '.concat(axis=0)' (#6759) Signed-off-by: Dmitry Chigarev --- .../dataframe/pandas/dataframe/dataframe.py | 11 +- .../core/dataframe/pandas/metadata/dtypes.py | 128 +++++++++++++++- .../storage_formats/pandas/test_internals.py | 143 +++++++++++++++++- 3 files changed, 263 insertions(+), 19 deletions(-) diff --git a/modin/core/dataframe/pandas/dataframe/dataframe.py b/modin/core/dataframe/pandas/dataframe/dataframe.py index e32c1693981..81092b7eb57 100644 --- a/modin/core/dataframe/pandas/dataframe/dataframe.py +++ b/modin/core/dataframe/pandas/dataframe/dataframe.py @@ -3695,16 +3695,7 @@ def _compute_new_widths(): new_index = self.index.append([other.index for other in others]) new_columns = joined_index frames = [self] + others - if all(frame.has_materialized_dtypes for frame in frames): - all_dtypes = [frame.dtypes for frame in frames] - if not all(dtypes.empty for dtypes in all_dtypes): - new_dtypes = pandas.concat(all_dtypes, axis=1) - # 'nan' value will be placed in a row if a column doesn't exist in all frames; - # this value is np.float64 type so we need an explicit conversion - new_dtypes.fillna(np.dtype("float64"), inplace=True) - new_dtypes = new_dtypes.apply( - lambda row: find_common_type(row.values), axis=1 - ) + new_dtypes = ModinDtypes.concat([frame._dtypes for frame in frames], axis=1) # If we have already cached the length of each row in at least one # of the row's partitions, we can build new_lengths for the new # frame. Typically, if we know the length for any partition in a diff --git a/modin/core/dataframe/pandas/metadata/dtypes.py b/modin/core/dataframe/pandas/metadata/dtypes.py index 680825fa0cc..35674441af0 100644 --- a/modin/core/dataframe/pandas/metadata/dtypes.py +++ b/modin/core/dataframe/pandas/metadata/dtypes.py @@ -18,6 +18,7 @@ import numpy as np import pandas from pandas._typing import IndexLabel +from pandas.core.dtypes.cast import find_common_type if TYPE_CHECKING: from modin.core.dataframe.pandas.dataframe.dataframe import PandasDataframe @@ -446,20 +447,120 @@ def get_dtypes_set(self) -> set[np.dtype]: return known_dtypes @classmethod - def concat( + def _merge_dtypes( cls, values: list[Union["DtypesDescriptor", pandas.Series, None]] - ) -> "DtypesDescriptor": # noqa: GL08 + ) -> "DtypesDescriptor": + """ + Union columns described by ``values`` and compute common dtypes for them. + + Parameters + ---------- + values : list of DtypesDescriptors, pandas.Series or Nones + + Returns + ------- + DtypesDescriptor + """ + known_dtypes = {} + cols_with_unknown_dtypes = [] + know_all_names = True + dtypes_are_unknown = False + + # index - joined column names, columns - dtypes taken from 'values' + # 0 1 2 3 + # col1 int bool float int + # col2 int int int int + # colN bool bool bool int + dtypes_matrix = pandas.DataFrame() + + for i, val in enumerate(values): + if isinstance(val, cls): + know_all_names &= val._know_all_names + dtypes = val._known_dtypes.copy() + dtypes.update({col: "unknown" for col in val._cols_with_unknown_dtypes}) + if val._remaining_dtype is not None: + # we can't process remaining dtypes, so just discarding them + know_all_names = False + + # setting a custom name to the Series to prevent duplicated names + # in the 'dtypes_matrix' + series = pandas.Series(dtypes, name=i) + dtypes_matrix = pandas.concat([dtypes_matrix, series], axis=1) + dtypes_matrix.fillna( + value={ + # If we encountered a 'NaN' while 'val' describes all the columns, then + # it means, that the missing columns for this instance will be filled with NaNs (floats), + # otherwise, it may indicate missing columns that this 'val' has no info about, + # meaning that we shouldn't try computing a new dtype for this column, + # so marking it as 'unknown' + i: np.dtype(float) + if val._know_all_names and val._remaining_dtype is None + else "unknown" + }, + inplace=True, + ) + elif isinstance(val, pandas.Series): + dtypes_matrix = pandas.concat([dtypes_matrix, val], axis=1) + elif val is None: + # one of the 'dtypes' is None, meaning that we wouldn't been infer a valid result dtype, + # however, we're continuing our loop so we would at least know the columns we're missing + # dtypes for + dtypes_are_unknown = True + know_all_names = False + else: + raise NotImplementedError(type(val)) + + if dtypes_are_unknown: + return DtypesDescriptor( + cols_with_unknown_dtypes=dtypes_matrix.index, + know_all_names=know_all_names, + ) + + def combine_dtypes(row): + if (row == "unknown").any(): + return "unknown" + row = row.fillna(np.dtype("float")) + return find_common_type(list(row.values)) + + dtypes = dtypes_matrix.apply(combine_dtypes, axis=1) + + for col, dtype in dtypes.items(): + if dtype == "unknown": + cols_with_unknown_dtypes.append(col) + else: + known_dtypes[col] = dtype + + return DtypesDescriptor( + known_dtypes, + cols_with_unknown_dtypes, + remaining_dtype=None, + know_all_names=know_all_names, + ) + + @classmethod + def concat( + cls, values: list[Union["DtypesDescriptor", pandas.Series, None]], axis: int = 0 + ) -> "DtypesDescriptor": """ Concatenate dtypes descriptors into a single descriptor. Parameters ---------- values : list of DtypesDescriptors and pandas.Series + axis : int, default: 0 + If ``axis == 0``: concatenate column names. This implements the logic of + how dtypes are combined on ``pd.concat([df1, df2], axis=1)``. + If ``axis == 1``: perform a union join for the column names described by + `values` and then find common dtypes for the columns appeared to be in + an intersection. This implements the logic of how dtypes are combined on + ``pd.concat([df1, df2], axis=0).dtypes``. Returns ------- DtypesDescriptor """ + if axis == 1: + return cls._merge_dtypes(values) known_dtypes = {} cols_with_unknown_dtypes = [] schema_is_known = True @@ -636,13 +737,20 @@ def lazy_get(self, ids: list, numeric_index: bool = False) -> "ModinDtypes": return ModinDtypes(self._value.iloc[ids] if numeric_index else self._value[ids]) @classmethod - def concat(cls, values: list) -> "ModinDtypes": + def concat(cls, values: list, axis: int = 0) -> "ModinDtypes": """ - Concatenate dtypes.. + Concatenate dtypes. Parameters ---------- values : list of DtypesDescriptors, pandas.Series, ModinDtypes and Nones + axis : int, default: 0 + If ``axis == 0``: concatenate column names. This implements the logic of + how dtypes are combined on ``pd.concat([df1, df2], axis=1)``. + If ``axis == 1``: perform a union join for the column names described by + `values` and then find common dtypes for the columns appeared to be in + an intersection. This implements the logic of how dtypes are combined on + ``pd.concat([df1, df2], axis=0).dtypes``. Returns ------- @@ -658,12 +766,16 @@ def concat(cls, values: list) -> "ModinDtypes": raise NotImplementedError(type(val)) try: - desc = DtypesDescriptor.concat(preprocessed_vals) + desc = DtypesDescriptor.concat(preprocessed_vals, axis=axis) except NotImplementedError as e: - # 'DtypesDescriptor' doesn't support duplicated labels, however, if all values are pandas Serieses, + # 'DtypesDescriptor' doesn't support duplicated labels, however, if all values are pandas Series, # we still can perform concatenation using pure pandas - if "duplicated" not in e.args[0].lower() or not all( - isinstance(val, pandas.Series) for val in values + if ( + # 'pd.concat(axis=1)' fails on duplicated labels anyway, so doing this logic + # only in case 'axis=0' + axis == 0 + and "duplicated" not in e.args[0].lower() + or not all(isinstance(val, pandas.Series) for val in values) ): raise e desc = pandas.concat(values) diff --git a/modin/test/storage_formats/pandas/test_internals.py b/modin/test/storage_formats/pandas/test_internals.py index f315aa0bd52..c205bf1be4b 100644 --- a/modin/test/storage_formats/pandas/test_internals.py +++ b/modin/test/storage_formats/pandas/test_internals.py @@ -1729,7 +1729,7 @@ def test_lazy_get_desc(self): ) assert res.equals(exp) - def test_concat(self): + def test_concat_axis_0(self): res = DtypesDescriptor.concat( [ DtypesDescriptor(self.schema[["a", "b"]]), @@ -1815,6 +1815,147 @@ def test_concat(self): ) assert res.equals(exp) + @pytest.mark.parametrize( + "initial_dtypes, result_cols_with_known_dtypes, result_cols_with_unknown_dtypes", + [ + [ + # initial dtypes (cols_with_known_dtypes, cols_with_unknown_dtypes, remaining_dtype): + # dtypes for all columns are known + [ + (["a", "b", "c", "d"], [], None), + (["a", "b", "e", "d"], [], None), + (["a", "b"], [], None), + ], + # result_cols_with_known_dtypes: + # all dtypes were known in the beginning, expecting the same + # for the result + ["a", "b", "c", "d", "e"], + # result_cols_with_unknown_dtypes + [], + ], + [ + # initial dtypes (cols_with_known_dtypes, cols_with_unknown_dtypes, remaining_dtype) + [ + (["a", "b"], ["c", "d"], None), + (["a", "b", "d"], ["e"], None), + (["a", "b"], [], None), + ], + # result_cols_with_known_dtypes: + # across all dataframes, dtypes were only known for 'a' and 'b' columns + ["a", "b"], + # result_cols_with_unknown_dtypes + ["c", "d", "e"], + ], + [ + # initial dtypes (cols_with_known_dtypes, cols_with_unknown_dtypes, remaining_dtype): + # the 'e' column in the second frame is missing here, emulating 'know_all_names=False' case + [ + (["a", "b"], ["c", "d"], None), + (["a", "b", "d"], [], None), + (["a", "b"], [], None), + ], + # result_cols_with_known_dtypes + ["a", "b"], + # result_cols_with_unknown_dtypes: + # the missing 'e' column will be deducted from the resulted frame after '.concat()' + ["c", "d", "e"], + ], + [ + # initial dtypes (cols_with_known_dtypes, cols_with_unknown_dtypes, remaining_dtype) + # the 'c' column in the first frame is described using 'remaining_dtype' + [ + (["a", "b", "d"], [], np.dtype(bool)), + (["a", "b", "e", "d"], [], None), + (["a", "b"], [], None), + ], + # result_cols_with_known_dtypes: + # remaining dtypes are not supported by 'concat(axis=0)', so dtype for the 'c' + # column is missing here + ["a", "b", "e", "d"], + # result_cols_with_unknown_dtypes: + ["c"], + ], + ], + ) + def test_concat_axis_1( + self, + initial_dtypes, + result_cols_with_known_dtypes, + result_cols_with_unknown_dtypes, + ): + """ + Test that ``DtypesDescriptor.concat(axis=1)`` works as expected. + + Parameters + ---------- + initial_dtypes : list of tuples: (cols_with_known_dtypes, cols_with_unknown_dtypes, remaining_dtype) + Describe how to build ``DtypesDescriptor`` for each of the three dataframes. + result_cols_with_known_dtypes : list of labels + Column names for which dtypes has to be determined after ``.concat()``. + result_cols_with_unknown_dtypes : list of labels + Column names for which dtypes has to be unknown after ``.concat()``. + """ + md_df1, pd_df1 = create_test_dfs( + { + "a": [1, 2, 3], + "b": [3.5, 4.5, 5.5], + "c": [True, False, True], + "d": ["a", "b", "c"], + } + ) + md_df2, pd_df2 = create_test_dfs( + { + "a": [1.5, 2.5, 3.5], + "b": [3.5, 4.5, 5.5], + "e": [True, False, True], + "d": ["a", "b", "c"], + } + ) + md_df3, pd_df3 = create_test_dfs({"a": [1, 2, 3], "b": [3.5, 4.5, 5.5]}) + + for md_df, (known_cols, unknown_cols, remaining_dtype) in zip( + [md_df1, md_df2, md_df3], initial_dtypes + ): + known_dtypes = {col: md_df.dtypes[col] for col in known_cols} + know_all_names = ( + len(known_cols) + len(unknown_cols) == len(md_df.columns) + or remaining_dtype is not None + ) + # setting columns cache to 'None', in order to prevent completing 'dtypes' with the materialized columns + md_df._query_compiler._modin_frame.set_columns_cache(None) + md_df._query_compiler._modin_frame.set_dtypes_cache( + ModinDtypes( + DtypesDescriptor( + known_dtypes, + unknown_cols, + remaining_dtype, + know_all_names=know_all_names, + ) + ) + ) + md_dtypes = pd.concat( + [md_df1, md_df2, md_df3] + )._query_compiler._modin_frame._dtypes + pd_dtypes = pandas.concat([pd_df1, pd_df2, pd_df3]).dtypes + if len(result_cols_with_known_dtypes) == len(pd_dtypes): + md_dtypes = ( + md_dtypes if isinstance(md_dtypes, pandas.Series) else md_dtypes._value + ) + assert isinstance(md_dtypes, pandas.Series) + assert md_dtypes.equals(pd_dtypes) + else: + assert set(md_dtypes._value._known_dtypes.keys()) == set( + result_cols_with_known_dtypes + ) + # reindexing to ensure proper order + md_known_dtypes = pandas.Series(md_dtypes._value._known_dtypes).reindex( + result_cols_with_known_dtypes + ) + assert md_known_dtypes.equals(pd_dtypes[result_cols_with_known_dtypes]) + assert set(md_dtypes._value._cols_with_unknown_dtypes) == set( + result_cols_with_unknown_dtypes + ) + def test_ModinDtypes_duplicated_concat(self): # test that 'ModinDtypes' is able to perform dtypes concatenation on duplicated labels # if all of them are Serieses From b8323b5f46e25d257c08f55745c018197f7530f5 Mon Sep 17 00:00:00 2001 From: Dmitry Chigarev Date: Tue, 21 Nov 2023 19:01:35 +0100 Subject: [PATCH 094/201] PERF-#6762: Carry dtypes information in lazy indices (#6763) Signed-off-by: Dmitry Chigarev --- modin/core/dataframe/algebra/groupby.py | 21 ++++++- .../dataframe/pandas/dataframe/dataframe.py | 6 ++ modin/core/dataframe/pandas/metadata/index.py | 26 +++++++- .../storage_formats/pandas/query_compiler.py | 59 +++++++++++++++---- .../storage_formats/pandas/test_internals.py | 47 +++++++++++++++ 5 files changed, 144 insertions(+), 15 deletions(-) diff --git a/modin/core/dataframe/algebra/groupby.py b/modin/core/dataframe/algebra/groupby.py index e4c03feb63f..6a9dc67b51a 100644 --- a/modin/core/dataframe/algebra/groupby.py +++ b/modin/core/dataframe/algebra/groupby.py @@ -15,6 +15,7 @@ import pandas +from modin.core.dataframe.pandas.metadata import ModinIndex from modin.error_message import ErrorMessage from modin.utils import MODIN_UNNAMED_SERIES_LABEL, hashable @@ -407,8 +408,26 @@ def caller( # Otherwise `by` was already bound to the Map function in `build_map_reduce_functions`. broadcastable_by = getattr(by, "_modin_frame", None) apply_indices = list(map_func.keys()) if isinstance(map_func, dict) else None + if ( + broadcastable_by is not None + and groupby_kwargs.get("as_index", True) + and broadcastable_by.has_materialized_dtypes + ): + new_index = ModinIndex( + # value can be anything here, as it will be reassigned on a parent update + value=query_compiler._modin_frame, + axis=0, + dtypes=broadcastable_by.dtypes, + ) + else: + new_index = None new_modin_frame = query_compiler._modin_frame.groupby_reduce( - axis, broadcastable_by, map_fn, reduce_fn, apply_indices=apply_indices + axis, + broadcastable_by, + map_fn, + reduce_fn, + apply_indices=apply_indices, + new_index=new_index, ) result = query_compiler.__constructor__(new_modin_frame) diff --git a/modin/core/dataframe/pandas/dataframe/dataframe.py b/modin/core/dataframe/pandas/dataframe/dataframe.py index 81092b7eb57..4be07569f5b 100644 --- a/modin/core/dataframe/pandas/dataframe/dataframe.py +++ b/modin/core/dataframe/pandas/dataframe/dataframe.py @@ -3911,6 +3911,12 @@ def join_cols(df, *cols): row_lengths=result._row_lengths_cache, ) + if not result.has_materialized_index: + by_dtypes = ModinDtypes(self._dtypes).lazy_get(by) + if by_dtypes.is_materialized: + new_index = ModinIndex(value=result, axis=0, dtypes=by_dtypes) + result.set_index_cache(new_index) + if result_schema is not None: new_dtypes = pandas.Series(result_schema) diff --git a/modin/core/dataframe/pandas/metadata/index.py b/modin/core/dataframe/pandas/metadata/index.py index 6242b61e2f8..52a6d536aaf 100644 --- a/modin/core/dataframe/pandas/metadata/index.py +++ b/modin/core/dataframe/pandas/metadata/index.py @@ -35,13 +35,16 @@ class ModinIndex: axis : int, optional Specifies an axis the object represents, serves as an optional hint. This parameter must be passed in case value is a ``PandasDataframe``. + dtypes : pandas.Series, optional + Materialized dtypes of index levels. """ - def __init__(self, value, axis=None): + def __init__(self, value, axis=None, dtypes=None): from modin.core.dataframe.pandas.dataframe.dataframe import PandasDataframe self._is_default_callable = False self._axis = axis + self._dtypes = dtypes if callable(value): self._value = value @@ -58,6 +61,25 @@ def __init__(self, value, axis=None): self._index_id = uuid.uuid4() self._lengths_id = uuid.uuid4() + def maybe_get_dtypes(self): + """ + Get index dtypes if available. + + Returns + ------- + pandas.Series or None + """ + if self._dtypes is not None: + return self._dtypes + if self.is_materialized: + self._dtypes = ( + self._value.dtypes + if isinstance(self._value, pandas.MultiIndex) + else pandas.Series([self._value.dtype], index=[self._value.name]) + ) + return self._dtypes + return None + @staticmethod def _get_default_callable(dataframe_obj, axis): """ @@ -308,7 +330,7 @@ def copy(self, copy_lengths=False) -> "ModinIndex": idx_cache = self._value if not callable(idx_cache): idx_cache = idx_cache.copy() - result = ModinIndex(idx_cache, axis=self._axis) + result = ModinIndex(idx_cache, axis=self._axis, dtypes=self._dtypes) result._index_id = self._index_id result._is_default_callable = self._is_default_callable if copy_lengths: diff --git a/modin/core/storage_formats/pandas/query_compiler.py b/modin/core/storage_formats/pandas/query_compiler.py index 14020e031c6..373d0d876ab 100644 --- a/modin/core/storage_formats/pandas/query_compiler.py +++ b/modin/core/storage_formats/pandas/query_compiler.py @@ -61,6 +61,7 @@ from modin.core.dataframe.pandas.metadata import ( DtypesDescriptor, ModinDtypes, + ModinIndex, extract_dtype, ) from modin.core.storage_formats.base.query_compiler import BaseQueryCompiler @@ -589,16 +590,9 @@ def map_func( [right_pandas.dtypes, right_index_dtypes] )[right_renamer.keys()].rename(right_renamer) - left_index_dtypes = None - if self._modin_frame.has_materialized_index: - left_index_dtypes = ( - self.index.dtypes - if isinstance(self.index, pandas.MultiIndex) - else pandas.Series( - [self.index.dtype], index=[self.index.name] - ) - ) - + left_index_dtypes = ( + self._modin_frame._index_cache.maybe_get_dtypes() + ) left_dtypes = ( ModinDtypes.concat( [self._modin_frame._dtypes, left_index_dtypes] @@ -755,12 +749,36 @@ def _reset(df, *axis_lengths, partition_idx): # pragma: no cover copy_lengths=True ) else: - # concat index dtypes (None, since they're unknown) with column dtypes + # concat index dtypes with column dtypes + index_dtypes = self._modin_frame._index_cache.maybe_get_dtypes() try: - dtypes = ModinDtypes.concat([None, self._modin_frame._dtypes]) + dtypes = ModinDtypes.concat( + [ + index_dtypes, + self._modin_frame._dtypes, + ] + ) except NotImplementedError: # may raise on duplicated names in materialized 'self.dtypes' dtypes = None + if ( + # can precompute new columns if we know columns and index names + self._modin_frame.has_materialized_columns + and index_dtypes is not None + ): + empty_index = ( + pandas.Index([0], name=index_dtypes.index[0]) + if len(index_dtypes) == 1 + else pandas.MultiIndex.from_arrays( + [[i] for i in range(len(index_dtypes))], + names=index_dtypes.index, + ) + ) + new_columns = ( + pandas.DataFrame(columns=self.columns, index=empty_index) + .reset_index(**kwargs) + .columns + ) return self.__constructor__( self._modin_frame.apply_full_axis( @@ -4124,12 +4142,29 @@ def compute_groupby(df, drop=False, partition_idx=0): else: apply_indices = None + if ( + # For now handling only simple cases, where 'by' columns are described by a single query compiler + agg_kwargs.get("as_index", True) + and len(not_broadcastable_by) == 0 + and len(broadcastable_by) == 1 + and broadcastable_by[0].has_materialized_dtypes + ): + new_index = ModinIndex( + # value can be anything here, as it will be reassigned on a parent update + value=self._modin_frame, + axis=0, + dtypes=broadcastable_by[0].dtypes, + ) + else: + new_index = None + new_modin_frame = self._modin_frame.broadcast_apply_full_axis( axis=axis, func=lambda df, by=None, partition_idx=None: groupby_agg_builder( df, by, drop, partition_idx ), other=broadcastable_by, + new_index=new_index, apply_indices=apply_indices, enumerate_partitions=True, ) diff --git a/modin/test/storage_formats/pandas/test_internals.py b/modin/test/storage_formats/pandas/test_internals.py index c205bf1be4b..c0e1bf90ec4 100644 --- a/modin/test/storage_formats/pandas/test_internals.py +++ b/modin/test/storage_formats/pandas/test_internals.py @@ -2319,3 +2319,50 @@ def test_preserve_dtypes_reset_index(self, drop, has_materialized_index): ) patch.assert_not_called() + + def test_groupby_index_dtype(self): + with mock.patch.object(PandasDataframe, "_compute_dtypes") as patch: + # case 1: MapReduce impl, Series as an output of groupby + df = pd.DataFrame({"a": [1, 2, 2], "b": [3, 4, 5]}) + res = df.groupby("a").size().reset_index(name="new_name") + res_dtypes = res._query_compiler._modin_frame._dtypes._value + assert "a" in res_dtypes._known_dtypes + assert res_dtypes._known_dtypes["a"] == np.dtype(int) + + # case 2: ExperimentalImpl impl, Series as an output of groupby + ExperimentalGroupbyImpl.put(True) + try: + df = pd.DataFrame({"a": [1, 2, 2], "b": [3, 4, 5]}) + res = df.groupby("a").size().reset_index(name="new_name") + res_dtypes = res._query_compiler._modin_frame._dtypes._value + assert "a" in res_dtypes._known_dtypes + assert res_dtypes._known_dtypes["a"] == np.dtype(int) + finally: + ExperimentalGroupbyImpl.put(False) + + # case 3: MapReduce impl, DataFrame as an output of groupby + df = pd.DataFrame({"a": [1, 2, 2], "b": [3, 4, 5]}) + res = df.groupby("a").sum().reset_index() + res_dtypes = res._query_compiler._modin_frame._dtypes._value + assert "a" in res_dtypes._known_dtypes + assert res_dtypes._known_dtypes["a"] == np.dtype(int) + + # case 4: ExperimentalImpl impl, DataFrame as an output of groupby + ExperimentalGroupbyImpl.put(True) + try: + df = pd.DataFrame({"a": [1, 2, 2], "b": [3, 4, 5]}) + res = df.groupby("a").sum().reset_index() + res_dtypes = res._query_compiler._modin_frame._dtypes._value + assert "a" in res_dtypes._known_dtypes + assert res_dtypes._known_dtypes["a"] == np.dtype(int) + finally: + ExperimentalGroupbyImpl.put(False) + + # case 5: FullAxis impl, DataFrame as an output of groupby + df = pd.DataFrame({"a": [1, 2, 2], "b": [3, 4, 5]}) + res = df.groupby("a").quantile().reset_index() + res_dtypes = res._query_compiler._modin_frame._dtypes._value + assert "a" in res_dtypes._known_dtypes + assert res_dtypes._known_dtypes["a"] == np.dtype(int) + + patch.assert_not_called() From 58a74fd500857180f56e8116962ae203fc90feac Mon Sep 17 00:00:00 2001 From: Anatoly Myachev Date: Mon, 27 Nov 2023 19:31:36 +0100 Subject: [PATCH 095/201] FIX-#6768: make sure `to_numpy` use `**kwargs` after #6704 (#6769) Signed-off-by: Anatoly Myachev Co-authored-by: Dmitry Chigarev --- modin/core/dataframe/pandas/partitioning/partition.py | 2 +- .../ray/generic/partitioning/partition_manager.py | 2 +- .../unidist/generic/partitioning/partition_manager.py | 2 +- modin/pandas/test/test_series.py | 9 +++++++++ 4 files changed, 12 insertions(+), 3 deletions(-) diff --git a/modin/core/dataframe/pandas/partitioning/partition.py b/modin/core/dataframe/pandas/partitioning/partition.py index 38cc41caee2..224c40a194c 100644 --- a/modin/core/dataframe/pandas/partitioning/partition.py +++ b/modin/core/dataframe/pandas/partitioning/partition.py @@ -209,7 +209,7 @@ def to_numpy(self, **kwargs): If the underlying object is a pandas DataFrame, this will return a 2D NumPy array. """ - return self.apply(lambda df, **kwargs: df.to_numpy(**kwargs)).get() + return self.apply(lambda df: df.to_numpy(**kwargs)).get() @staticmethod def _iloc(df, row_labels, col_labels): # noqa: RT01, PR01 diff --git a/modin/core/execution/ray/generic/partitioning/partition_manager.py b/modin/core/execution/ray/generic/partitioning/partition_manager.py index f5795d0778d..d4b61b25d38 100644 --- a/modin/core/execution/ray/generic/partitioning/partition_manager.py +++ b/modin/core/execution/ray/generic/partitioning/partition_manager.py @@ -42,7 +42,7 @@ def to_numpy(cls, partitions, **kwargs): """ if partitions.shape[1] == 1: parts = cls.get_objects_from_partitions(partitions.flatten()) - parts = [part.to_numpy() for part in parts] + parts = [part.to_numpy(**kwargs) for part in parts] else: parts = RayWrapper.materialize( [ diff --git a/modin/core/execution/unidist/generic/partitioning/partition_manager.py b/modin/core/execution/unidist/generic/partitioning/partition_manager.py index 666152a0376..8052c5cc117 100644 --- a/modin/core/execution/unidist/generic/partitioning/partition_manager.py +++ b/modin/core/execution/unidist/generic/partitioning/partition_manager.py @@ -42,7 +42,7 @@ def to_numpy(cls, partitions, **kwargs): """ if partitions.shape[1] == 1: parts = cls.get_objects_from_partitions(partitions.flatten()) - parts = [part.to_numpy() for part in parts] + parts = [part.to_numpy(**kwargs) for part in parts] else: parts = UnidistWrapper.materialize( [ diff --git a/modin/pandas/test/test_series.py b/modin/pandas/test/test_series.py index 1b2c5eb0a5a..030bd485107 100644 --- a/modin/pandas/test/test_series.py +++ b/modin/pandas/test/test_series.py @@ -3475,6 +3475,15 @@ def test_to_numpy(data): assert_array_equal(modin_series.to_numpy(), pandas_series.to_numpy()) +def test_to_numpy_dtype(): + modin_series, pandas_series = create_test_series(test_data["float_nan_data"]) + assert_array_equal( + modin_series.to_numpy(dtype="int64"), + pandas_series.to_numpy(dtype="int64"), + strict=True, + ) + + @pytest.mark.parametrize( "data", test_data_values + test_data_large_categorical_series_values, From 552cad8aed0e08bb81e2a634188050b705b194d5 Mon Sep 17 00:00:00 2001 From: Anatoly Myachev Date: Tue, 28 Nov 2023 13:00:25 +0100 Subject: [PATCH 096/201] FIX-#6771: avoid 'ValueError: assignment destination is read-only' for 'cumsum' (#6772) Signed-off-by: Anatoly Myachev --- .../core/dataframe/pandas/partitioning/axis_partition.py | 8 +++++++- modin/pandas/test/test_series.py | 4 ++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/modin/core/dataframe/pandas/partitioning/axis_partition.py b/modin/core/dataframe/pandas/partitioning/axis_partition.py index 30ed90c4321..bfc94605744 100644 --- a/modin/core/dataframe/pandas/partitioning/axis_partition.py +++ b/modin/core/dataframe/pandas/partitioning/axis_partition.py @@ -422,7 +422,13 @@ def deploy_axis_func( dataframe = pandas.concat(list(partitions), axis=axis, copy=False) with warnings.catch_warnings(): warnings.filterwarnings("ignore", category=FutureWarning) - result = func(dataframe, *f_args, **f_kwargs) + try: + result = func(dataframe, *f_args, **f_kwargs) + except ValueError as err: + if "assignment destination is read-only" in str(err): + result = func(dataframe.copy(), *f_args, **f_kwargs) + else: + raise err if num_splits == 1: # If we're not going to split the result, we don't need to specify diff --git a/modin/pandas/test/test_series.py b/modin/pandas/test/test_series.py index 030bd485107..e64e1371d3e 100644 --- a/modin/pandas/test/test_series.py +++ b/modin/pandas/test/test_series.py @@ -1474,6 +1474,10 @@ def test_cumsum(data, skipna): df_equals(modin_series.cumsum(skipna=skipna), pandas_result) +def test_cumsum_6771(): + _ = to_pandas(pd.Series([1, 2, 3], dtype="Int64").cumsum()) + + @pytest.mark.parametrize("data", test_data_values, ids=test_data_keys) def test_describe(data): modin_series, pandas_series = create_test_series(data) From ca654ded999bcdec8b87851dfd44b50e8f9fae69 Mon Sep 17 00:00:00 2001 From: Devin Petersohn Date: Tue, 28 Nov 2023 06:03:34 -0800 Subject: [PATCH 097/201] FIX-#4355: Fix rename algebraic operator to avoid copying (#4356) Signed-off-by: Devin Petersohn Signed-off-by: Anatoly Myachev Co-authored-by: Anatoly Myachev Co-authored-by: Mahesh Vashishtha Co-authored-by: Anatoly Myachev --- .../dataframe/base/dataframe/dataframe.py | 7 -- .../dataframe/pandas/dataframe/dataframe.py | 110 ++---------------- .../storage_formats/pandas/query_compiler.py | 18 ++- 3 files changed, 26 insertions(+), 109 deletions(-) diff --git a/modin/core/dataframe/base/dataframe/dataframe.py b/modin/core/dataframe/base/dataframe/dataframe.py index 4567f23c37e..7fbf776f890 100644 --- a/modin/core/dataframe/base/dataframe/dataframe.py +++ b/modin/core/dataframe/base/dataframe/dataframe.py @@ -478,7 +478,6 @@ def rename( self, new_row_labels: Optional[Union[Dict[Hashable, Hashable], Callable]] = None, new_col_labels: Optional[Union[Dict[Hashable, Hashable], Callable]] = None, - level: Optional[Union[int, List[int]]] = None, ) -> "ModinDataframe": """ Replace the row and column labels with the specified new labels. @@ -489,17 +488,11 @@ def rename( Mapping or callable that relates old row labels to new labels. new_col_labels : dictionary or callable, optional Mapping or callable that relates old col labels to new labels. - level : int or list of ints, optional - Level(s) whose row labels to replace. Returns ------- ModinDataframe A new ModinDataframe with the new row and column labels. - - Notes - ----- - If level is not specified, the default behavior is to replace row labels in all levels. """ pass diff --git a/modin/core/dataframe/pandas/dataframe/dataframe.py b/modin/core/dataframe/pandas/dataframe/dataframe.py index 4be07569f5b..7676e5eae53 100644 --- a/modin/core/dataframe/pandas/dataframe/dataframe.py +++ b/modin/core/dataframe/pandas/dataframe/dataframe.py @@ -1610,57 +1610,6 @@ def astype_builder(df): new_dtypes, ) - # Metadata modification methods - def add_prefix(self, prefix, axis): - """ - Add a prefix to the current row or column labels. - - Parameters - ---------- - prefix : str - The prefix to add. - axis : int - The axis to update. - - Returns - ------- - PandasDataframe - A new dataframe with the updated labels. - """ - - def new_labels_mapper(x, prefix=str(prefix)): - return prefix + str(x) - - if axis == 0: - return self.rename(new_row_labels=new_labels_mapper) - return self.rename(new_col_labels=new_labels_mapper) - - def add_suffix(self, suffix, axis): - """ - Add a suffix to the current row or column labels. - - Parameters - ---------- - suffix : str - The suffix to add. - axis : int - The axis to update. - - Returns - ------- - PandasDataframe - A new dataframe with the updated labels. - """ - - def new_labels_mapper(x, suffix=str(suffix)): - return str(x) + suffix - - if axis == 0: - return self.rename(new_row_labels=new_labels_mapper) - return self.rename(new_col_labels=new_labels_mapper) - - # END Metadata modification methods - def numeric_columns(self, include_bool=True): """ Return the names of numeric columns in the frame. @@ -2323,7 +2272,6 @@ def rename( self, new_row_labels: Optional[Union[Dict[Hashable, Hashable], Callable]] = None, new_col_labels: Optional[Union[Dict[Hashable, Hashable], Callable]] = None, - level: Optional[Union[int, List[int]]] = None, ) -> "PandasDataframe": """ Replace the row and column labels with the specified new labels. @@ -2334,60 +2282,22 @@ def rename( Mapping or callable that relates old row labels to new labels. new_col_labels : dictionary or callable, optional Mapping or callable that relates old col labels to new labels. - level : int, optional - Level whose row labels to replace. Returns ------- PandasDataframe A new PandasDataframe with the new row and column labels. - - Notes - ----- - If level is not specified, the default behavior is to replace row labels in all levels. """ - new_index = self.index.copy() - - def make_label_swapper(label_dict): - if isinstance(label_dict, dict): - return lambda label: label_dict.get(label, label) - return label_dict - - def swap_labels_levels(index_tuple): - if isinstance(new_row_labels, dict): - return tuple(new_row_labels.get(label, label) for label in index_tuple) - return tuple(new_row_labels(label) for label in index_tuple) - - if new_row_labels: - swap_row_labels = make_label_swapper(new_row_labels) - if isinstance(self.index, pandas.MultiIndex): - if level is not None: - new_index.set_levels( - new_index.levels[level].map(swap_row_labels), level - ) - else: - new_index = new_index.map(swap_labels_levels) - else: - new_index = new_index.map(swap_row_labels) - new_cols = self.columns.copy() - if new_col_labels: - new_cols = new_cols.map(make_label_swapper(new_col_labels)) - - def map_fn(df): - return df.rename(index=new_row_labels, columns=new_col_labels, level=level) - - new_parts = self._partition_mgr_cls.map_partitions(self._partitions, map_fn) - new_dtypes = None - if self.has_materialized_dtypes: - new_dtypes = self.dtypes.set_axis(new_cols) - return self.__constructor__( - new_parts, - new_index, - new_cols, - self._row_lengths_cache, - self._column_widths_cache, - new_dtypes, - ) + result = self.copy() + if new_row_labels is not None: + if callable(new_row_labels): + new_row_labels = result.index.map(new_row_labels) + result.index = new_row_labels + if new_col_labels is not None: + if callable(new_col_labels): + new_col_labels = result.columns.map(new_col_labels) + result.columns = new_col_labels + return result def combine_and_apply( self, func, new_index=None, new_columns=None, new_dtypes=None diff --git a/modin/core/storage_formats/pandas/query_compiler.py b/modin/core/storage_formats/pandas/query_compiler.py index 373d0d876ab..80027ec105b 100644 --- a/modin/core/storage_formats/pandas/query_compiler.py +++ b/modin/core/storage_formats/pandas/query_compiler.py @@ -326,10 +326,24 @@ def get_dtypes_set(self): # Metadata modification methods def add_prefix(self, prefix, axis=1): - return self.__constructor__(self._modin_frame.add_prefix(prefix, axis)) + if axis == 1: + return self.__constructor__( + self._modin_frame.rename(new_col_labels=lambda x: f"{prefix}{x}") + ) + else: + return self.__constructor__( + self._modin_frame.rename(new_row_labels=lambda x: f"{prefix}{x}") + ) def add_suffix(self, suffix, axis=1): - return self.__constructor__(self._modin_frame.add_suffix(suffix, axis)) + if axis == 1: + return self.__constructor__( + self._modin_frame.rename(new_col_labels=lambda x: f"{x}{suffix}") + ) + else: + return self.__constructor__( + self._modin_frame.rename(new_row_labels=lambda x: f"{x}{suffix}") + ) # END Metadata modification methods From ebc718d0f4bae70c668169dbce5eb3f35606cd38 Mon Sep 17 00:00:00 2001 From: Anatoly Myachev Date: Tue, 28 Nov 2023 17:33:12 +0100 Subject: [PATCH 098/201] FIX-#6773: make sure '_to_pandas' return mutable pandas objects (#6775) Signed-off-by: Anatoly Myachev --- modin/core/dataframe/pandas/utils.py | 4 ++++ modin/pandas/test/test_general.py | 30 ++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/modin/core/dataframe/pandas/utils.py b/modin/core/dataframe/pandas/utils.py index bb8d018e6e2..c1703d6f2db 100644 --- a/modin/core/dataframe/pandas/utils.py +++ b/modin/core/dataframe/pandas/utils.py @@ -44,4 +44,8 @@ def concatenate(dfs): i, pandas.Categorical(df.iloc[:, i], categories=union.categories) ) # `ValueError: buffer source array is read-only` if copy==False + if len(dfs) == 1: + # concat doesn't make a copy if len(dfs) == 1, + # so do it explicitly + return dfs[0].copy() return pandas.concat(dfs, copy=True) diff --git a/modin/pandas/test/test_general.py b/modin/pandas/test/test_general.py index 2f6f331f4d4..2fec3371ca9 100644 --- a/modin/pandas/test/test_general.py +++ b/modin/pandas/test/test_general.py @@ -835,6 +835,36 @@ def test_to_pandas_indices(data): ), f"Levels of indices at axis {axis} are different!" +def test_to_pandas_read_only_issue(): + df = pd.DataFrame( + [ + [np.nan, 2, np.nan, 0], + [3, 4, np.nan, 1], + [np.nan, np.nan, np.nan, np.nan], + [np.nan, 3, np.nan, 4], + ], + columns=list("ABCD"), + ) + pdf = df._to_pandas() + # there shouldn't be `ValueError: putmask: output array is read-only` + pdf.fillna(0, inplace=True) + + +def test_to_numpy_read_only_issue(): + df = pd.DataFrame( + [ + [np.nan, 2, np.nan, 0], + [3, 4, np.nan, 1], + [np.nan, np.nan, np.nan, np.nan], + [np.nan, 3, np.nan, 4], + ], + columns=list("ABCD"), + ) + arr = df.to_numpy() + # there shouldn't be `ValueError: putmask: output array is read-only` + np.putmask(arr, np.isnan(arr), 0) + + def test_create_categorical_dataframe_with_duplicate_column_name(): # This tests for https://github.com/modin-project/modin/issues/4312 pd_df = pandas.DataFrame( From 76d741bec279305b041ba5689947438884893dad Mon Sep 17 00:00:00 2001 From: Anatoly Myachev Date: Wed, 29 Nov 2023 14:35:35 +0100 Subject: [PATCH 099/201] TEST-#6777: Make `to_csv` tests on Unidist more stable (#6776) Signed-off-by: Anatoly Myachev --- .github/workflows/ci.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b938e08b7c6..0e0f1944f6c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -632,6 +632,15 @@ jobs: if: matrix.os != 'windows' - run: ${{ matrix.execution.shell-ex }} $PARALLEL modin/numpy/test - run: ${{ matrix.execution.shell-ex }} -m "not exclude_in_sanity" modin/pandas/test/test_io.py --verbose + if: matrix.execution.name != 'unidist' + - uses: nick-fields/retry@v2 + # to avoid issues with non-stable `to_csv` tests for unidist on MPI backend. + # for details see: https://github.com/modin-project/modin/pull/6776 + with: + timeout_minutes: 15 + max_attempts: 3 + command: conda run --no-capture-output -n modin_on_unidist ${{ matrix.execution.shell-ex }} -m "not exclude_in_sanity" modin/pandas/test/test_io.py --verbose + if: matrix.execution.name == 'unidist' - run: ${{ matrix.execution.shell-ex }} modin/experimental/pandas/test/test_io_exp.py - run: ${{ matrix.execution.shell-ex }} $PARALLEL modin/test/interchange/dataframe_protocol/test_general.py - run: ${{ matrix.execution.shell-ex }} $PARALLEL modin/test/interchange/dataframe_protocol/pandas/test_protocol.py From c3b2a53bfefd3aeaf4704904991bf193d070f9b8 Mon Sep 17 00:00:00 2001 From: Anatoly Myachev Date: Thu, 30 Nov 2023 15:32:46 +0100 Subject: [PATCH 100/201] FIX-#6779: pass only one indexer into `Series.__getitem__` (#6780) Signed-off-by: Anatoly Myachev Co-authored-by: Dmitry Chigarev --- modin/pandas/indexing.py | 3 +++ modin/pandas/test/test_series.py | 8 ++++++++ 2 files changed, 11 insertions(+) diff --git a/modin/pandas/indexing.py b/modin/pandas/indexing.py index f492278e379..fc36b84df12 100644 --- a/modin/pandas/indexing.py +++ b/modin/pandas/indexing.py @@ -568,6 +568,9 @@ def _handle_boolean_masking(self, row_loc, col_loc): masked_df = self.df.__constructor__( query_compiler=self.qc.getitem_array(row_loc._query_compiler) ) + if isinstance(masked_df, Series): + assert col_loc == slice(None) + return masked_df # Passing `slice(None)` as a row indexer since we've just applied it return type(self)(masked_df)[(slice(None), col_loc)] diff --git a/modin/pandas/test/test_series.py b/modin/pandas/test/test_series.py index e64e1371d3e..8945a356fed 100644 --- a/modin/pandas/test/test_series.py +++ b/modin/pandas/test/test_series.py @@ -2363,6 +2363,14 @@ def test_loc(data): df_equals(modin_result, pandas_result) +def test_loc_with_boolean_series(): + modin_series, pandas_series = create_test_series([1, 2, 3]) + modin_mask, pandas_mask = create_test_series([True, False, False]) + modin_result = modin_series.loc[modin_mask] + pandas_result = pandas_series.loc[pandas_mask] + df_equals(modin_result, pandas_result) + + # This tests the bug from https://github.com/modin-project/modin/issues/3736 def test_loc_setting_categorical_series(): modin_series = pd.Series(["a", "b", "c"], dtype="category") From 2fee14409bfda6661b8fcccbb33f3c93ae17204f Mon Sep 17 00:00:00 2001 From: Anatoly Myachev Date: Thu, 30 Nov 2023 15:38:42 +0100 Subject: [PATCH 101/201] FEAT-#6784: Add d2p implementations for `DataFrame.__rdivmod__/__divmod__` (#6785) Signed-off-by: Anatoly Myachev --- modin/pandas/dataframe.py | 18 ++++++++++++++++++ modin/pandas/test/dataframe/test_binary.py | 11 +++++++++++ 2 files changed, 29 insertions(+) diff --git a/modin/pandas/dataframe.py b/modin/pandas/dataframe.py index 2484b3932e2..acba48aa3d5 100644 --- a/modin/pandas/dataframe.py +++ b/modin/pandas/dataframe.py @@ -60,6 +60,7 @@ from .series import Series from .utils import ( SET_DATAFRAME_ATTRIBUTE_WARNING, + _doc_binary_op, cast_function_modin2pandas, from_non_pandas, from_pandas, @@ -2720,6 +2721,23 @@ def __delitem__(self, key): raise KeyError(key) self._update_inplace(new_query_compiler=self._query_compiler.delitem(key)) + @_doc_binary_op( + operation="integer division and modulo", + bin_op="divmod", + returns="tuple of two DataFrames", + ) + def __divmod__(self, right): + return self._default_to_pandas(pandas.DataFrame.__divmod__, right) + + @_doc_binary_op( + operation="integer division and modulo", + bin_op="divmod", + right="left", + returns="tuple of two DataFrames", + ) + def __rdivmod__(self, left): + return self._default_to_pandas(pandas.DataFrame.__rdivmod__, left) + __add__ = add __iadd__ = add # pragma: no cover __radd__ = radd diff --git a/modin/pandas/test/dataframe/test_binary.py b/modin/pandas/test/dataframe/test_binary.py index 62a20d5fdea..327991891a5 100644 --- a/modin/pandas/test/dataframe/test_binary.py +++ b/modin/pandas/test/dataframe/test_binary.py @@ -90,6 +90,17 @@ def test_math_functions(other, axis, op): ) +@pytest.mark.parametrize("other", [lambda df: 2, lambda df: df]) +def test___divmod__(other): + data = test_data["float_nan_data"] + eval_general(*create_test_dfs(data), lambda df: divmod(df, other(df))) + + +def test___rdivmod__(): + data = test_data["float_nan_data"] + eval_general(*create_test_dfs(data), lambda df: divmod(2, df)) + + @pytest.mark.parametrize( "other", [lambda df: df[: -(2**4)], lambda df: df[df.columns[0]].reset_index(drop=True)], From 1bd39dcc618298060ff265f7a6492d14c27fc5f5 Mon Sep 17 00:00:00 2001 From: Anatoly Myachev Date: Fri, 1 Dec 2023 10:33:28 +0100 Subject: [PATCH 102/201] FIX-#6786: properly d2p for cross 'DataFrame.join' (#6787) Signed-off-by: Anatoly Myachev --- modin/pandas/dataframe.py | 2 +- modin/pandas/test/dataframe/test_join_sort.py | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/modin/pandas/dataframe.py b/modin/pandas/dataframe.py index acba48aa3d5..97df23e9704 100644 --- a/modin/pandas/dataframe.py +++ b/modin/pandas/dataframe.py @@ -1163,7 +1163,7 @@ def join( if other.name is None: raise ValueError("Other Series must have a name") other = self.__constructor__(other) - if on is not None: + if on is not None or how == "cross": return self.__constructor__( query_compiler=self._query_compiler.join( other._query_compiler, diff --git a/modin/pandas/test/dataframe/test_join_sort.py b/modin/pandas/test/dataframe/test_join_sort.py index 0d4ab89f22a..232eba8a1ef 100644 --- a/modin/pandas/test/dataframe/test_join_sort.py +++ b/modin/pandas/test/dataframe/test_join_sort.py @@ -167,6 +167,19 @@ def test_join(test_data, test_data2): df_equals(modin_join, pandas_join) +def test_join_cross_6786(): + data = [[7, 8, 9], [10, 11, 12]] + modin_df, pandas_df = create_test_dfs(data, columns=["x", "y", "z"]) + + modin_join = modin_df.join( + modin_df[["x"]].set_axis(["p", "q"], axis=0), how="cross", lsuffix="p" + ) + pandas_join = pandas_df.join( + pandas_df[["x"]].set_axis(["p", "q"], axis=0), how="cross", lsuffix="p" + ) + df_equals(modin_join, pandas_join) + + def test_join_5203(): data = np.ones([2, 4]) kwargs = {"columns": ["a", "b", "c", "d"]} From b8b843476cf9db42d0271970441b1a0d596c9807 Mon Sep 17 00:00:00 2001 From: Iaroslav Igoshev Date: Fri, 1 Dec 2023 13:48:54 +0100 Subject: [PATCH 103/201] FIX-#6791: Pass additional environment variables to MPI workers (#6792) Signed-off-by: Igoshev, Iaroslav --- modin/core/execution/unidist/common/utils.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/modin/core/execution/unidist/common/utils.py b/modin/core/execution/unidist/common/utils.py index 5159e05a3c3..30d735945e5 100644 --- a/modin/core/execution/unidist/common/utils.py +++ b/modin/core/execution/unidist/common/utils.py @@ -45,8 +45,9 @@ def initialize_unidist(): unidist.init() """, ) - # TODO: allow unidist to inherit env variables on initialization - # with set_env(PYTHONWARNINGS="ignore::FutureWarning"): + unidist_cfg.MpiRuntimeEnv.put( + {"env_vars": {"PYTHONWARNINGS": "ignore::FutureWarning"}} + ) unidist.init() num_cpus = sum(v["CPU"] for v in unidist.cluster_resources().values()) From 68c69f81bb73a0c499b21fb9b3f90d407872a6dc Mon Sep 17 00:00:00 2001 From: Jignyas Anand Siripurapu <93654470+JignyasAnand@users.noreply.github.com> Date: Fri, 1 Dec 2023 20:24:26 +0530 Subject: [PATCH 104/201] FIX-#6781: Use `pandas.api.types.pandas_dtype` to convert to valid numpy and pandas only dtypes (#6788) Signed-off-by: JignyasAnand --- modin/core/dataframe/pandas/dataframe/dataframe.py | 3 ++- modin/pandas/test/dataframe/test_reduce.py | 10 +++++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/modin/core/dataframe/pandas/dataframe/dataframe.py b/modin/core/dataframe/pandas/dataframe/dataframe.py index 7676e5eae53..8140ab0c43d 100644 --- a/modin/core/dataframe/pandas/dataframe/dataframe.py +++ b/modin/core/dataframe/pandas/dataframe/dataframe.py @@ -1960,7 +1960,8 @@ def _compute_tree_reduce_metadata(self, axis, new_parts, dtypes=None): dtypes = self.copy_dtypes_cache() elif dtypes is not None: dtypes = pandas.Series( - [np.dtype(dtypes)] * len(new_axes[1]), index=new_axes[1] + [pandas.api.types.pandas_dtype(dtypes)] * len(new_axes[1]), + index=new_axes[1], ) result = self.__constructor__( diff --git a/modin/pandas/test/dataframe/test_reduce.py b/modin/pandas/test/dataframe/test_reduce.py index ceda40e4161..6edee442de6 100644 --- a/modin/pandas/test/dataframe/test_reduce.py +++ b/modin/pandas/test/dataframe/test_reduce.py @@ -18,7 +18,7 @@ from pandas._testing import assert_series_equal import modin.pandas as pd -from modin.config import NPartitions, StorageFormat +from modin.config import Engine, NPartitions, StorageFormat from modin.pandas.test.utils import ( arg_keys, assert_dtypes_equal, @@ -306,6 +306,14 @@ def test_sum(data, axis, skipna, is_transposed): df_equals(modin_result, pandas_result) +@pytest.mark.skipif(Engine.get() == "Native", reason="Fails on HDK") +@pytest.mark.parametrize("dtype", ["int64", "Int64"]) +def test_dtype_consistency(dtype): + # test for issue #6781 + res_dtype = pd.DataFrame([1, 2, 3, 4], dtype=dtype).sum().dtype + assert res_dtype == pandas.api.types.pandas_dtype(dtype) + + @pytest.mark.parametrize("fn", ["prod, sum"]) @pytest.mark.parametrize( "numeric_only", bool_arg_values, ids=arg_keys("numeric_only", bool_arg_keys) From b8202f127d79b20076126edce85dd63a59e4a677 Mon Sep 17 00:00:00 2001 From: Ari Brown Date: Fri, 1 Dec 2023 11:50:20 -0500 Subject: [PATCH 105/201] FIX-#6778: Read parquet files without file extensions using fastparquet (#6790) * FIX-#6778: Read parquet files without file extensions using fastparquet In supporting fastparquet, modin takes the paths provided, globs them, and filters them to only look at files with the .parq or .parquet extension. This commit adds support so that if the path supplied is explicitly a file, it will be included. Signed-off by: Ari Brown * FIX-#6778: Generalizes the check of whether something is a file Avoids the use of `os.path.isfile()` to use `self.fs.isfile()`. Signed-off-by: Ari Brown --------- Signed-off-by: Ari Brown --- .../io/column_stores/parquet_dispatcher.py | 32 +++++++++++++------ modin/pandas/test/test_io.py | 14 ++++++++ 2 files changed, 37 insertions(+), 9 deletions(-) diff --git a/modin/core/io/column_stores/parquet_dispatcher.py b/modin/core/io/column_stores/parquet_dispatcher.py index 6596ead087d..801474fef8b 100644 --- a/modin/core/io/column_stores/parquet_dispatcher.py +++ b/modin/core/io/column_stores/parquet_dispatcher.py @@ -285,19 +285,33 @@ def files(self): def to_pandas_dataframe(self, columns): return self.dataset.to_pandas(columns=columns) + # Karthik Velayutham writes: + # + # fastparquet doesn't have a nice method like PyArrow, so we + # have to copy some of their logic here while we work on getting + # an easier method to get a list of valid files. + # See: https://github.com/dask/fastparquet/issues/795 def _get_fastparquet_files(self): # noqa: GL08 - # fastparquet doesn't have a nice method like PyArrow, so we - # have to copy some of their logic here while we work on getting - # an easier method to get a list of valid files. - # See: https://github.com/dask/fastparquet/issues/795 if "*" in self.path: files = self.fs.glob(self.path) else: - files = [ - f - for f in self.fs.find(self.path) - if f.endswith(".parquet") or f.endswith(".parq") - ] + # (Resolving issue #6778) + # + # Users will pass in a directory to a delta table, which stores parquet + # files in various directories along with other, non-parquet files. We + # need to identify those parquet files and not the non-parquet files. + # + # However, we also need to support users passing in explicit files that + # don't necessarily have the `.parq` or `.parquet` extension -- if a user + # says that a file is parquet, then we should probably give it a shot. + if self.fs.isfile(self.path): + files = self.fs.find(self.path) + else: + files = [ + f + for f in self.fs.find(self.path) + if f.endswith(".parquet") or f.endswith(".parq") + ] return files diff --git a/modin/pandas/test/test_io.py b/modin/pandas/test/test_io.py index 672bd3a8003..da12c980a67 100644 --- a/modin/pandas/test/test_io.py +++ b/modin/pandas/test/test_io.py @@ -1464,6 +1464,20 @@ def comparator(df1, df2): comparator=comparator, ) + # Tests issue #6778 + def test_read_parquet_no_extension(self, engine, make_parquet_file): + with ensure_clean(".parquet") as unique_filename: + # Remove the .parquet extension + no_ext_fname = unique_filename[: unique_filename.index(".parquet")] + + make_parquet_file(filename=no_ext_fname) + eval_io( + fn_name="read_parquet", + # read_parquet kwargs + engine=engine, + path=no_ext_fname, + ) + @pytest.mark.parametrize( "filters", [None, [], [("col1", "==", 5)], [("col1", "<=", 215), ("col2", ">=", 35)]], From 10700cdc082bd62f088e1785d435a97c4fb232e7 Mon Sep 17 00:00:00 2001 From: Dmitry Chigarev Date: Mon, 4 Dec 2023 17:08:02 +0100 Subject: [PATCH 106/201] FIX-#6799: Allow creating incomplete 'ModinIndex' objects (#6800) Signed-off-by: Dmitry Chigarev --- modin/core/dataframe/algebra/groupby.py | 4 +- .../dataframe/pandas/dataframe/dataframe.py | 8 +++- modin/core/dataframe/pandas/metadata/index.py | 45 ++++++++++++++++--- .../storage_formats/pandas/query_compiler.py | 4 +- 4 files changed, 50 insertions(+), 11 deletions(-) diff --git a/modin/core/dataframe/algebra/groupby.py b/modin/core/dataframe/algebra/groupby.py index 6a9dc67b51a..0f97e2d7fcb 100644 --- a/modin/core/dataframe/algebra/groupby.py +++ b/modin/core/dataframe/algebra/groupby.py @@ -414,8 +414,8 @@ def caller( and broadcastable_by.has_materialized_dtypes ): new_index = ModinIndex( - # value can be anything here, as it will be reassigned on a parent update - value=query_compiler._modin_frame, + # actual value will be assigned on a parent update + value=None, axis=0, dtypes=broadcastable_by.dtypes, ) diff --git a/modin/core/dataframe/pandas/dataframe/dataframe.py b/modin/core/dataframe/pandas/dataframe/dataframe.py index 8140ab0c43d..bafde25e949 100644 --- a/modin/core/dataframe/pandas/dataframe/dataframe.py +++ b/modin/core/dataframe/pandas/dataframe/dataframe.py @@ -3243,7 +3243,9 @@ def broadcast_apply_full_axis( ) if not keep_partitioning: - if kw["row_lengths"] is None and new_index is not None: + if kw["row_lengths"] is None and ModinIndex.is_materialized_index( + new_index + ): if axis == 0: kw["row_lengths"] = get_length_list( axis_len=len(new_index), num_splits=new_partitions.shape[0] @@ -3255,7 +3257,9 @@ def broadcast_apply_full_axis( kw["row_lengths"] = self._row_lengths_cache elif len(new_index) == 1 and new_partitions.shape[0] == 1: kw["row_lengths"] = [1] - if kw["column_widths"] is None and new_columns is not None: + if kw["column_widths"] is None and ModinIndex.is_materialized_index( + new_columns + ): if axis == 1: kw["column_widths"] = get_length_list( axis_len=len(new_columns), diff --git a/modin/core/dataframe/pandas/metadata/index.py b/modin/core/dataframe/pandas/metadata/index.py index 52a6d536aaf..b378211fd77 100644 --- a/modin/core/dataframe/pandas/metadata/index.py +++ b/modin/core/dataframe/pandas/metadata/index.py @@ -17,6 +17,7 @@ import uuid import pandas +from pandas.core.dtypes.common import is_list_like from pandas.core.indexes.api import ensure_index @@ -26,12 +27,16 @@ class ModinIndex: Parameters ---------- - value : sequence, PandasDataframe or callable() -> (pandas.Index, list of ints) + value : sequence, PandasDataframe or callable() -> (pandas.Index, list of ints), optional If a sequence passed this will be considered as the index values. If a ``PandasDataframe`` passed then it will be used to lazily extract indices when required, note that the `axis` parameter must be passed in this case. If a callable passed then it's expected to return a pandas Index and a list of partition lengths along the index axis. + If ``None`` was passed, the index will be considered an incomplete and will raise + a ``RuntimeError`` on an attempt of materialization. To complete the index object + you have to use ``.maybe_specify_new_frame_ref()`` method. + axis : int, optional Specifies an axis the object represents, serves as an optional hint. This parameter must be passed in case value is a ``PandasDataframe``. @@ -39,7 +44,7 @@ class ModinIndex: Materialized dtypes of index levels. """ - def __init__(self, value, axis=None, dtypes=None): + def __init__(self, value=None, axis=None, dtypes=None): from modin.core.dataframe.pandas.dataframe.dataframe import PandasDataframe self._is_default_callable = False @@ -52,6 +57,9 @@ def __init__(self, value, axis=None, dtypes=None): assert axis is not None self._value = self._get_default_callable(value, axis) self._is_default_callable = True + elif value is None: + assert axis is not None + self._value = value else: self._value = ensure_index(value) @@ -128,7 +136,9 @@ def maybe_specify_new_frame_ref(self, value, axis) -> "ModinIndex": ModinIndex New ModinIndex with the reference updated. """ - if not callable(self._value) or not self._is_default_callable: + if self._value is not None and ( + not callable(self._value) or not self._is_default_callable + ): return self new_index = self.copy(copy_lengths=True) @@ -145,7 +155,28 @@ def is_materialized(self) -> bool: ------- bool """ - return isinstance(self._value, pandas.Index) + return self.is_materialized_index(self) + + @classmethod + def is_materialized_index(cls, index) -> bool: + """ + Check if the passed object represents a materialized index. + + Parameters + ---------- + index : object + An object to check. + + Returns + ------- + bool + """ + # importing here to avoid circular import issue + from modin.pandas.indexing import is_range_like + + if isinstance(index, cls): + index = index._value + return is_list_like(index) or is_range_like(index) or isinstance(index, slice) def get(self, return_lengths=False) -> pandas.Index: """ @@ -166,6 +197,10 @@ def get(self, return_lengths=False) -> pandas.Index: if callable(self._value): index, self._lengths_cache = self._value() self._value = ensure_index(index) + elif self._value is None: + raise RuntimeError( + "It's not allowed to call '.materialize()' before '._value' is specified." + ) else: raise NotImplementedError(type(self._value)) if return_lengths: @@ -328,7 +363,7 @@ def copy(self, copy_lengths=False) -> "ModinIndex": ModinIndex """ idx_cache = self._value - if not callable(idx_cache): + if idx_cache is not None and not callable(idx_cache): idx_cache = idx_cache.copy() result = ModinIndex(idx_cache, axis=self._axis, dtypes=self._dtypes) result._index_id = self._index_id diff --git a/modin/core/storage_formats/pandas/query_compiler.py b/modin/core/storage_formats/pandas/query_compiler.py index 80027ec105b..91ea6c08c0a 100644 --- a/modin/core/storage_formats/pandas/query_compiler.py +++ b/modin/core/storage_formats/pandas/query_compiler.py @@ -4164,8 +4164,8 @@ def compute_groupby(df, drop=False, partition_idx=0): and broadcastable_by[0].has_materialized_dtypes ): new_index = ModinIndex( - # value can be anything here, as it will be reassigned on a parent update - value=self._modin_frame, + # actual value will be assigned on a parent update + value=None, axis=0, dtypes=broadcastable_by[0].dtypes, ) From 275e32b43dbe64cbe8eef51034ce268194ef5e54 Mon Sep 17 00:00:00 2001 From: Anatoly Myachev Date: Mon, 4 Dec 2023 19:01:17 +0100 Subject: [PATCH 107/201] TEST-#6795: don't use platform-dependent 'int' type (#6796) Signed-off-by: Anatoly Myachev --- .../storage_formats/pandas/test_internals.py | 71 +++++++++++-------- 1 file changed, 40 insertions(+), 31 deletions(-) diff --git a/modin/test/storage_formats/pandas/test_internals.py b/modin/test/storage_formats/pandas/test_internals.py index c0e1bf90ec4..c638457dfa1 100644 --- a/modin/test/storage_formats/pandas/test_internals.py +++ b/modin/test/storage_formats/pandas/test_internals.py @@ -1524,7 +1524,7 @@ class TestModinDtypes: schema = pandas.Series( { - "a": np.dtype(int), + "a": np.dtype("int64"), "b": np.dtype(float), "c": np.dtype(bool), "d": np.dtype(bool), @@ -1959,17 +1959,17 @@ def test_concat_axis_1( def test_ModinDtypes_duplicated_concat(self): # test that 'ModinDtypes' is able to perform dtypes concatenation on duplicated labels # if all of them are Serieses - res = ModinDtypes.concat([pandas.Series([np.dtype(int)], index=["a"])] * 2) + res = ModinDtypes.concat([pandas.Series([np.dtype("int64")], index=["a"])] * 2) assert isinstance(res._value, pandas.Series) assert res._value.equals( - pandas.Series([np.dtype(int), np.dtype(int)], index=["a", "a"]) + pandas.Series([np.dtype("int64"), np.dtype("int64")], index=["a", "a"]) ) # test that 'ModinDtypes.concat' with duplicated labels raises when not all dtypes are materialized with pytest.raises(NotImplementedError): res = ModinDtypes.concat( [ - pandas.Series([np.dtype(int)], index=["a"]), + pandas.Series([np.dtype("int64")], index=["a"]), DtypesDescriptor(cols_with_unknown_dtypes=["a"]), ] ) @@ -2007,7 +2007,7 @@ def test_update_parent(self): [ [ DtypesDescriptor( - {"a": np.dtype(int), "b": np.dtype(float), "c": np.dtype(float)} + {"a": np.dtype("int64"), "b": np.dtype(float), "c": np.dtype(float)} ), DtypesDescriptor( cols_with_unknown_dtypes=["col1", "col2", "col3"], @@ -2016,12 +2016,16 @@ def test_update_parent(self): ], [ DtypesDescriptor( - {"a": np.dtype(int), "b": np.dtype(float), "c": np.dtype(float)}, + { + "a": np.dtype("int64"), + "b": np.dtype(float), + "c": np.dtype(float), + }, columns_order={0: "a", 1: "b", 2: "c"}, ), DtypesDescriptor( { - "col1": np.dtype(int), + "col1": np.dtype("int64"), "col2": np.dtype(float), "col3": np.dtype(float), }, @@ -2030,19 +2034,19 @@ def test_update_parent(self): ], [ DtypesDescriptor( - {"a": np.dtype(int), "b": np.dtype(float)}, + {"a": np.dtype("int64"), "b": np.dtype(float)}, cols_with_unknown_dtypes=["c"], columns_order={0: "a", 1: "b", 2: "c"}, ), DtypesDescriptor( - {"col1": np.dtype(int), "col2": np.dtype(float)}, + {"col1": np.dtype("int64"), "col2": np.dtype(float)}, cols_with_unknown_dtypes=["col3"], columns_order={0: "col1", 1: "col2", 2: "col3"}, ), ], [ DtypesDescriptor( - {"a": np.dtype(int)}, + {"a": np.dtype("int64")}, cols_with_unknown_dtypes=["c"], know_all_names=False, ), @@ -2052,7 +2056,9 @@ def test_update_parent(self): ), ], [ - DtypesDescriptor({"a": np.dtype(int)}, remaining_dtype=np.dtype(float)), + DtypesDescriptor( + {"a": np.dtype("int64")}, remaining_dtype=np.dtype(float) + ), DtypesDescriptor( cols_with_unknown_dtypes=["col1", "col2", "col3"], columns_order={0: "col1", 1: "col2", 2: "col3"}, @@ -2060,21 +2066,21 @@ def test_update_parent(self): ], [ lambda: pandas.Series( - [np.dtype(int), np.dtype(float), np.dtype(float)], + [np.dtype("int64"), np.dtype(float), np.dtype(float)], index=["a", "b", "c"], ), lambda: pandas.Series( - [np.dtype(int), np.dtype(float), np.dtype(float)], + [np.dtype("int64"), np.dtype(float), np.dtype(float)], index=["col1", "col2", "col3"], ), ], [ pandas.Series( - [np.dtype(int), np.dtype(float), np.dtype(float)], + [np.dtype("int64"), np.dtype(float), np.dtype(float)], index=["a", "b", "c"], ), pandas.Series( - [np.dtype(int), np.dtype(float), np.dtype(float)], + [np.dtype("int64"), np.dtype(float), np.dtype(float)], index=["col1", "col2", "col3"], ), ], @@ -2128,7 +2134,8 @@ def test_preserve_dtypes_setitem(self, self_dtype, value, value_dtype): df._query_compiler._modin_frame.set_dtypes_cache( ModinDtypes( DtypesDescriptor( - {"a": np.dtype(int)}, cols_with_unknown_dtypes=["b", "c"] + {"a": np.dtype("int64")}, + cols_with_unknown_dtypes=["b", "c"], ) ) ) @@ -2141,13 +2148,14 @@ def test_preserve_dtypes_setitem(self, self_dtype, value, value_dtype): if self_dtype == "materialized": result_dtype = pandas.Series( - [np.dtype(int), value_dtype, np.dtype(int)], index=["a", "b", "c"] + [np.dtype("int64"), value_dtype, np.dtype("int64")], + index=["a", "b", "c"], ) assert df._query_compiler._modin_frame.has_materialized_dtypes assert df.dtypes.equals(result_dtype) elif self_dtype == "partial": result_dtype = DtypesDescriptor( - {"a": np.dtype(int), "b": value_dtype}, + {"a": np.dtype("int64"), "b": value_dtype}, cols_with_unknown_dtypes=["c"], columns_order={0: "a", 1: "b", 2: "c"}, ) @@ -2183,7 +2191,7 @@ def test_preserve_dtypes_insert(self, self_dtype, value, value_dtype): df._query_compiler._modin_frame.set_dtypes_cache( ModinDtypes( DtypesDescriptor( - {"a": np.dtype(int)}, cols_with_unknown_dtypes=["b"] + {"a": np.dtype("int64")}, cols_with_unknown_dtypes=["b"] ) ) ) @@ -2196,13 +2204,14 @@ def test_preserve_dtypes_insert(self, self_dtype, value, value_dtype): if self_dtype == "materialized": result_dtype = pandas.Series( - [value_dtype, np.dtype(int), np.dtype(int)], index=["c", "a", "b"] + [value_dtype, np.dtype("int64"), np.dtype("int64")], + index=["c", "a", "b"], ) assert df._query_compiler._modin_frame.has_materialized_dtypes assert df.dtypes.equals(result_dtype) elif self_dtype == "partial": result_dtype = DtypesDescriptor( - {"a": np.dtype(int), "c": value_dtype}, + {"a": np.dtype("int64"), "c": value_dtype}, cols_with_unknown_dtypes=["b"], columns_order={0: "c", 1: "a", 2: "b"}, ) @@ -2258,14 +2267,14 @@ def test_preserve_dtypes_reset_index(self, drop, has_materialized_index): assert res._query_compiler._modin_frame.has_materialized_dtypes assert res.dtypes.equals( pandas.Series( - [np.dtype(int), np.dtype(int)], index=["index", "a"] + [np.dtype("int64"), np.dtype("int64")], index=["index", "a"] ) ) else: # we now know that there are cols with unknown name and dtype in our dataframe, # so the resulting dtypes should contain information only about original column expected_dtypes = DtypesDescriptor( - {"a": np.dtype(int)}, + {"a": np.dtype("int64")}, know_all_names=False, ) assert res._query_compiler._modin_frame._dtypes._value.equals( @@ -2277,7 +2286,7 @@ def test_preserve_dtypes_reset_index(self, drop, has_materialized_index): df._query_compiler._modin_frame.set_dtypes_cache( ModinDtypes( DtypesDescriptor( - {"a": np.dtype(int)}, cols_with_unknown_dtypes=["b"] + {"a": np.dtype("int64")}, cols_with_unknown_dtypes=["b"] ) ) ) @@ -2299,7 +2308,7 @@ def test_preserve_dtypes_reset_index(self, drop, has_materialized_index): # the resulted dtype should have information about 'index' and 'a' columns, # and miss dtype info for 'b' column expected_dtypes = DtypesDescriptor( - {"index": np.dtype(int), "a": np.dtype(int)}, + {"index": np.dtype("int64"), "a": np.dtype("int64")}, cols_with_unknown_dtypes=["b"], columns_order={0: "index", 1: "a", 2: "b"}, ) @@ -2310,7 +2319,7 @@ def test_preserve_dtypes_reset_index(self, drop, has_materialized_index): # we miss info about the 'index' column since it wasn't materialized at # the time of 'reset_index()' and we're still missing dtype info for 'b' column expected_dtypes = DtypesDescriptor( - {"a": np.dtype(int)}, + {"a": np.dtype("int64")}, cols_with_unknown_dtypes=["b"], know_all_names=False, ) @@ -2327,7 +2336,7 @@ def test_groupby_index_dtype(self): res = df.groupby("a").size().reset_index(name="new_name") res_dtypes = res._query_compiler._modin_frame._dtypes._value assert "a" in res_dtypes._known_dtypes - assert res_dtypes._known_dtypes["a"] == np.dtype(int) + assert res_dtypes._known_dtypes["a"] == np.dtype("int64") # case 2: ExperimentalImpl impl, Series as an output of groupby ExperimentalGroupbyImpl.put(True) @@ -2336,7 +2345,7 @@ def test_groupby_index_dtype(self): res = df.groupby("a").size().reset_index(name="new_name") res_dtypes = res._query_compiler._modin_frame._dtypes._value assert "a" in res_dtypes._known_dtypes - assert res_dtypes._known_dtypes["a"] == np.dtype(int) + assert res_dtypes._known_dtypes["a"] == np.dtype("int64") finally: ExperimentalGroupbyImpl.put(False) @@ -2345,7 +2354,7 @@ def test_groupby_index_dtype(self): res = df.groupby("a").sum().reset_index() res_dtypes = res._query_compiler._modin_frame._dtypes._value assert "a" in res_dtypes._known_dtypes - assert res_dtypes._known_dtypes["a"] == np.dtype(int) + assert res_dtypes._known_dtypes["a"] == np.dtype("int64") # case 4: ExperimentalImpl impl, DataFrame as an output of groupby ExperimentalGroupbyImpl.put(True) @@ -2354,7 +2363,7 @@ def test_groupby_index_dtype(self): res = df.groupby("a").sum().reset_index() res_dtypes = res._query_compiler._modin_frame._dtypes._value assert "a" in res_dtypes._known_dtypes - assert res_dtypes._known_dtypes["a"] == np.dtype(int) + assert res_dtypes._known_dtypes["a"] == np.dtype("int64") finally: ExperimentalGroupbyImpl.put(False) @@ -2363,6 +2372,6 @@ def test_groupby_index_dtype(self): res = df.groupby("a").quantile().reset_index() res_dtypes = res._query_compiler._modin_frame._dtypes._value assert "a" in res_dtypes._known_dtypes - assert res_dtypes._known_dtypes["a"] == np.dtype(int) + assert res_dtypes._known_dtypes["a"] == np.dtype("int64") patch.assert_not_called() From e6e1a23ee5dccbfdf49dc6f3fe6fcc6f5129176f Mon Sep 17 00:00:00 2001 From: Jignyas Anand Siripurapu <93654470+JignyasAnand@users.noreply.github.com> Date: Tue, 5 Dec 2023 04:50:19 +0530 Subject: [PATCH 108/201] FIX-#6774: Modify conditions for `loc` to get similar behavior to pandas (#6798) Signed-off-by: JignyasAnand --- modin/pandas/indexing.py | 6 ++--- modin/pandas/test/dataframe/test_indexing.py | 25 ++++++++++++++++++++ 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/modin/pandas/indexing.py b/modin/pandas/indexing.py index fc36b84df12..f6fbcb7833c 100644 --- a/modin/pandas/indexing.py +++ b/modin/pandas/indexing.py @@ -813,7 +813,7 @@ def _setitem_with_new_columns(self, row_loc, col_loc, item): ---------- row_loc : scalar, slice, list, array or tuple Row locator. - col_loc : scalar, slice, list, array or tuple + col_loc : list, array or tuple Columns locator. item : modin.pandas.DataFrame, modin.pandas.Series or scalar Value that should be assigned to located dataset. @@ -821,12 +821,12 @@ def _setitem_with_new_columns(self, row_loc, col_loc, item): if is_list_like(item) and not isinstance(item, (DataFrame, Series)): item = np.array(item) if len(item.shape) == 1: - if item.shape[0] != len(col_loc): + if len(col_loc) != 1: raise ValueError( "Must have equal len keys and value when setting with an iterable" ) else: - if item.shape != (len(row_loc), len(col_loc)): + if item.shape[-1] != len(col_loc): raise ValueError( "Must have equal len keys and value when setting with an iterable" ) diff --git a/modin/pandas/test/dataframe/test_indexing.py b/modin/pandas/test/dataframe/test_indexing.py index 642766922f1..e73d52eabd0 100644 --- a/modin/pandas/test/dataframe/test_indexing.py +++ b/modin/pandas/test/dataframe/test_indexing.py @@ -515,6 +515,31 @@ def test_loc_4456( eval_loc(modin_df, pandas_df, (mdf_value, pdf_value), key) +def test_loc_6774(): + modin_df, pandas_df = create_test_dfs( + {"a": [1, 2, 3, 4, 5], "b": [10, 20, 30, 40, 50]} + ) + pandas_df.loc[:, "c"] = [10, 20, 30, 40, 51] + modin_df.loc[:, "c"] = [10, 20, 30, 40, 51] + df_equals(modin_df, pandas_df) + + pandas_df.loc[2:, "y"] = [30, 40, 51] + modin_df.loc[2:, "y"] = [30, 40, 51] + df_equals(modin_df, pandas_df) + + pandas_df.loc[:, ["b", "c", "d"]] = ( + pd.DataFrame([[10, 20, 30, 40, 50], [10, 20, 30, 40], [10, 20, 30]]) + .transpose() + .values + ) + modin_df.loc[:, ["b", "c", "d"]] = ( + pd.DataFrame([[10, 20, 30, 40, 50], [10, 20, 30, 40], [10, 20, 30]]) + .transpose() + .values + ) + df_equals(modin_df, pandas_df) + + def test_loc_5829(): data = {"a": [1, 2, 3, 4, 5], "b": [11, 12, 13, 14, 15]} modin_df = pd.DataFrame(data, dtype=object) From 684395454a7de655357732c7dadd50470482f4af Mon Sep 17 00:00:00 2001 From: Anatoly Myachev Date: Tue, 5 Dec 2023 17:15:44 +0100 Subject: [PATCH 109/201] PERF-#4804: Preserve lengths/widths caches in `broadcast_apply_full_axis` (#6760) Signed-off-by: Anatoly Myachev Co-authored-by: Dmitry Chigarev --- .../dataframe/pandas/dataframe/dataframe.py | 21 ++++++ .../storage_formats/pandas/test_internals.py | 65 +++++++++++++++++++ 2 files changed, 86 insertions(+) diff --git a/modin/core/dataframe/pandas/dataframe/dataframe.py b/modin/core/dataframe/pandas/dataframe/dataframe.py index bafde25e949..e9ff924f883 100644 --- a/modin/core/dataframe/pandas/dataframe/dataframe.py +++ b/modin/core/dataframe/pandas/dataframe/dataframe.py @@ -3272,6 +3272,27 @@ def broadcast_apply_full_axis( kw["column_widths"] = self._column_widths_cache elif len(new_columns) == 1 and new_partitions.shape[1] == 1: kw["column_widths"] = [1] + else: + if ( + axis == 0 + and kw["row_lengths"] is None + and self._row_lengths_cache is not None + and ModinIndex.is_materialized_index(new_index) + and len(new_index) == sum(self._row_lengths_cache) + # to avoid problems that may arise when filtering empty dataframes + and all(r != 0 for r in self._row_lengths_cache) + ): + kw["row_lengths"] = self._row_lengths_cache + if ( + axis == 1 + and kw["column_widths"] is None + and self._column_widths_cache is not None + and ModinIndex.is_materialized_index(new_columns) + and len(new_columns) == sum(self._column_widths_cache) + # to avoid problems that may arise when filtering empty dataframes + and all(w != 0 for w in self._column_widths_cache) + ): + kw["column_widths"] = self._column_widths_cache result = self.__constructor__( new_partitions, index=new_index, columns=new_columns, **kw diff --git a/modin/test/storage_formats/pandas/test_internals.py b/modin/test/storage_formats/pandas/test_internals.py index c638457dfa1..c0eb02ca971 100644 --- a/modin/test/storage_formats/pandas/test_internals.py +++ b/modin/test/storage_formats/pandas/test_internals.py @@ -1398,6 +1398,71 @@ def test_sort_values_cache(): validate_partitions_cache(mf_initial, axis=1) +def test_apply_full_axis_preserve_widths(): + md_df = construct_modin_df_by_scheme( + pandas.DataFrame( + {"a": [1, 2, 3, 4], "b": [3, 4, 5, 6], "c": [6, 7, 8, 9], "d": [0, 1, 2, 3]} + ), + {"row_lengths": [2, 2], "column_widths": [2, 2]}, + )._query_compiler._modin_frame + + assert md_df._row_lengths_cache == [2, 2] + assert md_df._column_widths_cache == [2, 2] + + def func(df): + if df.iloc[0, 0] == 1: + return pandas.DataFrame( + {"a": [1, 2, 3], "b": [3, 4, 5], "c": [6, 7, 8], "d": [0, 1, 2]} + ) + else: + return pandas.DataFrame({"a": [4], "b": [6], "c": [9], "d": [3]}) + + res = md_df.apply_full_axis( + func=func, + axis=1, + new_index=[0, 1, 2, 3], + new_columns=["a", "b", "c", "d"], + keep_partitioning=True, + ) + col_widths_cache = res._column_widths_cache + actual_column_widths = [part.width() for part in res._partitions[0]] + + assert col_widths_cache == actual_column_widths + assert res._row_lengths_cache is None + + +def test_apply_full_axis_preserve_lengths(): + md_df = construct_modin_df_by_scheme( + pandas.DataFrame( + {"a": [1, 2, 3, 4], "b": [3, 4, 5, 6], "c": [6, 7, 8, 9], "d": [0, 1, 2, 3]} + ), + {"row_lengths": [2, 2], "column_widths": [2, 2]}, + )._query_compiler._modin_frame + + assert md_df._row_lengths_cache == [2, 2] + assert md_df._column_widths_cache == [2, 2] + + def func(df): + if df.iloc[0, 0] == 1: + return pandas.DataFrame({"a": [3, 2, 3, 4], "b": [3, 4, 5, 6]}) + else: + return pandas.DataFrame({"c": [9, 5, 6, 7]}) + + res = md_df.apply_full_axis( + func=func, + axis=0, + new_index=[0, 1, 2, 3], + new_columns=["a", "b", "c"], + keep_partitioning=True, + ) + + row_lengths_cache = res._row_lengths_cache + actual_row_lengths = [part.length() for part in res._partitions[:, 0]] + + assert row_lengths_cache == actual_row_lengths + assert res._column_widths_cache is None + + class DummyFuture: """ A dummy object emulating future's behaviour, this class is used in ``test_call_queue_serialization``. From a4052173b783628d0de1dd37b185a9f3ad066fde Mon Sep 17 00:00:00 2001 From: Dmitry Chigarev Date: Wed, 6 Dec 2023 18:48:35 +0100 Subject: [PATCH 110/201] FEAT-#6803: Enable range-partitioning impl for 'groupby.apply()' by default (#6804) Signed-off-by: Dmitry Chigarev --- .../dataframe/pandas/dataframe/dataframe.py | 8 ------ .../storage_formats/pandas/query_compiler.py | 25 +++++++++++++------ 2 files changed, 17 insertions(+), 16 deletions(-) diff --git a/modin/core/dataframe/pandas/dataframe/dataframe.py b/modin/core/dataframe/pandas/dataframe/dataframe.py index e9ff924f883..74ab2a5edd8 100644 --- a/modin/core/dataframe/pandas/dataframe/dataframe.py +++ b/modin/core/dataframe/pandas/dataframe/dataframe.py @@ -3735,14 +3735,6 @@ def groupby( skip_on_aligning_flag = "__skip_me_on_aligning__" def apply_func(df): # pragma: no cover - if any( - isinstance(dtype, pandas.CategoricalDtype) - for dtype in df.dtypes[by].values - ): - raise NotImplementedError( - "Reshuffling groupby is not yet supported when grouping on a categorical column. " - + "https://github.com/modin-project/modin/issues/5925" - ) result = operator(df.groupby(by, **kwargs)) if ( align_result_columns diff --git a/modin/core/storage_formats/pandas/query_compiler.py b/modin/core/storage_formats/pandas/query_compiler.py index 91ea6c08c0a..4f2f957bb58 100644 --- a/modin/core/storage_formats/pandas/query_compiler.py +++ b/modin/core/storage_formats/pandas/query_compiler.py @@ -66,6 +66,7 @@ ) from modin.core.storage_formats.base.query_compiler import BaseQueryCompiler from modin.error_message import ErrorMessage +from modin.logging import get_logger from modin.utils import ( MODIN_UNNAMED_SERIES_LABEL, _inherit_docstrings, @@ -3782,12 +3783,12 @@ def _groupby_shuffle( + "https://github.com/modin-project/modin/issues/5926" ) - # So this check works only if we have dtypes cache materialized, otherwise the exception will be thrown - # inside the kernel and so it will be uncatchable. TODO: figure out a better way to handle this. - if self._modin_frame._dtypes is not None and any( - isinstance(dtype, pandas.CategoricalDtype) - for dtype in self.dtypes[by].values - ): + # This check materializes dtypes for 'by' columns + if isinstance(self._modin_frame._dtypes, ModinDtypes): + by_dtypes = self._modin_frame._dtypes.lazy_get(by).get() + else: + by_dtypes = self.dtypes[by] + if any(isinstance(dtype, pandas.CategoricalDtype) for dtype in by_dtypes): raise NotImplementedError( "Reshuffling groupby is not yet supported when grouping on a categorical column. " + "https://github.com/modin-project/modin/issues/5925" @@ -3960,7 +3961,10 @@ def groupby_agg( by, agg_func, axis, groupby_kwargs, agg_args, agg_kwargs, how, drop ) - if ExperimentalGroupbyImpl.get(): + # 'group_wise' means 'groupby.apply()'. We're certain that range-partitioning groupby + # always works better for '.apply()', so we're using it regardless of the 'ExperimentalGroupbyImpl' + # value + if how == "group_wise" or ExperimentalGroupbyImpl.get(): try: return self._groupby_shuffle( by=by, @@ -3973,10 +3977,15 @@ def groupby_agg( how=how, ) except NotImplementedError as e: - ErrorMessage.warn( + # if a user wants to use range-partitioning groupby explicitly, then we should print a visible + # warning to them on a failure, otherwise we're only logging it + message = ( f"Can't use experimental reshuffling groupby implementation because of: {e}" + "\nFalling back to a full-axis implementation." ) + get_logger().info(message) + if ExperimentalGroupbyImpl.get(): + ErrorMessage.warn(message) if isinstance(agg_func, dict) and GroupbyReduceImpl.has_impl_for(agg_func): return self._groupby_dict_reduce( From 4b090f22a8ada099641599b3bf4d49fb6adfe10f Mon Sep 17 00:00:00 2001 From: Dmitry Chigarev Date: Thu, 7 Dec 2023 18:12:58 +0100 Subject: [PATCH 111/201] REFACTOR-#6805: Move all IO functions to 'modin.pandas.io' module (#6806) Signed-off-by: Dmitry Chigarev --- .../scalability/scalability_benchmarks.py | 15 +- .../hdk_on_native/test/test_dataframe.py | 2 +- modin/pandas/dataframe.py | 4 +- modin/pandas/general.py | 3 +- modin/pandas/io.py | 274 ++++++++++++++---- modin/pandas/plotting.py | 3 +- modin/pandas/series.py | 5 +- modin/pandas/test/dataframe/test_default.py | 3 +- modin/pandas/test/dataframe/test_join_sort.py | 2 +- .../test/dataframe/test_map_metadata.py | 2 +- modin/pandas/test/test_general.py | 3 +- modin/pandas/test/test_groupby.py | 3 +- modin/pandas/test/test_io.py | 3 +- modin/pandas/test/test_series.py | 3 +- modin/pandas/test/utils.py | 3 +- modin/pandas/utils.py | 120 ++------ .../dataframe_protocol/base/test_sanity.py | 2 +- .../dataframe_protocol/hdk/test_protocol.py | 2 +- modin/utils.py | 88 ++++-- 19 files changed, 342 insertions(+), 198 deletions(-) diff --git a/asv_bench/benchmarks/scalability/scalability_benchmarks.py b/asv_bench/benchmarks/scalability/scalability_benchmarks.py index 740dcde5272..b4763af1575 100644 --- a/asv_bench/benchmarks/scalability/scalability_benchmarks.py +++ b/asv_bench/benchmarks/scalability/scalability_benchmarks.py @@ -14,13 +14,20 @@ """These benchmarks are supposed to be run only for modin, since they do not make sense for pandas.""" import modin.pandas as pd -from modin.pandas.utils import from_pandas try: - from modin.utils import to_numpy, to_pandas + from modin.pandas.io import from_pandas except ImportError: - # This provides compatibility with older versions of the Modin, allowing us to test old commits. - from modin.pandas.utils import to_pandas + from modin.pandas.utils import from_pandas + +try: + from modin.pandas.io import to_numpy, to_pandas +except ImportError: + try: + from modin.utils import to_numpy, to_pandas + except ImportError: + # This provides compatibility with older versions of the Modin, allowing us to test old commits. + from modin.pandas.utils import to_pandas import pandas diff --git a/modin/experimental/core/execution/native/implementations/hdk_on_native/test/test_dataframe.py b/modin/experimental/core/execution/native/implementations/hdk_on_native/test/test_dataframe.py index 131f828fa32..9b9b6166822 100644 --- a/modin/experimental/core/execution/native/implementations/hdk_on_native/test/test_dataframe.py +++ b/modin/experimental/core/execution/native/implementations/hdk_on_native/test/test_dataframe.py @@ -45,6 +45,7 @@ from modin.experimental.core.execution.native.implementations.hdk_on_native.partitioning.partition_manager import ( HdkOnNativeDataframePartitionManager, ) +from modin.pandas.io import from_arrow from modin.pandas.test.utils import ( bool_arg_values, df_equals, @@ -56,7 +57,6 @@ time_parsing_csv_path, to_pandas, ) -from modin.pandas.utils import from_arrow from modin.utils import try_cast_to_pandas # Our configuration in pytest.ini requires that we explicitly catch all diff --git a/modin/pandas/dataframe.py b/modin/pandas/dataframe.py index 97df23e9704..28c6cd543a9 100644 --- a/modin/pandas/dataframe.py +++ b/modin/pandas/dataframe.py @@ -44,12 +44,12 @@ from modin.error_message import ErrorMessage from modin.logging import disable_logging from modin.pandas import Categorical +from modin.pandas.io import from_non_pandas, from_pandas, to_pandas from modin.utils import ( MODIN_UNNAMED_SERIES_LABEL, _inherit_docstrings, expanduser_path_arg, hashable, - to_pandas, try_cast_to_pandas, ) @@ -62,8 +62,6 @@ SET_DATAFRAME_ATTRIBUTE_WARNING, _doc_binary_op, cast_function_modin2pandas, - from_non_pandas, - from_pandas, ) diff --git a/modin/pandas/general.py b/modin/pandas/general.py index 3fbe8ea2e27..47ec30ddaa3 100644 --- a/modin/pandas/general.py +++ b/modin/pandas/general.py @@ -25,7 +25,8 @@ from modin.core.storage_formats.base.query_compiler import BaseQueryCompiler from modin.error_message import ErrorMessage from modin.logging import enable_logging -from modin.utils import _inherit_docstrings, to_pandas +from modin.pandas.io import to_pandas +from modin.utils import _inherit_docstrings from .base import BasePandasDataset from .dataframe import DataFrame diff --git a/modin/pandas/io.py b/modin/pandas/io.py index 4ef37b4e485..76d7416e027 100644 --- a/modin/pandas/io.py +++ b/modin/pandas/io.py @@ -28,6 +28,7 @@ from collections import OrderedDict from typing import ( IO, + TYPE_CHECKING, Any, AnyStr, Callable, @@ -43,6 +44,7 @@ Union, ) +import numpy as np import pandas from pandas._libs.lib import NoDefault, no_default from pandas._typing import ( @@ -63,12 +65,37 @@ from pandas.io.parsers import TextFileReader from pandas.io.parsers.readers import _c_parser_defaults +from modin.config import ExperimentalNumPyAPI from modin.error_message import ErrorMessage from modin.logging import ClassLogger, enable_logging -from modin.utils import _inherit_docstrings, expanduser_path_arg +from modin.utils import ( + SupportsPrivateToNumPy, + SupportsPrivateToPandas, + SupportsPublicToNumPy, + _inherit_docstrings, + classproperty, + expanduser_path_arg, +) + +# below logic is to handle circular imports without errors +if TYPE_CHECKING: + from .dataframe import DataFrame + from .series import Series + + +class ModinObjects: + """Lazily import Modin classes and provide an access to them.""" + + _dataframe = None + + @classproperty + def DataFrame(cls): + """Get ``modin.pandas.DataFrame`` class.""" + if cls._dataframe is None: + from .dataframe import DataFrame -from .dataframe import DataFrame -from .series import Series + cls._dataframe = DataFrame + return cls._dataframe def _read(**kwargs): @@ -91,11 +118,11 @@ def _read(**kwargs): # This happens when `read_csv` returns a TextFileReader object for iterating through if isinstance(pd_obj, TextFileReader): reader = pd_obj.read - pd_obj.read = lambda *args, **kwargs: DataFrame( + pd_obj.read = lambda *args, **kwargs: ModinObjects.DataFrame( query_compiler=reader(*args, **kwargs) ) return pd_obj - result = DataFrame(query_compiler=pd_obj) + result = ModinObjects.DataFrame(query_compiler=pd_obj) if squeeze: return result.squeeze(axis=1) return result @@ -122,10 +149,10 @@ def read_xml( compression: CompressionOptions = "infer", storage_options: StorageOptions = None, dtype_backend: Union[DtypeBackend, NoDefault] = no_default, -) -> DataFrame: +) -> "DataFrame": ErrorMessage.default_to_pandas("read_xml") _, _, _, kwargs = inspect.getargvalues(inspect.currentframe()) - return DataFrame(pandas.read_xml(**kwargs)) + return ModinObjects.DataFrame(pandas.read_xml(**kwargs)) @_inherit_docstrings(pandas.read_csv, apilink="pandas.read_csv") @@ -190,7 +217,7 @@ def read_csv( float_precision: Literal["high", "legacy"] | None = None, storage_options: StorageOptions = None, dtype_backend: Union[DtypeBackend, NoDefault] = no_default, -) -> DataFrame | TextFileReader: +) -> "DataFrame" | TextFileReader: # ISSUE #2408: parse parameter shared with pandas read_csv and read_table and update with provided args _pd_read_csv_signature = { val.name for val in inspect.signature(pandas.read_csv).parameters.values() @@ -262,7 +289,7 @@ def read_table( float_precision: str | None = None, storage_options: StorageOptions = None, dtype_backend: Union[DtypeBackend, NoDefault] = no_default, -) -> DataFrame | TextFileReader: +) -> "DataFrame" | TextFileReader: # ISSUE #2408: parse parameter shared with pandas read_csv and read_table and update with provided args _pd_read_table_signature = { val.name for val in inspect.signature(pandas.read_table).parameters.values() @@ -287,7 +314,7 @@ def read_parquet( filesystem=None, filters=None, **kwargs, -) -> DataFrame: +) -> "DataFrame": from modin.core.execution.dispatching.factories.dispatcher import FactoryDispatcher if engine == "fastparquet" and dtype_backend is not no_default: @@ -295,7 +322,7 @@ def read_parquet( "The 'dtype_backend' argument is not supported for the fastparquet engine" ) - return DataFrame( + return ModinObjects.DataFrame( query_compiler=FactoryDispatcher.read_parquet( path=path, engine=engine, @@ -333,12 +360,12 @@ def read_json( storage_options: StorageOptions = None, dtype_backend: Union[DtypeBackend, NoDefault] = no_default, engine="ujson", -) -> DataFrame | Series | pandas.io.json._json.JsonReader: +) -> "DataFrame" | "Series" | pandas.io.json._json.JsonReader: _, _, _, kwargs = inspect.getargvalues(inspect.currentframe()) from modin.core.execution.dispatching.factories.dispatcher import FactoryDispatcher - return DataFrame(query_compiler=FactoryDispatcher.read_json(**kwargs)) + return ModinObjects.DataFrame(query_compiler=FactoryDispatcher.read_json(**kwargs)) @_inherit_docstrings(pandas.read_gbq, apilink="pandas.read_gbq") @@ -357,13 +384,13 @@ def read_gbq( use_bqstorage_api: bool | None = None, max_results: int | None = None, progress_bar_type: str | None = None, -) -> DataFrame: +) -> "DataFrame": _, _, _, kwargs = inspect.getargvalues(inspect.currentframe()) kwargs.update(kwargs.pop("kwargs", {})) from modin.core.execution.dispatching.factories.dispatcher import FactoryDispatcher - return DataFrame(query_compiler=FactoryDispatcher.read_gbq(**kwargs)) + return ModinObjects.DataFrame(query_compiler=FactoryDispatcher.read_gbq(**kwargs)) @_inherit_docstrings(pandas.read_html, apilink="pandas.read_html") @@ -389,7 +416,7 @@ def read_html( extract_links: Literal[None, "header", "footer", "body", "all"] = None, dtype_backend: Union[DtypeBackend, NoDefault] = no_default, storage_options: StorageOptions = None, -) -> list[DataFrame]: # noqa: PR01, RT01, D200 +) -> list["DataFrame"]: # noqa: PR01, RT01, D200 """ Read HTML tables into a ``DataFrame`` object. """ @@ -398,7 +425,7 @@ def read_html( from modin.core.execution.dispatching.factories.dispatcher import FactoryDispatcher qcs = FactoryDispatcher.read_html(**kwargs) - return [DataFrame(query_compiler=qc) for qc in qcs] + return [ModinObjects.DataFrame(query_compiler=qc) for qc in qcs] @_inherit_docstrings(pandas.read_clipboard, apilink="pandas.read_clipboard") @@ -416,7 +443,9 @@ def read_clipboard( from modin.core.execution.dispatching.factories.dispatcher import FactoryDispatcher - return DataFrame(query_compiler=FactoryDispatcher.read_clipboard(**kwargs)) + return ModinObjects.DataFrame( + query_compiler=FactoryDispatcher.read_clipboard(**kwargs) + ) @_inherit_docstrings(pandas.read_excel, apilink="pandas.read_excel") @@ -456,7 +485,7 @@ def read_excel( storage_options: StorageOptions = None, dtype_backend: Union[DtypeBackend, NoDefault] = no_default, engine_kwargs: Optional[dict] = None, -) -> DataFrame | dict[IntStrT, DataFrame]: +) -> "DataFrame" | dict[IntStrT, "DataFrame"]: _, _, _, kwargs = inspect.getargvalues(inspect.currentframe()) from modin.core.execution.dispatching.factories.dispatcher import FactoryDispatcher @@ -465,10 +494,10 @@ def read_excel( if isinstance(intermediate, (OrderedDict, dict)): parsed = type(intermediate)() for key in intermediate.keys(): - parsed[key] = DataFrame(query_compiler=intermediate.get(key)) + parsed[key] = ModinObjects.DataFrame(query_compiler=intermediate.get(key)) return parsed else: - return DataFrame(query_compiler=intermediate) + return ModinObjects.DataFrame(query_compiler=intermediate) @_inherit_docstrings(pandas.read_hdf, apilink="pandas.read_hdf") @@ -495,7 +524,7 @@ def read_hdf( from modin.core.execution.dispatching.factories.dispatcher import FactoryDispatcher - return DataFrame(query_compiler=FactoryDispatcher.read_hdf(**kwargs)) + return ModinObjects.DataFrame(query_compiler=FactoryDispatcher.read_hdf(**kwargs)) @_inherit_docstrings(pandas.read_feather, apilink="pandas.read_feather") @@ -512,7 +541,9 @@ def read_feather( from modin.core.execution.dispatching.factories.dispatcher import FactoryDispatcher - return DataFrame(query_compiler=FactoryDispatcher.read_feather(**kwargs)) + return ModinObjects.DataFrame( + query_compiler=FactoryDispatcher.read_feather(**kwargs) + ) @_inherit_docstrings(pandas.read_stata) @@ -532,12 +563,12 @@ def read_stata( iterator: bool = False, compression: CompressionOptions = "infer", storage_options: StorageOptions = None, -) -> DataFrame | pandas.io.stata.StataReader: +) -> "DataFrame" | pandas.io.stata.StataReader: _, _, _, kwargs = inspect.getargvalues(inspect.currentframe()) from modin.core.execution.dispatching.factories.dispatcher import FactoryDispatcher - return DataFrame(query_compiler=FactoryDispatcher.read_stata(**kwargs)) + return ModinObjects.DataFrame(query_compiler=FactoryDispatcher.read_stata(**kwargs)) @_inherit_docstrings(pandas.read_sas, apilink="pandas.read_sas") @@ -552,13 +583,13 @@ def read_sas( chunksize: int | None = None, iterator: bool = False, compression: CompressionOptions = "infer", -) -> DataFrame | pandas.io.sas.sasreader.ReaderBase: # noqa: PR01, RT01, D200 +) -> "DataFrame" | pandas.io.sas.sasreader.ReaderBase: # noqa: PR01, RT01, D200 """ Read SAS files stored as either XPORT or SAS7BDAT format files. """ from modin.core.execution.dispatching.factories.dispatcher import FactoryDispatcher - return DataFrame( + return ModinObjects.DataFrame( query_compiler=FactoryDispatcher.read_sas( filepath_or_buffer=filepath_or_buffer, format=format, @@ -583,7 +614,9 @@ def read_pickle( from modin.core.execution.dispatching.factories.dispatcher import FactoryDispatcher - return DataFrame(query_compiler=FactoryDispatcher.read_pickle(**kwargs)) + return ModinObjects.DataFrame( + query_compiler=FactoryDispatcher.read_pickle(**kwargs) + ) @_inherit_docstrings(pandas.read_sql, apilink="pandas.read_sql") @@ -611,9 +644,10 @@ def read_sql( ErrorMessage.default_to_pandas("Parameters provided [chunksize]") df_gen = pandas.read_sql(**kwargs) return ( - DataFrame(query_compiler=FactoryDispatcher.from_pandas(df)) for df in df_gen + ModinObjects.DataFrame(query_compiler=FactoryDispatcher.from_pandas(df)) + for df in df_gen ) - return DataFrame(query_compiler=FactoryDispatcher.read_sql(**kwargs)) + return ModinObjects.DataFrame(query_compiler=FactoryDispatcher.read_sql(**kwargs)) @_inherit_docstrings(pandas.read_fwf, apilink="pandas.read_fwf") @@ -643,11 +677,11 @@ def read_fwf( # When `read_fwf` returns a TextFileReader object for iterating through if isinstance(pd_obj, TextFileReader): reader = pd_obj.read - pd_obj.read = lambda *args, **kwargs: DataFrame( + pd_obj.read = lambda *args, **kwargs: ModinObjects.DataFrame( query_compiler=reader(*args, **kwargs) ) return pd_obj - return DataFrame(query_compiler=pd_obj) + return ModinObjects.DataFrame(query_compiler=pd_obj) @_inherit_docstrings(pandas.read_sql_table, apilink="pandas.read_sql_table") @@ -670,7 +704,9 @@ def read_sql_table( from modin.core.execution.dispatching.factories.dispatcher import FactoryDispatcher - return DataFrame(query_compiler=FactoryDispatcher.read_sql_table(**kwargs)) + return ModinObjects.DataFrame( + query_compiler=FactoryDispatcher.read_sql_table(**kwargs) + ) @_inherit_docstrings(pandas.read_sql_query, apilink="pandas.read_sql_query") @@ -685,12 +721,14 @@ def read_sql_query( chunksize: int | None = None, dtype: DtypeArg | None = None, dtype_backend: Union[DtypeBackend, NoDefault] = no_default, -) -> DataFrame | Iterator[DataFrame]: +) -> "DataFrame" | Iterator["DataFrame"]: _, _, _, kwargs = inspect.getargvalues(inspect.currentframe()) from modin.core.execution.dispatching.factories.dispatcher import FactoryDispatcher - return DataFrame(query_compiler=FactoryDispatcher.read_sql_query(**kwargs)) + return ModinObjects.DataFrame( + query_compiler=FactoryDispatcher.read_sql_query(**kwargs) + ) @_inherit_docstrings(pandas.to_pickle) @@ -705,7 +743,7 @@ def to_pickle( ) -> None: from modin.core.execution.dispatching.factories.dispatcher import FactoryDispatcher - if isinstance(obj, DataFrame): + if isinstance(obj, ModinObjects.DataFrame): obj = obj._query_compiler return FactoryDispatcher.to_pickle( obj, @@ -730,7 +768,7 @@ def read_spss( """ from modin.core.execution.dispatching.factories.dispatcher import FactoryDispatcher - return DataFrame( + return ModinObjects.DataFrame( query_compiler=FactoryDispatcher.read_spss( path=path, usecols=usecols, @@ -751,12 +789,12 @@ def json_normalize( errors: Optional[str] = "raise", sep: str = ".", max_level: Optional[int] = None, -) -> DataFrame: # noqa: PR01, RT01, D200 +) -> "DataFrame": # noqa: PR01, RT01, D200 """ Normalize semi-structured JSON data into a flat table. """ ErrorMessage.default_to_pandas("json_normalize") - return DataFrame( + return ModinObjects.DataFrame( pandas.json_normalize( data, record_path, meta, meta_prefix, record_prefix, errors, sep, max_level ) @@ -772,12 +810,12 @@ def read_orc( dtype_backend: Union[DtypeBackend, NoDefault] = no_default, filesystem=None, **kwargs, -) -> DataFrame: # noqa: PR01, RT01, D200 +) -> "DataFrame": # noqa: PR01, RT01, D200 """ Load an ORC object from the file path, returning a DataFrame. """ ErrorMessage.default_to_pandas("read_orc") - return DataFrame( + return ModinObjects.DataFrame( pandas.read_orc( path, columns=columns, @@ -819,25 +857,25 @@ def return_handler(*args, **kwargs): does not accept Modin DataFrame objects, so we must convert to pandas. """ - from modin.utils import to_pandas - # We don't want to constantly be giving this error message for # internal methods. if item[0] != "_": ErrorMessage.default_to_pandas("`{}`".format(item)) args = [ - to_pandas(arg) if isinstance(arg, DataFrame) else arg + to_pandas(arg) + if isinstance(arg, ModinObjects.DataFrame) + else arg for arg in args ] kwargs = { - k: to_pandas(v) if isinstance(v, DataFrame) else v + k: to_pandas(v) if isinstance(v, ModinObjects.DataFrame) else v for k, v in kwargs.items() } obj = super(HDFStore, self).__getattribute__(item)(*args, **kwargs) if self._return_modin_dataframe and isinstance( obj, pandas.DataFrame ): - return DataFrame(obj) + return ModinObjects.DataFrame(obj) return obj # We replace the method with `return_handler` for inplace operations @@ -883,23 +921,23 @@ def return_handler(*args, **kwargs): methods of ExcelFile with the pandas equivalent. It will convert Modin DataFrame to pandas DataFrame, etc. """ - from modin.utils import to_pandas - # We don't want to constantly be giving this error message for # internal methods. if item[0] != "_": ErrorMessage.default_to_pandas("`{}`".format(item)) args = [ - to_pandas(arg) if isinstance(arg, DataFrame) else arg + to_pandas(arg) + if isinstance(arg, ModinObjects.DataFrame) + else arg for arg in args ] kwargs = { - k: to_pandas(v) if isinstance(v, DataFrame) else v + k: to_pandas(v) if isinstance(v, ModinObjects.DataFrame) else v for k, v in kwargs.items() } obj = super(ExcelFile, self).__getattribute__(item)(*args, **kwargs) if isinstance(obj, pandas.DataFrame): - return DataFrame(obj) + return ModinObjects.DataFrame(obj) return obj # We replace the method with `return_handler` for inplace operations @@ -907,6 +945,134 @@ def return_handler(*args, **kwargs): return method +def from_non_pandas(df, index, columns, dtype): + """ + Convert a non-pandas DataFrame into Modin DataFrame. + + Parameters + ---------- + df : object + Non-pandas DataFrame. + index : object + Index for non-pandas DataFrame. + columns : object + Columns for non-pandas DataFrame. + dtype : type + Data type to force. + + Returns + ------- + modin.pandas.DataFrame + Converted DataFrame. + """ + from modin.core.execution.dispatching.factories.dispatcher import FactoryDispatcher + + new_qc = FactoryDispatcher.from_non_pandas(df, index, columns, dtype) + if new_qc is not None: + return ModinObjects.DataFrame(query_compiler=new_qc) + return new_qc + + +def from_pandas(df): + """ + Convert a pandas DataFrame to a Modin DataFrame. + + Parameters + ---------- + df : pandas.DataFrame + The pandas DataFrame to convert. + + Returns + ------- + modin.pandas.DataFrame + A new Modin DataFrame object. + """ + from modin.core.execution.dispatching.factories.dispatcher import FactoryDispatcher + + return ModinObjects.DataFrame(query_compiler=FactoryDispatcher.from_pandas(df)) + + +def from_arrow(at): + """ + Convert an Arrow Table to a Modin DataFrame. + + Parameters + ---------- + at : Arrow Table + The Arrow Table to convert from. + + Returns + ------- + DataFrame + A new Modin DataFrame object. + """ + from modin.core.execution.dispatching.factories.dispatcher import FactoryDispatcher + + return ModinObjects.DataFrame(query_compiler=FactoryDispatcher.from_arrow(at)) + + +def from_dataframe(df): + """ + Convert a DataFrame implementing the dataframe exchange protocol to a Modin DataFrame. + + See more about the protocol in https://data-apis.org/dataframe-protocol/latest/index.html. + + Parameters + ---------- + df : DataFrame + The DataFrame object supporting the dataframe exchange protocol. + + Returns + ------- + DataFrame + A new Modin DataFrame object. + """ + from modin.core.execution.dispatching.factories.dispatcher import FactoryDispatcher + + return ModinObjects.DataFrame(query_compiler=FactoryDispatcher.from_dataframe(df)) + + +def to_pandas(modin_obj: SupportsPrivateToPandas) -> Any: + """ + Convert a Modin DataFrame/Series to a pandas DataFrame/Series. + + Parameters + ---------- + modin_obj : modin.DataFrame, modin.Series + The Modin DataFrame/Series to convert. + + Returns + ------- + pandas.DataFrame or pandas.Series + Converted object with type depending on input. + """ + return modin_obj._to_pandas() + + +def to_numpy( + modin_obj: Union[SupportsPrivateToNumPy, SupportsPublicToNumPy] +) -> np.ndarray: + """ + Convert a Modin object to a NumPy array. + + Parameters + ---------- + modin_obj : modin.DataFrame, modin."Series", modin.numpy.array + The Modin distributed object to convert. + + Returns + ------- + numpy.array + Converted object with type depending on input. + """ + if isinstance(modin_obj, SupportsPrivateToNumPy): + return modin_obj._to_numpy() + array = modin_obj.to_numpy() + if ExperimentalNumPyAPI.get(): + array = array._to_numpy() + return array + + __all__ = [ "ExcelFile", "HDFStore", @@ -931,5 +1097,11 @@ def return_handler(*args, **kwargs): "read_stata", "read_table", "read_xml", + "from_non_pandas", + "from_pandas", + "from_arrow", + "from_dataframe", "to_pickle", + "to_pandas", + "to_numpy", ] diff --git a/modin/pandas/plotting.py b/modin/pandas/plotting.py index fd4a0f53f2e..38b08eb6351 100644 --- a/modin/pandas/plotting.py +++ b/modin/pandas/plotting.py @@ -16,7 +16,8 @@ from pandas import plotting as pdplot from modin.logging import ClassLogger -from modin.utils import instancer, to_pandas +from modin.pandas.io import to_pandas +from modin.utils import instancer from .dataframe import DataFrame diff --git a/modin/pandas/series.py b/modin/pandas/series.py index 849bd0ff656..be43ef3a06f 100644 --- a/modin/pandas/series.py +++ b/modin/pandas/series.py @@ -32,13 +32,14 @@ from modin.config import PersistentPickle from modin.logging import disable_logging -from modin.utils import MODIN_UNNAMED_SERIES_LABEL, _inherit_docstrings, to_pandas +from modin.pandas.io import from_pandas, to_pandas +from modin.utils import MODIN_UNNAMED_SERIES_LABEL, _inherit_docstrings from .accessor import CachedAccessor, SparseAccessor from .base import _ATTRS_NO_LOOKUP, BasePandasDataset from .iterator import PartitionIterator from .series_utils import CategoryMethods, DatetimeProperties, StringMethods -from .utils import _doc_binary_op, cast_function_modin2pandas, from_pandas, is_scalar +from .utils import _doc_binary_op, cast_function_modin2pandas, is_scalar if TYPE_CHECKING: from .dataframe import DataFrame diff --git a/modin/pandas/test/dataframe/test_default.py b/modin/pandas/test/dataframe/test_default.py index b3eca75ef1c..59b9f907e4f 100644 --- a/modin/pandas/test/dataframe/test_default.py +++ b/modin/pandas/test/dataframe/test_default.py @@ -22,6 +22,7 @@ import modin.pandas as pd from modin.config import Engine, NPartitions, StorageFormat +from modin.pandas.io import to_pandas from modin.pandas.test.utils import ( axis_keys, axis_values, @@ -43,7 +44,7 @@ test_data_values, ) from modin.test.test_utils import warns_that_defaulting_to_pandas -from modin.utils import get_current_execution, to_pandas +from modin.utils import get_current_execution NPartitions.put(4) diff --git a/modin/pandas/test/dataframe/test_join_sort.py b/modin/pandas/test/dataframe/test_join_sort.py index 232eba8a1ef..29523528ea8 100644 --- a/modin/pandas/test/dataframe/test_join_sort.py +++ b/modin/pandas/test/dataframe/test_join_sort.py @@ -20,6 +20,7 @@ import modin.pandas as pd from modin.config import Engine, NPartitions, StorageFormat +from modin.pandas.io import to_pandas from modin.pandas.test.utils import ( arg_keys, axis_keys, @@ -39,7 +40,6 @@ test_data_values, ) from modin.test.test_utils import warns_that_defaulting_to_pandas -from modin.utils import to_pandas NPartitions.put(4) diff --git a/modin/pandas/test/dataframe/test_map_metadata.py b/modin/pandas/test/dataframe/test_map_metadata.py index 0decc17faa7..200b148590c 100644 --- a/modin/pandas/test/dataframe/test_map_metadata.py +++ b/modin/pandas/test/dataframe/test_map_metadata.py @@ -602,7 +602,7 @@ def _get_lazy_proxy(): elif StorageFormat.get() == "Hdk": import pyarrow as pa - from modin.pandas.utils import from_arrow + from modin.pandas.io import from_arrow at = pa.concat_tables( [ diff --git a/modin/pandas/test/test_general.py b/modin/pandas/test/test_general.py index 2fec3371ca9..28c96a760a2 100644 --- a/modin/pandas/test/test_general.py +++ b/modin/pandas/test/test_general.py @@ -21,8 +21,9 @@ import modin.pandas as pd from modin.config import StorageFormat +from modin.pandas.io import to_pandas from modin.test.test_utils import warns_that_defaulting_to_pandas -from modin.utils import get_current_execution, to_pandas +from modin.utils import get_current_execution from .utils import ( bool_arg_keys, diff --git a/modin/pandas/test/test_groupby.py b/modin/pandas/test/test_groupby.py index 2b491372a92..484cdade2f3 100644 --- a/modin/pandas/test/test_groupby.py +++ b/modin/pandas/test/test_groupby.py @@ -27,7 +27,8 @@ from modin.core.dataframe.pandas.partitioning.axis_partition import ( PandasDataframeAxisPartition, ) -from modin.pandas.utils import from_pandas, is_scalar +from modin.pandas.io import from_pandas +from modin.pandas.utils import is_scalar from modin.test.test_utils import warns_that_defaulting_to_pandas from modin.utils import ( MODIN_UNNAMED_SERIES_LABEL, diff --git a/modin/pandas/test/test_io.py b/modin/pandas/test/test_io.py index da12c980a67..bf2ca41f61f 100644 --- a/modin/pandas/test/test_io.py +++ b/modin/pandas/test/test_io.py @@ -47,9 +47,8 @@ ) from modin.config.envvars import MinPartitionSize from modin.db_conn import ModinDatabaseConnection, UnsupportedDatabaseException -from modin.pandas.utils import from_arrow +from modin.pandas.io import from_arrow, to_pandas from modin.test.test_utils import warns_that_defaulting_to_pandas -from modin.utils import to_pandas from .utils import ( COMP_TO_EXT, diff --git a/modin/pandas/test/test_series.py b/modin/pandas/test/test_series.py index 8945a356fed..f6bc7fe9d5e 100644 --- a/modin/pandas/test/test_series.py +++ b/modin/pandas/test/test_series.py @@ -28,8 +28,9 @@ import modin.pandas as pd from modin.config import NPartitions, StorageFormat +from modin.pandas.io import to_pandas from modin.test.test_utils import warns_that_defaulting_to_pandas -from modin.utils import get_current_execution, to_pandas, try_cast_to_pandas +from modin.utils import get_current_execution, try_cast_to_pandas from .utils import ( RAND_HIGH, diff --git a/modin/pandas/test/utils.py b/modin/pandas/test/utils.py index ab1251ccf4f..085652e2d6d 100644 --- a/modin/pandas/test/utils.py +++ b/modin/pandas/test/utils.py @@ -44,7 +44,8 @@ import modin.pandas as pd from modin.config import MinPartitionSize, NPartitions, TestDatasetSize, TrackFileLeaks -from modin.utils import to_pandas, try_cast_to_pandas +from modin.pandas.io import to_pandas +from modin.utils import try_cast_to_pandas # Flag activated on command line with "--extra-test-parameters" option. # Used in some tests to perform additional parameter combinations. diff --git a/modin/pandas/utils.py b/modin/pandas/utils.py index 0df9906e482..03a9078b0ee 100644 --- a/modin/pandas/utils.py +++ b/modin/pandas/utils.py @@ -20,7 +20,7 @@ from pandas._typing import AggFuncType, AggFuncTypeBase, AggFuncTypeDict, IndexLabel from pandas.util._decorators import doc -from modin.utils import hashable +from modin.utils import func_from_deprecated_location, hashable _doc_binary_operation = """ Return {operation} of {left} and `{right}` (binary operator `{bin_op}`). @@ -40,100 +40,30 @@ + "https://pandas.pydata.org/pandas-docs/stable/indexing.html#attribute-access" ) - -def from_non_pandas(df, index, columns, dtype): - """ - Convert a non-pandas DataFrame into Modin DataFrame. - - Parameters - ---------- - df : object - Non-pandas DataFrame. - index : object - Index for non-pandas DataFrame. - columns : object - Columns for non-pandas DataFrame. - dtype : type - Data type to force. - - Returns - ------- - modin.pandas.DataFrame - Converted DataFrame. - """ - from modin.core.execution.dispatching.factories.dispatcher import FactoryDispatcher - - new_qc = FactoryDispatcher.from_non_pandas(df, index, columns, dtype) - if new_qc is not None: - from .dataframe import DataFrame - - return DataFrame(query_compiler=new_qc) - return new_qc - - -def from_pandas(df): - """ - Convert a pandas DataFrame to a Modin DataFrame. - - Parameters - ---------- - df : pandas.DataFrame - The pandas DataFrame to convert. - - Returns - ------- - modin.pandas.DataFrame - A new Modin DataFrame object. - """ - from modin.core.execution.dispatching.factories.dispatcher import FactoryDispatcher - - from .dataframe import DataFrame - - return DataFrame(query_compiler=FactoryDispatcher.from_pandas(df)) - - -def from_arrow(at): - """ - Convert an Arrow Table to a Modin DataFrame. - - Parameters - ---------- - at : Arrow Table - The Arrow Table to convert from. - - Returns - ------- - DataFrame - A new Modin DataFrame object. - """ - from modin.core.execution.dispatching.factories.dispatcher import FactoryDispatcher - - from .dataframe import DataFrame - - return DataFrame(query_compiler=FactoryDispatcher.from_arrow(at)) - - -def from_dataframe(df): - """ - Convert a DataFrame implementing the dataframe exchange protocol to a Modin DataFrame. - - See more about the protocol in https://data-apis.org/dataframe-protocol/latest/index.html. - - Parameters - ---------- - df : DataFrame - The DataFrame object supporting the dataframe exchange protocol. - - Returns - ------- - DataFrame - A new Modin DataFrame object. - """ - from modin.core.execution.dispatching.factories.dispatcher import FactoryDispatcher - - from .dataframe import DataFrame - - return DataFrame(query_compiler=FactoryDispatcher.from_dataframe(df)) +from_pandas = func_from_deprecated_location( + "from_pandas", + "modin.pandas.io", + "Importing ``from_pandas`` from ``modin.pandas.utils`` is deprecated and will be removed in a future version. " + + "This function was moved to ``modin.pandas.io``, please import it from there instead.", +) +from_arrow = func_from_deprecated_location( + "from_arrow", + "modin.pandas.io", + "Importing ``from_arrow`` from ``modin.pandas.utils`` is deprecated and will be removed in a future version. " + + "This function was moved to ``modin.pandas.io``, please import it from there instead.", +) +from_dataframe = func_from_deprecated_location( + "from_dataframe", + "modin.pandas.io", + "Importing ``from_dataframe`` from ``modin.pandas.utils`` is deprecated and will be removed in a future version. " + + "This function was moved to ``modin.pandas.io``, please import it from there instead.", +) +from_non_pandas = func_from_deprecated_location( + "from_non_pandas", + "modin.pandas.io", + "Importing ``from_non_pandas`` from ``modin.pandas.utils`` is deprecated and will be removed in a future version. " + + "This function was moved to ``modin.pandas.io``, please import it from there instead.", +) def cast_function_modin2pandas(func): diff --git a/modin/test/interchange/dataframe_protocol/base/test_sanity.py b/modin/test/interchange/dataframe_protocol/base/test_sanity.py index fff242e0b26..7ad0b4a6715 100644 --- a/modin/test/interchange/dataframe_protocol/base/test_sanity.py +++ b/modin/test/interchange/dataframe_protocol/base/test_sanity.py @@ -41,7 +41,7 @@ def dummy_io_method(*args, **kwargs): query_compiler_cls.from_dataframe = dummy_io_method query_compiler_cls.to_dataframe = dummy_io_method - from modin.pandas.utils import from_dataframe + from modin.pandas.io import from_dataframe with pytest.raises(TestPassed): from_dataframe(None) diff --git a/modin/test/interchange/dataframe_protocol/hdk/test_protocol.py b/modin/test/interchange/dataframe_protocol/hdk/test_protocol.py index 756b2a9f0c3..957a8d2b0a4 100644 --- a/modin/test/interchange/dataframe_protocol/hdk/test_protocol.py +++ b/modin/test/interchange/dataframe_protocol/hdk/test_protocol.py @@ -24,8 +24,8 @@ primitive_column_to_ndarray, set_nulls, ) +from modin.pandas.io import from_arrow, from_dataframe from modin.pandas.test.utils import df_equals -from modin.pandas.utils import from_arrow, from_dataframe from modin.test.test_utils import warns_that_defaulting_to_pandas from .utils import export_frame, get_data_of_all_types, split_df_into_chunks diff --git a/modin/utils.py b/modin/utils.py index c42a0516fa3..d56408dcb45 100644 --- a/modin/utils.py +++ b/modin/utils.py @@ -22,6 +22,7 @@ import re import sys import types +import warnings from pathlib import Path from textwrap import dedent, indent from typing import ( @@ -48,7 +49,7 @@ ) from modin._version import get_versions -from modin.config import Engine, ExperimentalNumPyAPI, IsExperimental, StorageFormat +from modin.config import Engine, IsExperimental, StorageFormat T = TypeVar("T") """Generic type parameter""" @@ -490,46 +491,48 @@ def wrapped(*args: tuple, **kw: dict) -> Any: return decorator -# TODO add proper type annotation -def to_pandas(modin_obj: SupportsPrivateToPandas) -> Any: +def func_from_deprecated_location( + func_name: str, module: str, deprecation_message: str +) -> Callable: """ - Convert a Modin DataFrame/Series to a pandas DataFrame/Series. + Create a function that decorates a function ``module.func_name`` with a ``FutureWarning``. Parameters ---------- - modin_obj : modin.DataFrame, modin.Series - The Modin DataFrame/Series to convert. + func_name : str + Function name to decorate. + module : str + Module where the function is located. + deprecation_message : str + Message to print in a future warning. Returns ------- - pandas.DataFrame or pandas.Series - Converted object with type depending on input. + callable """ - return modin_obj._to_pandas() + def deprecated_func(*args: tuple[Any], **kwargs: dict[Any, Any]) -> Any: + """Call deprecated function.""" + func = getattr(importlib.import_module(module), func_name) + # using 'FutureWarning' as 'DeprecationWarnings' are filtered out by default + warnings.warn(deprecation_message, FutureWarning) + return func(*args, **kwargs) -def to_numpy( - modin_obj: Union[SupportsPrivateToNumPy, SupportsPublicToNumPy] -) -> np.ndarray: - """ - Convert a Modin object to a NumPy array. + return deprecated_func - Parameters - ---------- - modin_obj : modin.DataFrame, modin.Series, modin.numpy.array - The Modin distributed object to convert. - Returns - ------- - numpy.array - Converted object with type depending on input. - """ - if isinstance(modin_obj, SupportsPrivateToNumPy): - return modin_obj._to_numpy() - array = modin_obj.to_numpy() - if ExperimentalNumPyAPI.get(): - array = array._to_numpy() - return array +to_numpy = func_from_deprecated_location( + "to_numpy", + "modin.pandas.io", + "Importing ``to_numpy`` from ``modin.pandas.utils`` is deprecated and will be removed in a future version. " + + "This function was moved to ``modin.pandas.io``, please import it from there instead.", +) +to_pandas = func_from_deprecated_location( + "to_pandas", + "modin.pandas.io", + "Importing ``to_pandas`` from ``modin.pandas.utils`` is deprecated and will be removed in a future version. " + + "This function was moved to ``modin.pandas.io``, please import it from there instead.", +) def hashable(obj: bool) -> bool: @@ -847,3 +850,30 @@ class ModinAssumptionError(Exception): """An exception that allows us defaults to pandas if any assumption fails.""" pass + + +class classproperty: + """ + Decorator that allows creating read-only class properties. + + Parameters + ---------- + func : method + + Examples + -------- + >>> class A: + ... field = 10 + ... @classproperty + ... def field_x2(cls): + ... return cls.field * 2 + ... + >>> print(A.field_x2) + 20 + """ + + def __init__(self, func: Any): + self.fget = func + + def __get__(self, instance: Any, owner: Any) -> Any: # noqa: GL08 + return self.fget(owner) From 72d013dc9c9eb5044c5eed8f7f81474c5704a93f Mon Sep 17 00:00:00 2001 From: Rehan Sohail Durrani Date: Thu, 7 Dec 2023 12:12:30 -0800 Subject: [PATCH 112/201] DOCS-#0000: Add conda forge doc (#6627) Signed-off-by: Rehan Durrani --- docs/release-procedure.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/release-procedure.md b/docs/release-procedure.md index f8a9474342e..10fc90f6721 100644 --- a/docs/release-procedure.md +++ b/docs/release-procedure.md @@ -133,7 +133,8 @@ and in case of Modin it waits either for Github releases or for tags and then ma a new automatic PR with version increment. You should watch for that PR and, fixing any issues if there are some, merge it -to make new Modin release appear in `conda-forge` channel. +to make new Modin release appear in `conda-forge` channel. For detailed instructions +on how to ensure the PR passes CI and is merge-able, check out [the how-to page in the modin-feedstock repo](https://github.com/conda-forge/modin-feedstock/blob/main/HOWTO.md)! ## Publicize Release Once the release has been finalized, make sure to post an announcement in the #general channel of From 1a1e090032a187409515ce5697156a0716c4a599 Mon Sep 17 00:00:00 2001 From: Anatoly Myachev Date: Thu, 7 Dec 2023 22:49:01 +0100 Subject: [PATCH 113/201] FEAT-#6801: Add 'modin.pandas.error' module (#6802) Signed-off-by: Anatoly Myachev --- modin/pandas/__init__.py | 2 ++ modin/pandas/errors/__init__.py | 18 ++++++++++++++++++ modin/pandas/test/test_api.py | 1 - 3 files changed, 20 insertions(+), 1 deletion(-) create mode 100644 modin/pandas/errors/__init__.py diff --git a/modin/pandas/__init__.py b/modin/pandas/__init__.py index 723024c48ff..0116104f520 100644 --- a/modin/pandas/__init__.py +++ b/modin/pandas/__init__.py @@ -163,6 +163,7 @@ def _update_engine(publisher: Parameter): _is_first_update[publisher.get()] = False +from modin.pandas import errors from modin.utils import show_versions from .. import __version__ @@ -331,6 +332,7 @@ def _update_engine(publisher: Parameter): "Float32Dtype", "Float64Dtype", "from_dummies", + "errors", ] del pandas, Parameter diff --git a/modin/pandas/errors/__init__.py b/modin/pandas/errors/__init__.py new file mode 100644 index 00000000000..d121414db45 --- /dev/null +++ b/modin/pandas/errors/__init__.py @@ -0,0 +1,18 @@ +# Licensed to Modin Development Team under one or more contributor license agreements. +# See the NOTICE file distributed with this work for additional information regarding +# copyright ownership. The Modin Development Team licenses this file to you under the +# Apache License, Version 2.0 (the "License"); you may not use this file except in +# compliance with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software distributed under +# the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF +# ANY KIND, either express or implied. See the License for the specific language +# governing permissions and limitations under the License. + + +"""The module is needed to allow the following import `import modin.pandas.errors`.""" + +from pandas.errors import * # noqa: F403, F401 +from pandas.errors import __all__ # noqa: F401 diff --git a/modin/pandas/test/test_api.py b/modin/pandas/test/test_api.py index 4ace25c0b0e..c8c0869749a 100644 --- a/modin/pandas/test/test_api.py +++ b/modin/pandas/test/test_api.py @@ -39,7 +39,6 @@ def test_top_level_api_equality(): "arrays", "api", "tseries", - "errors", "to_msgpack", # This one is experimental, and doesn't look finished "Panel", # This is deprecated and throws a warning every time. "SparseSeries", # depreceted since pandas 1.0, not present in 1.4+ From 43953875f6959513d81ef31464383525e1d555f2 Mon Sep 17 00:00:00 2001 From: Anatoly Myachev Date: Fri, 8 Dec 2023 15:42:26 +0100 Subject: [PATCH 114/201] TEST-#6729: Use custom pytest mark instead of `--extra-test-parameters` option (#6730) Signed-off-by: Anatoly Myachev --- modin/conftest.py | 10 ---- modin/pandas/test/dataframe/test_indexing.py | 49 ++++++++----------- modin/pandas/test/dataframe/test_join_sort.py | 9 ++-- modin/pandas/test/utils.py | 4 -- setup.cfg | 5 +- 5 files changed, 26 insertions(+), 51 deletions(-) diff --git a/modin/conftest.py b/modin/conftest.py index d860807ec19..482cbaf690f 100644 --- a/modin/conftest.py +++ b/modin/conftest.py @@ -90,12 +90,6 @@ def pytest_addoption(parser): default=None, help="specifies execution to run tests on", ) - parser.addoption( - "--extra-test-parameters", - action="store_true", - help="activate extra test parameter combinations", - default=False, - ) def set_experimental_env(mode): @@ -256,10 +250,6 @@ def get_unique_base_execution(): def pytest_configure(config): - import modin.pandas.test.utils as utils - - utils.extra_test_parameters = config.getoption("--extra-test-parameters") - execution = config.option.execution if execution is None: diff --git a/modin/pandas/test/dataframe/test_indexing.py b/modin/pandas/test/dataframe/test_indexing.py index e73d52eabd0..3e608a218f3 100644 --- a/modin/pandas/test/dataframe/test_indexing.py +++ b/modin/pandas/test/dataframe/test_indexing.py @@ -34,7 +34,6 @@ default_to_pandas_ignore_string, df_equals, eval_general, - extra_test_parameters, generate_multiindex, int_arg_keys, int_arg_values, @@ -1473,7 +1472,7 @@ def test_reset_index_multiindex_groupby(data): [ pytest.param( test_data["int_data"], - marks=pytest.mark.skipif(not extra_test_parameters, reason="extra"), + marks=pytest.mark.exclude_by_default, ), test_data["float_nan_data"], ], @@ -1494,23 +1493,23 @@ def test_reset_index_multiindex_groupby(data): [1, 0], pytest.param( [2, 1, 2], - marks=pytest.mark.skipif(not extra_test_parameters, reason="extra"), + marks=pytest.mark.exclude_by_default, ), pytest.param( [0, 0, 0, 0], - marks=pytest.mark.skipif(not extra_test_parameters, reason="extra"), + marks=pytest.mark.exclude_by_default, ), pytest.param( ["level_name_1"], - marks=pytest.mark.skipif(not extra_test_parameters, reason="extra"), + marks=pytest.mark.exclude_by_default, ), pytest.param( ["level_name_2", "level_name_1"], - marks=pytest.mark.skipif(not extra_test_parameters, reason="extra"), + marks=pytest.mark.exclude_by_default, ), pytest.param( [2, "level_name_0"], - marks=pytest.mark.skipif(not extra_test_parameters, reason="extra"), + marks=pytest.mark.exclude_by_default, ), ], ) @@ -1523,12 +1522,8 @@ def test_reset_index_multiindex_groupby(data): 0, 1, 2, - pytest.param( - 3, marks=pytest.mark.skipif(not extra_test_parameters, reason="extra") - ), - pytest.param( - 4, marks=pytest.mark.skipif(not extra_test_parameters, reason="extra") - ), + pytest.param(3, marks=pytest.mark.exclude_by_default), + pytest.param(4, marks=pytest.mark.exclude_by_default), ], ) @pytest.mark.parametrize( @@ -1536,13 +1531,13 @@ def test_reset_index_multiindex_groupby(data): [ pytest.param( False, - marks=pytest.mark.skipif(not extra_test_parameters, reason="extra"), + marks=pytest.mark.exclude_by_default, ), True, "mixed_1st_None", pytest.param( "mixed_2nd_None", - marks=pytest.mark.skipif(not extra_test_parameters, reason="extra"), + marks=pytest.mark.exclude_by_default, ), ], ) @@ -1631,7 +1626,7 @@ def test_reset_index_with_multi_index_no_drop( [ pytest.param( test_data["int_data"], - marks=pytest.mark.skipif(not extra_test_parameters, reason="extra"), + marks=pytest.mark.exclude_by_default, ), test_data["float_nan_data"], ], @@ -1651,23 +1646,23 @@ def test_reset_index_with_multi_index_no_drop( [1, 0], pytest.param( [2, 1, 2], - marks=pytest.mark.skipif(not extra_test_parameters, reason="extra"), + marks=pytest.mark.exclude_by_default, ), pytest.param( [0, 0, 0, 0], - marks=pytest.mark.skipif(not extra_test_parameters, reason="extra"), + marks=pytest.mark.exclude_by_default, ), pytest.param( ["level_name_1"], - marks=pytest.mark.skipif(not extra_test_parameters, reason="extra"), + marks=pytest.mark.exclude_by_default, ), pytest.param( ["level_name_2", "level_name_1"], - marks=pytest.mark.skipif(not extra_test_parameters, reason="extra"), + marks=pytest.mark.exclude_by_default, ), pytest.param( [2, "level_name_0"], - marks=pytest.mark.skipif(not extra_test_parameters, reason="extra"), + marks=pytest.mark.exclude_by_default, ), ], ) @@ -1677,12 +1672,8 @@ def test_reset_index_with_multi_index_no_drop( 0, 1, 2, - pytest.param( - 3, marks=pytest.mark.skipif(not extra_test_parameters, reason="extra") - ), - pytest.param( - 4, marks=pytest.mark.skipif(not extra_test_parameters, reason="extra") - ), + pytest.param(3, marks=pytest.mark.exclude_by_default), + pytest.param(4, marks=pytest.mark.exclude_by_default), ], ) @pytest.mark.parametrize( @@ -1690,13 +1681,13 @@ def test_reset_index_with_multi_index_no_drop( [ pytest.param( False, - marks=pytest.mark.skipif(not extra_test_parameters, reason="extra"), + marks=pytest.mark.exclude_by_default, ), True, "mixed_1st_None", pytest.param( "mixed_2nd_None", - marks=pytest.mark.skipif(not extra_test_parameters, reason="extra"), + marks=pytest.mark.exclude_by_default, ), ], ) diff --git a/modin/pandas/test/dataframe/test_join_sort.py b/modin/pandas/test/dataframe/test_join_sort.py index 29523528ea8..2925f999b05 100644 --- a/modin/pandas/test/dataframe/test_join_sort.py +++ b/modin/pandas/test/dataframe/test_join_sort.py @@ -31,7 +31,6 @@ default_to_pandas_ignore_string, df_equals, eval_general, - extra_test_parameters, generate_multiindex, random_state, rotate_decimal_digits_or_symbols, @@ -585,11 +584,11 @@ def test_sort_multiindex(sort_remaining): [ pytest.param( "first", - marks=pytest.mark.skipif(not extra_test_parameters, reason="extra"), + marks=pytest.mark.exclude_by_default, ), pytest.param( "first,last", - marks=pytest.mark.skipif(not extra_test_parameters, reason="extra"), + marks=pytest.mark.exclude_by_default, ), "first,last,middle", ], @@ -608,12 +607,12 @@ def test_sort_multiindex(sort_remaining): [ pytest.param( "mergesort", - marks=pytest.mark.skipif(not extra_test_parameters, reason="extra"), + marks=pytest.mark.exclude_by_default, ), "quicksort", pytest.param( "heapsort", - marks=pytest.mark.skipif(not extra_test_parameters, reason="extra"), + marks=pytest.mark.exclude_by_default, ), ], ) diff --git a/modin/pandas/test/utils.py b/modin/pandas/test/utils.py index 085652e2d6d..279157c9c56 100644 --- a/modin/pandas/test/utils.py +++ b/modin/pandas/test/utils.py @@ -47,10 +47,6 @@ from modin.pandas.io import to_pandas from modin.utils import try_cast_to_pandas -# Flag activated on command line with "--extra-test-parameters" option. -# Used in some tests to perform additional parameter combinations. -extra_test_parameters = False - random_state = np.random.RandomState(seed=42) DATASET_SIZE_DICT = { diff --git a/setup.cfg b/setup.cfg index 8f479523bec..55de39100b6 100644 --- a/setup.cfg +++ b/setup.cfg @@ -12,12 +12,11 @@ tag_prefix = parentdir_prefix = modin- [tool:pytest] -addopts = --cov-config=setup.cfg --cov=modin --cov-append --cov-report= +addopts = --cov-config=setup.cfg --cov=modin --cov-append --cov-report= -m "not exclude_by_default" xfail_strict=true markers = - xfail_executions - skip_executions exclude_in_sanity + exclude_by_default filterwarnings = error:.*defaulting to pandas.*:UserWarning From b61b40d6cf939774531bf0f54568d2cde5ba409d Mon Sep 17 00:00:00 2001 From: Anatoly Myachev Date: Fri, 8 Dec 2023 17:21:27 +0100 Subject: [PATCH 115/201] FIX-#6782: filter pandas warnings when precomputing dtypes (#6811) Signed-off-by: Anatoly Myachev --- modin/core/dataframe/algebra/binary.py | 17 ++++++++++------- modin/pandas/test/test_series.py | 12 ++++++++++++ 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/modin/core/dataframe/algebra/binary.py b/modin/core/dataframe/algebra/binary.py index 22cd2c3ef88..f19040cc104 100644 --- a/modin/core/dataframe/algebra/binary.py +++ b/modin/core/dataframe/algebra/binary.py @@ -13,6 +13,7 @@ """Module houses builder class for Binary operator.""" +import warnings from typing import Optional import numpy as np @@ -120,13 +121,15 @@ def maybe_compute_dtypes_common_cast( dtypes = None if func is not None: try: - df1 = pandas.DataFrame([[1] * len(common_columns)]).astype( - {i: dtypes_first[col] for i, col in enumerate(common_columns)} - ) - df2 = pandas.DataFrame([[1] * len(common_columns)]).astype( - {i: dtypes_second[col] for i, col in enumerate(common_columns)} - ) - dtypes = func(df1, df2).dtypes.set_axis(common_columns) + with warnings.catch_warnings(): + warnings.filterwarnings("ignore") + df1 = pandas.DataFrame([[1] * len(common_columns)]).astype( + {i: dtypes_first[col] for i, col in enumerate(common_columns)} + ) + df2 = pandas.DataFrame([[1] * len(common_columns)]).astype( + {i: dtypes_second[col] for i, col in enumerate(common_columns)} + ) + dtypes = func(df1, df2).dtypes.set_axis(common_columns) # it sometimes doesn't work correctly with strings, so falling back to # the "common_cast" method in this case except TypeError: diff --git a/modin/pandas/test/test_series.py b/modin/pandas/test/test_series.py index f6bc7fe9d5e..f8ee06503b8 100644 --- a/modin/pandas/test/test_series.py +++ b/modin/pandas/test/test_series.py @@ -13,6 +13,7 @@ from __future__ import annotations +import datetime import json import unittest.mock as mock @@ -3351,6 +3352,17 @@ def test_sub(data): inter_df_math_helper(modin_series, pandas_series, "sub") +def test_6782(): + datetime_scalar = datetime.datetime(1970, 1, 1, 0, 0) + with pytest.warns(UserWarning) as warns: + _ = pd.Series([datetime.datetime(2000, 1, 1)]) - datetime_scalar + for warn in warns.list: + assert ( + "Adding/subtracting object-dtype array to DatetimeArray not vectorized" + not in str(warn) + ) + + @pytest.mark.skipif( StorageFormat.get() == "Hdk", reason="https://github.com/intel-ai/hdk/issues/272", From c3a4f781eee5fe83b3747f3145080a06dea17599 Mon Sep 17 00:00:00 2001 From: Anatoly Myachev Date: Fri, 8 Dec 2023 17:31:14 +0100 Subject: [PATCH 116/201] FEAT-#6767: Provide the ability to use experimental functionality when experimental mode is not enabled globally via an environment variable (#6764) Signed-off-by: Anatoly Myachev --- .github/workflows/ci-required.yml | 1 - docs/development/architecture.rst | 28 +-- .../implementations/pandas_on_dask/index.rst | 25 -- .../pandas_on_dask/io/index.rst | 38 --- .../implementations/pandas_on_ray/index.rst | 25 -- .../pandas_on_ray/io/index.rst | 38 --- .../pandas_on_unidist/index.rst | 25 -- .../pandas_on_unidist/io/index.rst | 38 --- docs/flow/modin/experimental/pandas.rst | 2 +- docs/supported_apis/dataframe_supported.rst | 3 +- docs/usage_guide/advanced_usage/index.rst | 2 +- .../implementations/pandas_on_dask/io/io.py | 22 ++ .../dispatching/factories/dispatcher.py | 53 ++--- .../dispatching/factories/factories.py | 217 ++++++++---------- .../factories/test/test_dispatcher.py | 4 +- .../implementations/pandas_on_ray/io/io.py | 22 ++ .../pandas_on_unidist/io/io.py | 22 ++ .../pandas_on_dask/io/__init__.py | 18 -- .../implementations/pandas_on_dask/io/io.py | 77 ------- .../implementations/pandas_on_ray/__init__.py | 14 -- .../pandas_on_ray/io/__init__.py | 18 -- .../implementations/pandas_on_ray/io/io.py | 77 ------- .../pandas_on_unidist/io/__init__.py | 18 -- .../pandas_on_unidist/io/io.py | 79 ------- .../core/io/sql/sql_dispatcher.py | 2 +- modin/experimental/pandas/__init__.py | 21 +- modin/experimental/pandas/io.py | 4 +- modin/experimental/pandas/test/test_io_exp.py | 2 +- modin/pandas/accessor.py | 66 ++++++ modin/pandas/dataframe.py | 5 +- modin/pandas/test/test_api.py | 14 +- modin/utils.py | 2 +- 32 files changed, 287 insertions(+), 695 deletions(-) delete mode 100644 docs/flow/modin/experimental/core/execution/dask/implementations/pandas_on_dask/index.rst delete mode 100644 docs/flow/modin/experimental/core/execution/dask/implementations/pandas_on_dask/io/index.rst delete mode 100644 docs/flow/modin/experimental/core/execution/ray/implementations/pandas_on_ray/index.rst delete mode 100644 docs/flow/modin/experimental/core/execution/ray/implementations/pandas_on_ray/io/index.rst delete mode 100644 docs/flow/modin/experimental/core/execution/unidist/implementations/pandas_on_unidist/index.rst delete mode 100644 docs/flow/modin/experimental/core/execution/unidist/implementations/pandas_on_unidist/io/index.rst delete mode 100644 modin/experimental/core/execution/dask/implementations/pandas_on_dask/io/__init__.py delete mode 100644 modin/experimental/core/execution/dask/implementations/pandas_on_dask/io/io.py delete mode 100644 modin/experimental/core/execution/ray/implementations/pandas_on_ray/__init__.py delete mode 100644 modin/experimental/core/execution/ray/implementations/pandas_on_ray/io/__init__.py delete mode 100644 modin/experimental/core/execution/ray/implementations/pandas_on_ray/io/io.py delete mode 100644 modin/experimental/core/execution/unidist/implementations/pandas_on_unidist/io/__init__.py delete mode 100644 modin/experimental/core/execution/unidist/implementations/pandas_on_unidist/io/io.py diff --git a/.github/workflows/ci-required.yml b/.github/workflows/ci-required.yml index cfc3ce91158..58b33bbfb22 100644 --- a/.github/workflows/ci-required.yml +++ b/.github/workflows/ci-required.yml @@ -66,7 +66,6 @@ jobs: asv_bench/benchmarks/__init__.py asv_bench/benchmarks/io/__init__.py \ asv_bench/benchmarks/scalability/__init__.py \ modin/core/io \ - modin/experimental/core/execution/ray/implementations/pandas_on_ray \ modin/experimental/core/execution/ray/implementations/pyarrow_on_ray \ modin/pandas/series.py \ modin/core/execution/python \ diff --git a/docs/development/architecture.rst b/docs/development/architecture.rst index 61bff9c6819..4cebbca7345 100644 --- a/docs/development/architecture.rst +++ b/docs/development/architecture.rst @@ -224,18 +224,6 @@ documentation page on :doc:`contributing `. - Uses native python execution - mainly used for debugging. - The storage format is `pandas` and the in-memory partition type is a pandas DataFrame. - For more information on the execution path, see the :doc:`pandas on Python ` page. -- pandas on Ray (experimental) - - Uses the Ray_ execution framework. - - The storage format is `pandas` and the in-memory partition type is a pandas DataFrame. - - For more information on the execution path, see the :doc:`experimental pandas on Ray ` page. -- pandas on MPI (experimental) - - Uses MPI_ through the Unidist_ execution framework. - - The storage format is `pandas` and the in-memory partition type is a pandas DataFrame. - - For more information on the execution path, see the :doc:`experimental pandas on Unidist ` page. -- pandas on Dask (experimental) - - Uses the Dask_ execution framework. - - The storage format is `pandas` and the in-memory partition type is a pandas DataFrame. - - For more information on the execution path, see the :doc:`experimental pandas on Dask ` page. - :doc:`HDK on Native ` (experimental) - Uses HDK as an engine. - The storage format is `hdk` and the in-memory partition type is a pyarrow Table. When defaulting to pandas, the pandas DataFrame is used. @@ -341,19 +329,9 @@ details. The documentation covers most modules, with more docs being added every │ ├─── :doc:`experimental ` │ │ ├───core │ │ │ ├───execution - │ │ │ │ ├───native - │ │ │ │ │ └───implementations - │ │ │ │ │ └─── :doc:`hdk_on_native ` - │ │ │ │ ├───ray - │ │ │ │ │ └───implementations - │ │ │ │ │ ├─── :doc:`pandas_on_ray ` - │ │ │ │ │ └─── :doc:`pyarrow_on_ray ` - │ │ │ │ ├───unidist - │ │ │ │ | └───implementations - │ │ │ │ | └─── :doc:`pandas_on_unidist ` - | │ | | └───dask - | | | | └───implementations - │ │ │ │ └─── :doc:`pandas_on_dask ` + │ │ │ │ └───native + │ │ │ │ └───implementations + │ │ │ │ └─── :doc:`hdk_on_native ` │ │ │ ├─── :doc:`storage_formats ` | │ │ | ├─── :doc:`hdk ` │ │ │ | └─── :doc:`pyarrow ` diff --git a/docs/flow/modin/experimental/core/execution/dask/implementations/pandas_on_dask/index.rst b/docs/flow/modin/experimental/core/execution/dask/implementations/pandas_on_dask/index.rst deleted file mode 100644 index 543b3381695..00000000000 --- a/docs/flow/modin/experimental/core/execution/dask/implementations/pandas_on_dask/index.rst +++ /dev/null @@ -1,25 +0,0 @@ -:orphan: - -ExperimentalPandasOnDask Execution -================================== - -`ExperimentalPandasOnDask` execution keeps the underlying mechanisms of :doc:`PandasOnDask ` -execution architecturally unchanged and adds experimental features of ``Data Transformation``, ``Data Ingress`` and ``Data Egress`` (e.g. :py:func:`~modin.experimental.pandas.read_pickle_distributed`). - -PandasOnDask and ExperimentalPandasOnDask differences ------------------------------------------------------ - -- another Factory ``PandasOnDaskFactory`` -> ``ExperimentalPandasOnDaskFactory`` -- another IO class ``PandasOnDaskIO`` -> ``ExperimentalPandasOnDaskIO`` - -ExperimentalPandasOnDaskIO classes and modules ----------------------------------------------- - -- :py:class:`~modin.experimental.core.execution.dask.implementations.pandas_on_dask.io.io.ExperimentalPandasOnDaskIO` -- :py:class:`~modin.core.execution.dispatching.factories.factories.ExperimentalPandasOnDaskFactory` -- :py:class:`~modin.experimental.core.io.text.csv_glob_dispatcher.ExperimentalCSVGlobDispatcher` -- :py:class:`~modin.experimental.core.io.sql.sql_dispatcher.ExperimentalSQLDispatcher` -- :py:class:`~modin.experimental.core.io.pickle.pickle_dispatcher.ExperimentalPickleDispatcher` -- :py:class:`~modin.experimental.core.io.text.custom_text_dispatcher.ExperimentalCustomTextDispatcher` -- :py:class:`~modin.core.storage_formats.pandas.parsers.PandasCSVGlobParser` -- :doc:`ExperimentalPandasOnDask IO module ` diff --git a/docs/flow/modin/experimental/core/execution/dask/implementations/pandas_on_dask/io/index.rst b/docs/flow/modin/experimental/core/execution/dask/implementations/pandas_on_dask/io/index.rst deleted file mode 100644 index 58edd6b58f3..00000000000 --- a/docs/flow/modin/experimental/core/execution/dask/implementations/pandas_on_dask/io/index.rst +++ /dev/null @@ -1,38 +0,0 @@ -:orphan: - -IO module Description For ExperimentalPandasOnDask Execution -"""""""""""""""""""""""""""""""""""""""""""""""""""""""""""" - -High-Level Module Overview -'''''''''''''''''''''''''' - -This module houses experimental functionality with pandas storage format and Dask -engine. This functionality is concentrated in the :py:class:`~modin.experimental.core.execution.dask.implementations.pandas_on_dask.io.io.ExperimentalPandasOnDaskIO` -class, that contains methods, which extend typical pandas API to give user -more flexibility with IO operations. - -Usage Guide -''''''''''' - -In order to use the experimental features, just modify standard Modin import -statement as follows: - -.. code-block:: python - - # import modin.pandas as pd - import modin.experimental.pandas as pd - -Submodules Description -'''''''''''''''''''''' - -The ``modin.experimental.core.execution.dask.implementations.pandas_on_dask`` module primarily houses utils and -functions for the experimental IO class: - -* ``io.py`` - submodule containing IO class and parse functions, which are responsible - for data processing on the workers. - -Public API -'''''''''' - -.. autoclass:: modin.experimental.core.execution.dask.implementations.pandas_on_dask.io.io.ExperimentalPandasOnDaskIO - :members: diff --git a/docs/flow/modin/experimental/core/execution/ray/implementations/pandas_on_ray/index.rst b/docs/flow/modin/experimental/core/execution/ray/implementations/pandas_on_ray/index.rst deleted file mode 100644 index c9b17d29045..00000000000 --- a/docs/flow/modin/experimental/core/execution/ray/implementations/pandas_on_ray/index.rst +++ /dev/null @@ -1,25 +0,0 @@ -:orphan: - -ExperimentalPandasOnRay Execution -================================= - -`ExperimentalPandasOnRay` execution keeps the underlying mechanisms of :doc:`PandasOnRay ` -execution architecturally unchanged and adds experimental features of ``Data Transformation``, ``Data Ingress`` and ``Data Egress`` (e.g. :py:func:`~modin.experimental.pandas.read_pickle_distributed`). - -PandasOnRay and ExperimentalPandasOnRay differences ---------------------------------------------------- - -- another Factory ``PandasOnRayFactory`` -> ``ExperimentalPandasOnRayFactory`` -- another IO class ``PandasOnRayIO`` -> ``ExperimentalPandasOnRayIO`` - -ExperimentalPandasOnRayIO classes and modules ---------------------------------------------- - -- :py:class:`~modin.experimental.core.execution.ray.implementations.pandas_on_ray.io.io.ExperimentalPandasOnRayIO` -- :py:class:`~modin.core.execution.dispatching.factories.factories.ExperimentalPandasOnRayFactory` -- :py:class:`~modin.experimental.core.io.text.csv_glob_dispatcher.ExperimentalCSVGlobDispatcher` -- :py:class:`~modin.experimental.core.io.sql.sql_dispatcher.ExperimentalSQLDispatcher` -- :py:class:`~modin.experimental.core.io.pickle.pickle_dispatcher.ExperimentalPickleDispatcher` -- :py:class:`~modin.experimental.core.io.text.custom_text_dispatcher.ExperimentalCustomTextDispatcher` -- :py:class:`~modin.core.storage_formats.pandas.parsers.PandasCSVGlobParser` -- :doc:`ExperimentalPandasOnRay IO module ` diff --git a/docs/flow/modin/experimental/core/execution/ray/implementations/pandas_on_ray/io/index.rst b/docs/flow/modin/experimental/core/execution/ray/implementations/pandas_on_ray/io/index.rst deleted file mode 100644 index 357ceacda76..00000000000 --- a/docs/flow/modin/experimental/core/execution/ray/implementations/pandas_on_ray/io/index.rst +++ /dev/null @@ -1,38 +0,0 @@ -:orphan: - -IO module Description For ExperimentalPandasOnRay Execution -""""""""""""""""""""""""""""""""""""""""""""""""""""""""""" - -High-Level Module Overview -'''''''''''''''''''''''''' - -This module houses experimental functionality with pandas storage format and Ray -engine. This functionality is concentrated in the :py:class:`~modin.experimental.core.execution.ray.implementations.pandas_on_ray.io.io.ExperimentalPandasOnRayIO` -class, that contains methods, which extend typical pandas API to give user -more flexibility with IO operations. - -Usage Guide -''''''''''' - -In order to use the experimental features, just modify standard Modin import -statement as follows: - -.. code-block:: python - - # import modin.pandas as pd - import modin.experimental.pandas as pd - -Submodules Description -'''''''''''''''''''''' - -The ``modin.experimental.core.execution.ray.implementations.pandas_on_ray`` module primarily houses utils and -functions for the experimental IO class: - -* ``io.py`` - submodule containing IO class and parse functions, which are responsible - for data processing on the workers. - -Public API -'''''''''' - -.. autoclass:: modin.experimental.core.execution.ray.implementations.pandas_on_ray.io.io.ExperimentalPandasOnRayIO - :members: diff --git a/docs/flow/modin/experimental/core/execution/unidist/implementations/pandas_on_unidist/index.rst b/docs/flow/modin/experimental/core/execution/unidist/implementations/pandas_on_unidist/index.rst deleted file mode 100644 index 52043231ecc..00000000000 --- a/docs/flow/modin/experimental/core/execution/unidist/implementations/pandas_on_unidist/index.rst +++ /dev/null @@ -1,25 +0,0 @@ -:orphan: - -ExperimentalPandasOnUnidist Execution -===================================== - -`ExperimentalPandasOnUnidist` execution keeps the underlying mechanisms of :doc:`PandasOnUnidist ` -execution architecturally unchanged and adds experimental features of ``Data Transformation``, ``Data Ingress`` and ``Data Egress`` (e.g. :py:func:`~modin.experimental.pandas.read_pickle_distributed`). - -PandasOnUnidist and ExperimentalPandasOnUnidist differences ------------------------------------------------------------ - -- another Factory ``PandasOnUnidistFactory`` -> ``ExperimentalPandasOnUnidistFactory`` -- another IO class ``PandasOnUnidistIO`` -> ``ExperimentalPandasOnUnidistIO`` - -ExperimentalPandasOnUnidistIO classes and modules -------------------------------------------------- - -- :py:class:`~modin.experimental.core.execution.unidist.implementations.pandas_on_unidist.io.io.ExperimentalPandasOnUnidistIO` -- :py:class:`~modin.core.execution.dispatching.factories.factories.ExperimentalPandasOnUnidistFactory` -- :py:class:`~modin.experimental.core.io.text.csv_glob_dispatcher.ExperimentalCSVGlobDispatcher` -- :py:class:`~modin.experimental.core.io.sql.sql_dispatcher.ExperimentalSQLDispatcher` -- :py:class:`~modin.experimental.core.io.pickle.pickle_dispatcher.ExperimentalPickleDispatcher` -- :py:class:`~modin.experimental.core.io.text.custom_text_dispatcher.ExperimentalCustomTextDispatcher` -- :py:class:`~modin.core.storage_formats.pandas.parsers.PandasCSVGlobParser` -- :doc:`ExperimentalPandasOnUnidist IO module ` diff --git a/docs/flow/modin/experimental/core/execution/unidist/implementations/pandas_on_unidist/io/index.rst b/docs/flow/modin/experimental/core/execution/unidist/implementations/pandas_on_unidist/io/index.rst deleted file mode 100644 index b1fcbaff026..00000000000 --- a/docs/flow/modin/experimental/core/execution/unidist/implementations/pandas_on_unidist/io/index.rst +++ /dev/null @@ -1,38 +0,0 @@ -:orphan: - -IO module Description For ExperimentalPandasOnUnidist Execution -""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""" - -High-Level Module Overview -'''''''''''''''''''''''''' - -This module houses experimental functionality with pandas storage format and Unidist -engine. This functionality is concentrated in the :py:class:`~modin.experimental.core.execution.unidist.implementations.pandas_on_unidist.io.io.ExperimentalPandasOnUnidistIO` -class, that contains methods, which extend typical pandas API to give user -more flexibility with IO operations. - -Usage Guide -''''''''''' - -In order to use the experimental features, just modify standard Modin import -statement as follows: - -.. code-block:: python - - # import modin.pandas as pd - import modin.experimental.pandas as pd - -Submodules Description -'''''''''''''''''''''' - -The ``modin.experimental.core.execution.unidist.implementations.pandas_on_unidist`` module primarily houses utils and -functions for the experimental IO class: - -* ``io.py`` - submodule containing IO class and parse functions, which are responsible - for data processing on the workers. - -Public API -'''''''''' - -.. autoclass:: modin.experimental.core.execution.unidist.implementations.pandas_on_unidist.io.io.ExperimentalPandasOnUnidistIO - :members: diff --git a/docs/flow/modin/experimental/pandas.rst b/docs/flow/modin/experimental/pandas.rst index 7036a9c5c24..25d9d8f3bcc 100644 --- a/docs/flow/modin/experimental/pandas.rst +++ b/docs/flow/modin/experimental/pandas.rst @@ -13,4 +13,4 @@ Experimental API Reference .. autofunction:: read_csv_glob .. autofunction:: read_custom_text .. autofunction:: read_pickle_distributed -.. automethod:: modin.experimental.pandas.DataFrame.to_pickle_distributed +.. automethod:: modin.pandas.DataFrame.modin::to_pickle_distributed diff --git a/docs/supported_apis/dataframe_supported.rst b/docs/supported_apis/dataframe_supported.rst index 67cb3f9b603..bcd5a364221 100644 --- a/docs/supported_apis/dataframe_supported.rst +++ b/docs/supported_apis/dataframe_supported.rst @@ -424,7 +424,8 @@ default to pandas. +----------------------------+---------------------------+------------------------+----------------------------------------------------+ | ``to_period`` | `to_period`_ | D | | +----------------------------+---------------------------+------------------------+----------------------------------------------------+ -| ``to_pickle`` | `to_pickle`_ | D | Experimental implementation: to_pickle_distributed | +| ``to_pickle`` | `to_pickle`_ | D | Experimental implementation: | +| | | | DataFrame.modin.to_pickle_distributed | +----------------------------+---------------------------+------------------------+----------------------------------------------------+ | ``to_records`` | `to_records`_ | D | | +----------------------------+---------------------------+------------------------+----------------------------------------------------+ diff --git a/docs/usage_guide/advanced_usage/index.rst b/docs/usage_guide/advanced_usage/index.rst index 305f1a3da58..66560bebbe8 100644 --- a/docs/usage_guide/advanced_usage/index.rst +++ b/docs/usage_guide/advanced_usage/index.rst @@ -31,7 +31,7 @@ Modin also supports these experimental APIs on top of pandas that are under acti - :py:func:`~modin.experimental.pandas.read_sql` -- add optional parameters for the database connection - :py:func:`~modin.experimental.pandas.read_custom_text` -- read custom text data from file - :py:func:`~modin.experimental.pandas.read_pickle_distributed` -- read multiple files in a directory -- :py:meth:`~modin.experimental.pandas.DataFrame.to_pickle_distributed` -- write to multiple files in a directory +- :py:meth:`~modin.pandas.DataFrame.modin.to_pickle_distributed` -- write to multiple files in a directory DataFrame partitioning API -------------------------- diff --git a/modin/core/execution/dask/implementations/pandas_on_dask/io/io.py b/modin/core/execution/dask/implementations/pandas_on_dask/io/io.py index 23cf38d9301..090bbcffaee 100644 --- a/modin/core/execution/dask/implementations/pandas_on_dask/io/io.py +++ b/modin/core/execution/dask/implementations/pandas_on_dask/io/io.py @@ -31,6 +31,9 @@ SQLDispatcher, ) from modin.core.storage_formats.pandas.parsers import ( + ExperimentalCustomTextParser, + ExperimentalPandasPickleParser, + PandasCSVGlobParser, PandasCSVParser, PandasExcelParser, PandasFeatherParser, @@ -40,6 +43,12 @@ PandasSQLParser, ) from modin.core.storage_formats.pandas.query_compiler import PandasQueryCompiler +from modin.experimental.core.io import ( + ExperimentalCSVGlobDispatcher, + ExperimentalCustomTextDispatcher, + ExperimentalPickleDispatcher, + ExperimentalSQLDispatcher, +) class PandasOnDaskIO(BaseIO): @@ -74,5 +83,18 @@ def __make_write(*classes, build_args=build_args): to_sql = __make_write(SQLDispatcher) read_excel = __make_read(PandasExcelParser, ExcelDispatcher) + # experimental methods that don't exist in pandas + read_csv_glob = __make_read(PandasCSVGlobParser, ExperimentalCSVGlobDispatcher) + read_pickle_distributed = __make_read( + ExperimentalPandasPickleParser, ExperimentalPickleDispatcher + ) + to_pickle_distributed = __make_write(ExperimentalPickleDispatcher) + read_custom_text = __make_read( + ExperimentalCustomTextParser, ExperimentalCustomTextDispatcher + ) + read_sql_distributed = __make_read( + ExperimentalSQLDispatcher, build_args={**build_args, "base_read": read_sql} + ) + del __make_read # to not pollute class namespace del __make_write # to not pollute class namespace diff --git a/modin/core/execution/dispatching/factories/dispatcher.py b/modin/core/execution/dispatching/factories/dispatcher.py index 623bf354d45..5cdbf65f821 100644 --- a/modin/core/execution/dispatching/factories/dispatcher.py +++ b/modin/core/execution/dispatching/factories/dispatcher.py @@ -118,41 +118,33 @@ def get_factory(cls) -> factories.BaseFactory: return cls.__factory @classmethod - # FIXME: replace `_` parameter with `*args` - def _update_factory(cls, _): + def _update_factory(cls, *args): """ Update and prepare factory with a new one specified via Modin config. Parameters ---------- - _ : object + *args : iterable This parameters serves the compatibility purpose. Does not affect the result. """ factory_name = get_current_execution() + "Factory" + experimental_factory_name = "Experimental" + factory_name try: - cls.__factory = getattr(factories, factory_name) + cls.__factory = getattr(factories, factory_name, None) or getattr( + factories, experimental_factory_name + ) except AttributeError: - if factory_name == "ExperimentalOmnisciOnRayFactory": + if not IsExperimental.get(): + # allow missing factories in experimental mode only msg = ( - "OmniSci storage format no longer needs Ray engine; " - + "please specify MODIN_ENGINE='native'" + "Cannot find neither factory {} nor experimental factory {}. " + + "Potential reason might be incorrect environment variable value for " + + f"{StorageFormat.varname} or {Engine.varname}" + ) + raise FactoryNotFoundError( + msg.format(factory_name, experimental_factory_name) ) - raise FactoryNotFoundError(msg) - if not IsExperimental.get(): - # allow missing factories in experimenal mode only - if hasattr(factories, "Experimental" + factory_name): - msg = ( - "{0} is only accessible through the experimental API.\nRun " - + "`import modin.experimental.pandas as pd` to use {0}." - ) - else: - msg = ( - "Cannot find factory {}. " - + "Potential reason might be incorrect environment variable value for " - + f"{StorageFormat.varname} or {Engine.varname}" - ) - raise FactoryNotFoundError(msg.format(factory_name)) cls.__factory = StubFactory.set_failing_name(factory_name) else: try: @@ -200,14 +192,12 @@ def read_csv(cls, **kwargs): return cls.get_factory()._read_csv(**kwargs) @classmethod - @_inherit_docstrings(factories.ExperimentalPandasOnRayFactory._read_csv_glob) + @_inherit_docstrings(factories.PandasOnRayFactory._read_csv_glob) def read_csv_glob(cls, **kwargs): return cls.get_factory()._read_csv_glob(**kwargs) @classmethod - @_inherit_docstrings( - factories.ExperimentalPandasOnRayFactory._read_pickle_distributed - ) + @_inherit_docstrings(factories.PandasOnRayFactory._read_pickle_distributed) def read_pickle_distributed(cls, **kwargs): return cls.get_factory()._read_pickle_distributed(**kwargs) @@ -266,6 +256,11 @@ def read_pickle(cls, **kwargs): def read_sql(cls, **kwargs): return cls.get_factory()._read_sql(**kwargs) + @classmethod + @_inherit_docstrings(factories.PandasOnRayFactory._read_sql_distributed) + def read_sql_distributed(cls, **kwargs): + return cls.get_factory()._read_sql_distributed(**kwargs) + @classmethod @_inherit_docstrings(factories.BaseFactory._read_fwf) def read_fwf(cls, **kwargs): @@ -297,14 +292,12 @@ def to_pickle(cls, *args, **kwargs): return cls.get_factory()._to_pickle(*args, **kwargs) @classmethod - @_inherit_docstrings( - factories.ExperimentalPandasOnRayFactory._to_pickle_distributed - ) + @_inherit_docstrings(factories.PandasOnRayFactory._to_pickle_distributed) def to_pickle_distributed(cls, *args, **kwargs): return cls.get_factory()._to_pickle_distributed(*args, **kwargs) @classmethod - @_inherit_docstrings(factories.ExperimentalPandasOnRayFactory._read_custom_text) + @_inherit_docstrings(factories.PandasOnRayFactory._read_custom_text) def read_custom_text(cls, **kwargs): return cls.get_factory()._read_custom_text(**kwargs) diff --git a/modin/core/execution/dispatching/factories/factories.py b/modin/core/execution/dispatching/factories/factories.py index 56f6d2c6dbf..d4e2567099f 100644 --- a/modin/core/execution/dispatching/factories/factories.py +++ b/modin/core/execution/dispatching/factories/factories.py @@ -26,9 +26,9 @@ import pandas from pandas.util._decorators import doc -from modin.config import Engine +from modin.config import IsExperimental from modin.core.io import BaseIO -from modin.utils import _inherit_docstrings, get_current_execution +from modin.utils import get_current_execution _doc_abstract_factory_class = """ Abstract {role} factory which allows to override the IO module easily. @@ -93,10 +93,10 @@ types_dictionary = {"pandas": {"category": pandas.CategoricalDtype}} -supported_execution = ( - "ExperimentalPandasOnRay", - "ExperimentalPandasOnUnidist", - "ExperimentalPandasOnDask", +supported_executions = ( + "PandasOnRay", + "PandasOnUnidist", + "PandasOnDask", ) @@ -427,86 +427,7 @@ def _to_parquet(cls, *args, **kwargs): """ return cls.io_cls.to_parquet(*args, **kwargs) - -@doc(_doc_factory_class, execution_name="cuDFOnRay") -class CudfOnRayFactory(BaseFactory): - @classmethod - @doc(_doc_factory_prepare_method, io_module_name="``cuDFOnRayIO``") - def prepare(cls): - from modin.core.execution.ray.implementations.cudf_on_ray.io import cuDFOnRayIO - - cls.io_cls = cuDFOnRayIO - - -@doc(_doc_factory_class, execution_name="PandasOnRay") -class PandasOnRayFactory(BaseFactory): - @classmethod - @doc(_doc_factory_prepare_method, io_module_name="``PandasOnRayIO``") - def prepare(cls): - from modin.core.execution.ray.implementations.pandas_on_ray.io import ( - PandasOnRayIO, - ) - - cls.io_cls = PandasOnRayIO - - -@doc(_doc_factory_class, execution_name="PandasOnPython") -class PandasOnPythonFactory(BaseFactory): - @classmethod - @doc(_doc_factory_prepare_method, io_module_name="``PandasOnPythonIO``") - def prepare(cls): - from modin.core.execution.python.implementations.pandas_on_python.io import ( - PandasOnPythonIO, - ) - - cls.io_cls = PandasOnPythonIO - - -@doc(_doc_factory_class, execution_name="PandasOnDask") -class PandasOnDaskFactory(BaseFactory): - @classmethod - @doc(_doc_factory_prepare_method, io_module_name="``PandasOnDaskIO``") - def prepare(cls): - from modin.core.execution.dask.implementations.pandas_on_dask.io import ( - PandasOnDaskIO, - ) - - cls.io_cls = PandasOnDaskIO - - -@doc(_doc_abstract_factory_class, role="experimental") -class ExperimentalBaseFactory(BaseFactory): - @classmethod - @_inherit_docstrings(BaseFactory._read_sql) - def _read_sql(cls, **kwargs): - supported_engines = ("Ray", "Unidist", "Dask") - if Engine.get() not in supported_engines: - if "partition_column" in kwargs: - if kwargs["partition_column"] is not None: - warnings.warn( - f"Distributed read_sql() was only implemented for {', '.join(supported_engines)} engines." - ) - del kwargs["partition_column"] - if "lower_bound" in kwargs: - if kwargs["lower_bound"] is not None: - warnings.warn( - f"Distributed read_sql() was only implemented for {', '.join(supported_engines)} engines." - ) - del kwargs["lower_bound"] - if "upper_bound" in kwargs: - if kwargs["upper_bound"] is not None: - warnings.warn( - f"Distributed read_sql() was only implemented for {', '.join(supported_engines)} engines." - ) - del kwargs["upper_bound"] - if "max_sessions" in kwargs: - if kwargs["max_sessions"] is not None: - warnings.warn( - f"Distributed read_sql() was only implemented for {', '.join(supported_engines)} engines." - ) - del kwargs["max_sessions"] - return cls.io_cls.read_sql(**kwargs) - + # experimental methods that don't exist in pandas @classmethod @doc( _doc_io_method_raw_template, @@ -515,7 +436,7 @@ def _read_sql(cls, **kwargs): ) def _read_csv_glob(cls, **kwargs): current_execution = get_current_execution() - if current_execution not in supported_execution: + if current_execution not in supported_executions: raise NotImplementedError( f"`_read_csv_glob()` is not implemented for {current_execution} execution." ) @@ -529,12 +450,39 @@ def _read_csv_glob(cls, **kwargs): ) def _read_pickle_distributed(cls, **kwargs): current_execution = get_current_execution() - if current_execution not in supported_execution: + if current_execution not in supported_executions: raise NotImplementedError( f"`_read_pickle_distributed()` is not implemented for {current_execution} execution." ) return cls.io_cls.read_pickle_distributed(**kwargs) + @classmethod + @doc( + _doc_io_method_raw_template, + source="SQL files", + params=_doc_io_method_kwargs_params, + ) + def _read_sql_distributed(cls, **kwargs): + current_execution = get_current_execution() + if current_execution not in supported_executions: + extra_parameters = ( + "partition_column", + "lower_bound", + "upper_bound", + "max_sessions", + ) + if any( + param in kwargs and kwargs[param] is not None + for param in extra_parameters + ): + warnings.warn( + f"Distributed read_sql() was only implemented for {', '.join(supported_executions)} executions." + ) + for param in extra_parameters: + del kwargs[param] + return cls.io_cls.read_sql(**kwargs) + return cls.io_cls.read_sql_distributed(**kwargs) + @classmethod @doc( _doc_io_method_raw_template, @@ -543,7 +491,7 @@ def _read_pickle_distributed(cls, **kwargs): ) def _read_custom_text(cls, **kwargs): current_execution = get_current_execution() - if current_execution not in supported_execution: + if current_execution not in supported_executions: raise NotImplementedError( f"`_read_custom_text()` is not implemented for {current_execution} execution." ) @@ -562,40 +510,64 @@ def _to_pickle_distributed(cls, *args, **kwargs): Arguments to the writer method. """ current_execution = get_current_execution() - if current_execution not in supported_execution: + if current_execution not in supported_executions: raise NotImplementedError( f"`_to_pickle_distributed()` is not implemented for {current_execution} execution." ) return cls.io_cls.to_pickle_distributed(*args, **kwargs) -@doc(_doc_factory_class, execution_name="experimental PandasOnRay") -class ExperimentalPandasOnRayFactory(ExperimentalBaseFactory, PandasOnRayFactory): +@doc(_doc_factory_class, execution_name="PandasOnRay") +class PandasOnRayFactory(BaseFactory): @classmethod - @doc(_doc_factory_prepare_method, io_module_name="``ExperimentalPandasOnRayIO``") + @doc(_doc_factory_prepare_method, io_module_name="``PandasOnRayIO``") def prepare(cls): - from modin.experimental.core.execution.ray.implementations.pandas_on_ray.io import ( - ExperimentalPandasOnRayIO, + from modin.core.execution.ray.implementations.pandas_on_ray.io import ( + PandasOnRayIO, ) - cls.io_cls = ExperimentalPandasOnRayIO + cls.io_cls = PandasOnRayIO -@doc(_doc_factory_class, execution_name="experimental PandasOnDask") -class ExperimentalPandasOnDaskFactory(ExperimentalBaseFactory, PandasOnDaskFactory): +@doc(_doc_factory_class, execution_name="PandasOnPython") +class PandasOnPythonFactory(BaseFactory): @classmethod - @doc(_doc_factory_prepare_method, io_module_name="``ExperimentalPandasOnDaskIO``") + @doc(_doc_factory_prepare_method, io_module_name="``PandasOnPythonIO``") def prepare(cls): - from modin.experimental.core.execution.dask.implementations.pandas_on_dask.io import ( - ExperimentalPandasOnDaskIO, + from modin.core.execution.python.implementations.pandas_on_python.io import ( + PandasOnPythonIO, ) - cls.io_cls = ExperimentalPandasOnDaskIO + cls.io_cls = PandasOnPythonIO -@doc(_doc_factory_class, execution_name="experimental PandasOnPython") -class ExperimentalPandasOnPythonFactory(ExperimentalBaseFactory, PandasOnPythonFactory): - pass +@doc(_doc_factory_class, execution_name="PandasOnDask") +class PandasOnDaskFactory(BaseFactory): + @classmethod + @doc(_doc_factory_prepare_method, io_module_name="``PandasOnDaskIO``") + def prepare(cls): + from modin.core.execution.dask.implementations.pandas_on_dask.io import ( + PandasOnDaskIO, + ) + + cls.io_cls = PandasOnDaskIO + + +@doc(_doc_factory_class, execution_name="PandasOnUnidist") +class PandasOnUnidistFactory(BaseFactory): + @classmethod + @doc(_doc_factory_prepare_method, io_module_name="``PandasOnUnidistIO``") + def prepare(cls): + from modin.core.execution.unidist.implementations.pandas_on_unidist.io import ( + PandasOnUnidistIO, + ) + + cls.io_cls = PandasOnUnidistIO + + +# EXPERIMENTAL FACTORIES +# Factories that operate only in experimental mode. They provide access to executions +# that have little coverage of implemented functionality or are not stable enough. @doc(_doc_factory_class, execution_name="experimental PyarrowOnRay") @@ -607,6 +579,9 @@ def prepare(cls): PyarrowOnRayIO, ) + if not IsExperimental.get(): + raise ValueError("'PyarrowOnRay' only works in experimental mode.") + cls.io_cls = PyarrowOnRayIO @@ -619,32 +594,20 @@ def prepare(cls): HdkOnNativeIO, ) + if not IsExperimental.get(): + raise ValueError("'HdkOnNative' only works in experimental mode.") + cls.io_cls = HdkOnNativeIO -@doc(_doc_factory_class, execution_name="PandasOnUnidist") -class PandasOnUnidistFactory(BaseFactory): +@doc(_doc_factory_class, execution_name="cuDFOnRay") +class ExperimentalCudfOnRayFactory(BaseFactory): @classmethod - @doc(_doc_factory_prepare_method, io_module_name="``PandasOnUnidistIO``") + @doc(_doc_factory_prepare_method, io_module_name="``cuDFOnRayIO``") def prepare(cls): - from modin.core.execution.unidist.implementations.pandas_on_unidist.io import ( - PandasOnUnidistIO, - ) - - cls.io_cls = PandasOnUnidistIO - + from modin.core.execution.ray.implementations.cudf_on_ray.io import cuDFOnRayIO -@doc(_doc_factory_class, execution_name="experimental PandasOnUnidist") -class ExperimentalPandasOnUnidistFactory( - ExperimentalBaseFactory, PandasOnUnidistFactory -): - @classmethod - @doc( - _doc_factory_prepare_method, io_module_name="``ExperimentalPandasOnUnidistIO``" - ) - def prepare(cls): - from modin.experimental.core.execution.unidist.implementations.pandas_on_unidist.io import ( - ExperimentalPandasOnUnidistIO, - ) + if not IsExperimental.get(): + raise ValueError("'CudfOnRay' only works in experimental mode.") - cls.io_cls = ExperimentalPandasOnUnidistIO + cls.io_cls = cuDFOnRayIO diff --git a/modin/core/execution/dispatching/factories/test/test_dispatcher.py b/modin/core/execution/dispatching/factories/test/test_dispatcher.py index 28bb2b95b4d..a044e1aa386 100644 --- a/modin/core/execution/dispatching/factories/test/test_dispatcher.py +++ b/modin/core/execution/dispatching/factories/test/test_dispatcher.py @@ -113,8 +113,8 @@ def test_factory_switch(): def test_engine_wrong_factory(): with pytest.raises(FactoryNotFoundError): - with _switch_value(StorageFormat, "Pyarrow"): - with _switch_value(Engine, "Dask"): + with _switch_value(Engine, "Dask"): + with _switch_value(StorageFormat, "Pyarrow"): pass diff --git a/modin/core/execution/ray/implementations/pandas_on_ray/io/io.py b/modin/core/execution/ray/implementations/pandas_on_ray/io/io.py index c6409bd3e17..f08dc739716 100644 --- a/modin/core/execution/ray/implementations/pandas_on_ray/io/io.py +++ b/modin/core/execution/ray/implementations/pandas_on_ray/io/io.py @@ -30,6 +30,9 @@ SQLDispatcher, ) from modin.core.storage_formats.pandas.parsers import ( + ExperimentalCustomTextParser, + ExperimentalPandasPickleParser, + PandasCSVGlobParser, PandasCSVParser, PandasExcelParser, PandasFeatherParser, @@ -39,6 +42,12 @@ PandasSQLParser, ) from modin.core.storage_formats.pandas.query_compiler import PandasQueryCompiler +from modin.experimental.core.io import ( + ExperimentalCSVGlobDispatcher, + ExperimentalCustomTextDispatcher, + ExperimentalPickleDispatcher, + ExperimentalSQLDispatcher, +) from ..dataframe import PandasOnRayDataframe from ..partitioning import PandasOnRayDataframePartition @@ -76,6 +85,19 @@ def __make_write(*classes, build_args=build_args): to_sql = __make_write(SQLDispatcher) read_excel = __make_read(PandasExcelParser, ExcelDispatcher) + # experimental methods that don't exist in pandas + read_csv_glob = __make_read(PandasCSVGlobParser, ExperimentalCSVGlobDispatcher) + read_pickle_distributed = __make_read( + ExperimentalPandasPickleParser, ExperimentalPickleDispatcher + ) + to_pickle_distributed = __make_write(ExperimentalPickleDispatcher) + read_custom_text = __make_read( + ExperimentalCustomTextParser, ExperimentalCustomTextDispatcher + ) + read_sql_distributed = __make_read( + ExperimentalSQLDispatcher, build_args={**build_args, "base_read": read_sql} + ) + del __make_read # to not pollute class namespace del __make_write # to not pollute class namespace diff --git a/modin/core/execution/unidist/implementations/pandas_on_unidist/io/io.py b/modin/core/execution/unidist/implementations/pandas_on_unidist/io/io.py index df90e6ac068..ff5fbf05a29 100644 --- a/modin/core/execution/unidist/implementations/pandas_on_unidist/io/io.py +++ b/modin/core/execution/unidist/implementations/pandas_on_unidist/io/io.py @@ -29,6 +29,9 @@ SQLDispatcher, ) from modin.core.storage_formats.pandas.parsers import ( + ExperimentalCustomTextParser, + ExperimentalPandasPickleParser, + PandasCSVGlobParser, PandasCSVParser, PandasExcelParser, PandasFeatherParser, @@ -38,6 +41,12 @@ PandasSQLParser, ) from modin.core.storage_formats.pandas.query_compiler import PandasQueryCompiler +from modin.experimental.core.io import ( + ExperimentalCSVGlobDispatcher, + ExperimentalCustomTextDispatcher, + ExperimentalPickleDispatcher, + ExperimentalSQLDispatcher, +) from ..dataframe import PandasOnUnidistDataframe from ..partitioning import PandasOnUnidistDataframePartition @@ -75,6 +84,19 @@ def __make_write(*classes, build_args=build_args): to_sql = __make_write(SQLDispatcher) read_excel = __make_read(PandasExcelParser, ExcelDispatcher) + # experimental methods that don't exist in pandas + read_csv_glob = __make_read(PandasCSVGlobParser, ExperimentalCSVGlobDispatcher) + read_pickle_distributed = __make_read( + ExperimentalPandasPickleParser, ExperimentalPickleDispatcher + ) + to_pickle_distributed = __make_write(ExperimentalPickleDispatcher) + read_custom_text = __make_read( + ExperimentalCustomTextParser, ExperimentalCustomTextDispatcher + ) + read_sql_distributed = __make_read( + ExperimentalSQLDispatcher, build_args={**build_args, "base_read": read_sql} + ) + del __make_read # to not pollute class namespace del __make_write # to not pollute class namespace diff --git a/modin/experimental/core/execution/dask/implementations/pandas_on_dask/io/__init__.py b/modin/experimental/core/execution/dask/implementations/pandas_on_dask/io/__init__.py deleted file mode 100644 index ae42d666b91..00000000000 --- a/modin/experimental/core/execution/dask/implementations/pandas_on_dask/io/__init__.py +++ /dev/null @@ -1,18 +0,0 @@ -# Licensed to Modin Development Team under one or more contributor license agreements. -# See the NOTICE file distributed with this work for additional information regarding -# copyright ownership. The Modin Development Team licenses this file to you under the -# Apache License, Version 2.0 (the "License"); you may not use this file except in -# compliance with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software distributed under -# the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF -# ANY KIND, either express or implied. See the License for the specific language -# governing permissions and limitations under the License. - -"""Experimental Base IO classes optimized for pandas on Dask execution.""" - -from .io import ExperimentalPandasOnDaskIO - -__all__ = ["ExperimentalPandasOnDaskIO"] diff --git a/modin/experimental/core/execution/dask/implementations/pandas_on_dask/io/io.py b/modin/experimental/core/execution/dask/implementations/pandas_on_dask/io/io.py deleted file mode 100644 index bfc628eebb8..00000000000 --- a/modin/experimental/core/execution/dask/implementations/pandas_on_dask/io/io.py +++ /dev/null @@ -1,77 +0,0 @@ -# Licensed to Modin Development Team under one or more contributor license agreements. -# See the NOTICE file distributed with this work for additional information regarding -# copyright ownership. The Modin Development Team licenses this file to you under the -# Apache License, Version 2.0 (the "License"); you may not use this file except in -# compliance with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software distributed under -# the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF -# ANY KIND, either express or implied. See the License for the specific language -# governing permissions and limitations under the License. - -""" -Module houses experimental IO classes and parser functions needed for these classes. - -Any function or class can be considered experimental API if it is not strictly replicating existent -Query Compiler API, even if it is only extending the API. -""" - -from modin.core.execution.dask.common import DaskWrapper -from modin.core.execution.dask.implementations.pandas_on_dask.dataframe import ( - PandasOnDaskDataframe, -) -from modin.core.execution.dask.implementations.pandas_on_dask.io import PandasOnDaskIO -from modin.core.execution.dask.implementations.pandas_on_dask.partitioning import ( - PandasOnDaskDataframePartition, -) -from modin.core.storage_formats.pandas.parsers import ( - ExperimentalCustomTextParser, - ExperimentalPandasPickleParser, - PandasCSVGlobParser, -) -from modin.core.storage_formats.pandas.query_compiler import PandasQueryCompiler -from modin.experimental.core.io import ( - ExperimentalCSVGlobDispatcher, - ExperimentalCustomTextDispatcher, - ExperimentalPickleDispatcher, - ExperimentalSQLDispatcher, -) - - -class ExperimentalPandasOnDaskIO(PandasOnDaskIO): - """ - Class for handling experimental IO functionality with pandas storage format and Dask engine. - - ``ExperimentalPandasOnDaskIO`` inherits some util functions and unmodified IO functions - from ``PandasOnDaskIO`` class. - """ - - build_args = dict( - frame_partition_cls=PandasOnDaskDataframePartition, - query_compiler_cls=PandasQueryCompiler, - frame_cls=PandasOnDaskDataframe, - base_io=PandasOnDaskIO, - ) - - def __make_read(*classes, build_args=build_args): - # used to reduce code duplication - return type("", (DaskWrapper, *classes), build_args).read - - def __make_write(*classes, build_args=build_args): - # used to reduce code duplication - return type("", (DaskWrapper, *classes), build_args).write - - read_csv_glob = __make_read(PandasCSVGlobParser, ExperimentalCSVGlobDispatcher) - read_pickle_distributed = __make_read( - ExperimentalPandasPickleParser, ExperimentalPickleDispatcher - ) - to_pickle_distributed = __make_write(ExperimentalPickleDispatcher) - read_custom_text = __make_read( - ExperimentalCustomTextParser, ExperimentalCustomTextDispatcher - ) - read_sql = __make_read(ExperimentalSQLDispatcher) - - del __make_read # to not pollute class namespace - del __make_write # to not pollute class namespace diff --git a/modin/experimental/core/execution/ray/implementations/pandas_on_ray/__init__.py b/modin/experimental/core/execution/ray/implementations/pandas_on_ray/__init__.py deleted file mode 100644 index bccf5e24948..00000000000 --- a/modin/experimental/core/execution/ray/implementations/pandas_on_ray/__init__.py +++ /dev/null @@ -1,14 +0,0 @@ -# Licensed to Modin Development Team under one or more contributor license agreements. -# See the NOTICE file distributed with this work for additional information regarding -# copyright ownership. The Modin Development Team licenses this file to you under the -# Apache License, Version 2.0 (the "License"); you may not use this file except in -# compliance with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software distributed under -# the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF -# ANY KIND, either express or implied. See the License for the specific language -# governing permissions and limitations under the License. - -"""Experimental functionality related to Ray execution engine and optimized for pandas storage format.""" diff --git a/modin/experimental/core/execution/ray/implementations/pandas_on_ray/io/__init__.py b/modin/experimental/core/execution/ray/implementations/pandas_on_ray/io/__init__.py deleted file mode 100644 index 50871daeadd..00000000000 --- a/modin/experimental/core/execution/ray/implementations/pandas_on_ray/io/__init__.py +++ /dev/null @@ -1,18 +0,0 @@ -# Licensed to Modin Development Team under one or more contributor license agreements. -# See the NOTICE file distributed with this work for additional information regarding -# copyright ownership. The Modin Development Team licenses this file to you under the -# Apache License, Version 2.0 (the "License"); you may not use this file except in -# compliance with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software distributed under -# the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF -# ANY KIND, either express or implied. See the License for the specific language -# governing permissions and limitations under the License. - -"""Experimental Base IO classes optimized for pandas on Ray execution.""" - -from .io import ExperimentalPandasOnRayIO - -__all__ = ["ExperimentalPandasOnRayIO"] diff --git a/modin/experimental/core/execution/ray/implementations/pandas_on_ray/io/io.py b/modin/experimental/core/execution/ray/implementations/pandas_on_ray/io/io.py deleted file mode 100644 index 31fbd6d92b3..00000000000 --- a/modin/experimental/core/execution/ray/implementations/pandas_on_ray/io/io.py +++ /dev/null @@ -1,77 +0,0 @@ -# Licensed to Modin Development Team under one or more contributor license agreements. -# See the NOTICE file distributed with this work for additional information regarding -# copyright ownership. The Modin Development Team licenses this file to you under the -# Apache License, Version 2.0 (the "License"); you may not use this file except in -# compliance with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software distributed under -# the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF -# ANY KIND, either express or implied. See the License for the specific language -# governing permissions and limitations under the License. - -""" -Module houses experimental IO classes and parser functions needed for these classes. - -Any function or class can be considered experimental API if it is not strictly replicating existent -Query Compiler API, even if it is only extending the API. -""" - -from modin.core.execution.ray.common import RayWrapper -from modin.core.execution.ray.implementations.pandas_on_ray.dataframe import ( - PandasOnRayDataframe, -) -from modin.core.execution.ray.implementations.pandas_on_ray.io import PandasOnRayIO -from modin.core.execution.ray.implementations.pandas_on_ray.partitioning import ( - PandasOnRayDataframePartition, -) -from modin.core.storage_formats.pandas.parsers import ( - ExperimentalCustomTextParser, - ExperimentalPandasPickleParser, - PandasCSVGlobParser, -) -from modin.core.storage_formats.pandas.query_compiler import PandasQueryCompiler -from modin.experimental.core.io import ( - ExperimentalCSVGlobDispatcher, - ExperimentalCustomTextDispatcher, - ExperimentalPickleDispatcher, - ExperimentalSQLDispatcher, -) - - -class ExperimentalPandasOnRayIO(PandasOnRayIO): - """ - Class for handling experimental IO functionality with pandas storage format and Ray engine. - - ``ExperimentalPandasOnRayIO`` inherits some util functions and unmodified IO functions - from ``PandasOnRayIO`` class. - """ - - build_args = dict( - frame_partition_cls=PandasOnRayDataframePartition, - query_compiler_cls=PandasQueryCompiler, - frame_cls=PandasOnRayDataframe, - base_io=PandasOnRayIO, - ) - - def __make_read(*classes, build_args=build_args): - # used to reduce code duplication - return type("", (RayWrapper, *classes), build_args).read - - def __make_write(*classes, build_args=build_args): - # used to reduce code duplication - return type("", (RayWrapper, *classes), build_args).write - - read_csv_glob = __make_read(PandasCSVGlobParser, ExperimentalCSVGlobDispatcher) - read_pickle_distributed = __make_read( - ExperimentalPandasPickleParser, ExperimentalPickleDispatcher - ) - to_pickle_distributed = __make_write(ExperimentalPickleDispatcher) - read_custom_text = __make_read( - ExperimentalCustomTextParser, ExperimentalCustomTextDispatcher - ) - read_sql = __make_read(ExperimentalSQLDispatcher) - - del __make_read # to not pollute class namespace - del __make_write # to not pollute class namespace diff --git a/modin/experimental/core/execution/unidist/implementations/pandas_on_unidist/io/__init__.py b/modin/experimental/core/execution/unidist/implementations/pandas_on_unidist/io/__init__.py deleted file mode 100644 index fd611287f7c..00000000000 --- a/modin/experimental/core/execution/unidist/implementations/pandas_on_unidist/io/__init__.py +++ /dev/null @@ -1,18 +0,0 @@ -# Licensed to Modin Development Team under one or more contributor license agreements. -# See the NOTICE file distributed with this work for additional information regarding -# copyright ownership. The Modin Development Team licenses this file to you under the -# Apache License, Version 2.0 (the "License"); you may not use this file except in -# compliance with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software distributed under -# the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF -# ANY KIND, either express or implied. See the License for the specific language -# governing permissions and limitations under the License. - -"""Experimental Base IO classes optimized for pandas on unidist execution.""" - -from .io import ExperimentalPandasOnUnidistIO - -__all__ = ["ExperimentalPandasOnUnidistIO"] diff --git a/modin/experimental/core/execution/unidist/implementations/pandas_on_unidist/io/io.py b/modin/experimental/core/execution/unidist/implementations/pandas_on_unidist/io/io.py deleted file mode 100644 index c94b0621a4d..00000000000 --- a/modin/experimental/core/execution/unidist/implementations/pandas_on_unidist/io/io.py +++ /dev/null @@ -1,79 +0,0 @@ -# Licensed to Modin Development Team under one or more contributor license agreements. -# See the NOTICE file distributed with this work for additional information regarding -# copyright ownership. The Modin Development Team licenses this file to you under the -# Apache License, Version 2.0 (the "License"); you may not use this file except in -# compliance with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software distributed under -# the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF -# ANY KIND, either express or implied. See the License for the specific language -# governing permissions and limitations under the License. - -""" -Module houses experimental IO classes and parser functions needed for these classes. - -Any function or class can be considered experimental API if it is not strictly replicating existent -Query Compiler API, even if it is only extending the API. -""" - -from modin.core.execution.unidist.common import UnidistWrapper -from modin.core.execution.unidist.implementations.pandas_on_unidist.dataframe import ( - PandasOnUnidistDataframe, -) -from modin.core.execution.unidist.implementations.pandas_on_unidist.io import ( - PandasOnUnidistIO, -) -from modin.core.execution.unidist.implementations.pandas_on_unidist.partitioning import ( - PandasOnUnidistDataframePartition, -) -from modin.core.storage_formats.pandas.parsers import ( - ExperimentalCustomTextParser, - ExperimentalPandasPickleParser, - PandasCSVGlobParser, -) -from modin.core.storage_formats.pandas.query_compiler import PandasQueryCompiler -from modin.experimental.core.io import ( - ExperimentalCSVGlobDispatcher, - ExperimentalCustomTextDispatcher, - ExperimentalPickleDispatcher, - ExperimentalSQLDispatcher, -) - - -class ExperimentalPandasOnUnidistIO(PandasOnUnidistIO): - """ - Class for handling experimental IO functionality with pandas storage format and unidist engine. - - ``ExperimentalPandasOnUnidistIO`` inherits some util functions and unmodified IO functions - from ``PandasOnUnidistIO`` class. - """ - - build_args = dict( - frame_partition_cls=PandasOnUnidistDataframePartition, - query_compiler_cls=PandasQueryCompiler, - frame_cls=PandasOnUnidistDataframe, - base_io=PandasOnUnidistIO, - ) - - def __make_read(*classes, build_args=build_args): - # used to reduce code duplication - return type("", (UnidistWrapper, *classes), build_args).read - - def __make_write(*classes, build_args=build_args): - # used to reduce code duplication - return type("", (UnidistWrapper, *classes), build_args).write - - read_csv_glob = __make_read(PandasCSVGlobParser, ExperimentalCSVGlobDispatcher) - read_pickle_distributed = __make_read( - ExperimentalPandasPickleParser, ExperimentalPickleDispatcher - ) - to_pickle_distributed = __make_write(ExperimentalPickleDispatcher) - read_custom_text = __make_read( - ExperimentalCustomTextParser, ExperimentalCustomTextDispatcher - ) - read_sql = __make_read(ExperimentalSQLDispatcher) - - del __make_read # to not pollute class namespace - del __make_write # to not pollute class namespace diff --git a/modin/experimental/core/io/sql/sql_dispatcher.py b/modin/experimental/core/io/sql/sql_dispatcher.py index bc6285c4107..d2fe256c131 100644 --- a/modin/experimental/core/io/sql/sql_dispatcher.py +++ b/modin/experimental/core/io/sql/sql_dispatcher.py @@ -72,7 +72,7 @@ def _read( message = "Defaulting to Modin core implementation; \ 'partition_column', 'lower_bound', 'upper_bound' must be different from None" warnings.warn(message) - return cls.base_io.read_sql( + return cls.base_read( sql, con, index_col, diff --git a/modin/experimental/pandas/__init__.py b/modin/experimental/pandas/__init__.py index 0deba579ecb..091bf260967 100644 --- a/modin/experimental/pandas/__init__.py +++ b/modin/experimental/pandas/__init__.py @@ -32,6 +32,8 @@ >>> df = pd.read_csv_glob("data*.csv") """ +import functools + from modin.config import IsExperimental IsExperimental.put(True) @@ -48,10 +50,17 @@ to_pickle_distributed, ) -setattr(DataFrame, "to_pickle_distributed", to_pickle_distributed) # noqa: F405 +old_to_pickle_distributed = to_pickle_distributed -warnings.warn( - "Thank you for using the Modin Experimental pandas API." - + "\nPlease note that some of these APIs deviate from pandas in order to " - + "provide improved performance." -) + +@functools.wraps(to_pickle_distributed) +def to_pickle_distributed(*args, **kwargs): + warnings.warn( + "`DataFrame.to_pickle_distributed` is deprecated and will be removed in a future version. " + + "Please use `DataFrame.modin.to_pickle_distributed` instead.", + category=FutureWarning, + ) + return old_to_pickle_distributed(*args, **kwargs) + + +setattr(DataFrame, "to_pickle_distributed", to_pickle_distributed) # noqa: F405 diff --git a/modin/experimental/pandas/io.py b/modin/experimental/pandas/io.py index fb3b1df1c42..532b1f7beac 100644 --- a/modin/experimental/pandas/io.py +++ b/modin/experimental/pandas/io.py @@ -115,7 +115,7 @@ def read_sql( assert IsExperimental.get(), "This only works in experimental mode" - result = FactoryDispatcher.read_sql(**kwargs) + result = FactoryDispatcher.read_sql_distributed(**kwargs) if isinstance(result, BaseQueryCompiler): return DataFrame(query_compiler=result) return (DataFrame(query_compiler=qc) for qc in result) @@ -316,7 +316,7 @@ def read_pickle_distributed( This experimental feature provides parallel reading from multiple pickle files which are defined by glob pattern. The files must contain parts of one dataframe, which can be - obtained, for example, by `to_pickle_distributed` function. + obtained, for example, by `DataFrame.modin.to_pickle_distributed` function. Parameters ---------- diff --git a/modin/experimental/pandas/test/test_io_exp.py b/modin/experimental/pandas/test/test_io_exp.py index 4bfd8c9b7ab..693dd170bb2 100644 --- a/modin/experimental/pandas/test/test_io_exp.py +++ b/modin/experimental/pandas/test/test_io_exp.py @@ -260,7 +260,7 @@ def test_distributed_pickling(filename, compression): if filename_param == test_default_to_pickle_filename else contextlib.nullcontext() ): - df.to_pickle_distributed(filename, compression=compression) + df.modin.to_pickle_distributed(filename, compression=compression) pickled_df = pd.read_pickle_distributed(filename, compression=compression) df_equals(pickled_df, df) diff --git a/modin/pandas/accessor.py b/modin/pandas/accessor.py index 611175166cf..3d09424f160 100644 --- a/modin/pandas/accessor.py +++ b/modin/pandas/accessor.py @@ -21,7 +21,10 @@ CachedAccessor implements API of pandas.core.accessor.CachedAccessor """ +import pickle + import pandas +from pandas._typing import CompressionOptions, StorageOptions from pandas.core.dtypes.dtypes import SparseDtype from modin import pandas as pd @@ -191,3 +194,66 @@ def __get__(self, obj, cls): accessor_obj = self._accessor(obj) object.__setattr__(obj, self._name, accessor_obj) return accessor_obj + + +class ExperimentalFunctions: + """ + Namespace class for accessing experimental Modin functions. + + Parameters + ---------- + data : DataFrame or Series + Object to operate on. + """ + + def __init__(self, data): + self._data = data + + def to_pickle_distributed( + self, + filepath_or_buffer, + compression: CompressionOptions = "infer", + protocol: int = pickle.HIGHEST_PROTOCOL, + storage_options: StorageOptions = None, + ): + """ + Pickle (serialize) object to file. + + This experimental feature provides parallel writing into multiple pickle files which are + defined by glob pattern, otherwise (without glob pattern) default pandas implementation is used. + + Parameters + ---------- + filepath_or_buffer : str, path object or file-like object + File path where the pickled object will be stored. + compression : {{'infer', 'gzip', 'bz2', 'zip', 'xz', None}}, default: 'infer' + A string representing the compression to use in the output file. By + default, infers from the file extension in specified path. + Compression mode may be any of the following possible + values: {{'infer', 'gzip', 'bz2', 'zip', 'xz', None}}. If compression + mode is 'infer' and path_or_buf is path-like, then detect + compression mode from the following extensions: + '.gz', '.bz2', '.zip' or '.xz'. (otherwise no compression). + If dict given and mode is 'zip' or inferred as 'zip', other entries + passed as additional compression options. + protocol : int, default: pickle.HIGHEST_PROTOCOL + Int which indicates which protocol should be used by the pickler, + default HIGHEST_PROTOCOL (see `pickle docs `_ + paragraph 12.1.2 for details). The possible values are 0, 1, 2, 3, 4, 5. A negative value + for the protocol parameter is equivalent to setting its value to HIGHEST_PROTOCOL. + storage_options : dict, optional + Extra options that make sense for a particular storage connection, e.g. + host, port, username, password, etc., if using a URL that will be parsed by + fsspec, e.g., starting "s3://", "gcs://". An error will be raised if providing + this argument with a non-fsspec URL. See the fsspec and backend storage + implementation docs for the set of allowed keys and values. + """ + from modin.experimental.pandas.io import to_pickle_distributed + + to_pickle_distributed( + self._data, + filepath_or_buffer=filepath_or_buffer, + compression=compression, + protocol=protocol, + storage_options=storage_options, + ) diff --git a/modin/pandas/dataframe.py b/modin/pandas/dataframe.py index 28c6cd543a9..0ddc868305d 100644 --- a/modin/pandas/dataframe.py +++ b/modin/pandas/dataframe.py @@ -53,7 +53,7 @@ try_cast_to_pandas, ) -from .accessor import CachedAccessor, SparseFrameAccessor +from .accessor import CachedAccessor, ExperimentalFunctions, SparseFrameAccessor from .base import _ATTRS_NO_LOOKUP, BasePandasDataset from .groupby import DataFrameGroupBy from .iterator import PartitionIterator @@ -3185,3 +3185,6 @@ def __reduce__(self): return self._inflate_light, (self._query_compiler, pid) # Persistance support methods - END + + # Namespace for experimental functions + modin = CachedAccessor("modin", ExperimentalFunctions) diff --git a/modin/pandas/test/test_api.py b/modin/pandas/test/test_api.py index c8c0869749a..ab3b6d17b8a 100644 --- a/modin/pandas/test/test_api.py +++ b/modin/pandas/test/test_api.py @@ -152,17 +152,21 @@ def test_dataframe_api_equality(): modin_dir = [obj for obj in dir(pd.DataFrame) if obj[0] != "_"] pandas_dir = [obj for obj in dir(pandas.DataFrame) if obj[0] != "_"] - ignore = ["timetuple"] + ignore_in_pandas = ["timetuple"] + # modin - namespace for using experimental functionality + ignore_in_modin = ["modin"] missing_from_modin = set(pandas_dir) - set(modin_dir) assert not len( - missing_from_modin - set(ignore) - ), "Differences found in API: {}".format(len(missing_from_modin - set(ignore))) + missing_from_modin - set(ignore_in_pandas) + ), "Differences found in API: {}".format( + len(missing_from_modin - set(ignore_in_pandas)) + ) assert not len( - set(modin_dir) - set(pandas_dir) + set(modin_dir) - set(ignore_in_modin) - set(pandas_dir) ), "Differences found in API: {}".format(set(modin_dir) - set(pandas_dir)) # These have to be checked manually - allowed_different = ["to_hdf", "hist"] + allowed_different = ["to_hdf", "hist", "modin"] assert_parameters_eq((pandas.DataFrame, pd.DataFrame), modin_dir, allowed_different) diff --git a/modin/utils.py b/modin/utils.py index d56408dcb45..02ee27e4f1b 100644 --- a/modin/utils.py +++ b/modin/utils.py @@ -698,7 +698,7 @@ def get_current_execution() -> str: str Returns On-like string. """ - return f"{'Experimental' if IsExperimental.get() else ''}{StorageFormat.get()}On{Engine.get()}" + return f"{StorageFormat.get()}On{Engine.get()}" def instancer(_class: Callable[[], T]) -> T: From 68fbdb1675f20c70a77c0cb608534e9995174680 Mon Sep 17 00:00:00 2001 From: Anatoly Myachev Date: Mon, 11 Dec 2023 19:51:43 +0100 Subject: [PATCH 117/201] REFACTOR-#6815: move experimental parsers into 'modin.experimental' folder (#6813) Signed-off-by: Anatoly Myachev --- .../implementations/pandas_on_dask/io/io.py | 12 +- .../implementations/pandas_on_ray/io/io.py | 12 +- .../pandas_on_unidist/io/io.py | 12 +- modin/core/storage_formats/pandas/parsers.py | 91 ------------- .../core/storage_formats/pandas/__init__.py | 14 ++ .../core/storage_formats/pandas/parsers.py | 122 ++++++++++++++++++ 6 files changed, 160 insertions(+), 103 deletions(-) create mode 100644 modin/experimental/core/storage_formats/pandas/__init__.py create mode 100644 modin/experimental/core/storage_formats/pandas/parsers.py diff --git a/modin/core/execution/dask/implementations/pandas_on_dask/io/io.py b/modin/core/execution/dask/implementations/pandas_on_dask/io/io.py index 090bbcffaee..e5242458b65 100644 --- a/modin/core/execution/dask/implementations/pandas_on_dask/io/io.py +++ b/modin/core/execution/dask/implementations/pandas_on_dask/io/io.py @@ -31,9 +31,6 @@ SQLDispatcher, ) from modin.core.storage_formats.pandas.parsers import ( - ExperimentalCustomTextParser, - ExperimentalPandasPickleParser, - PandasCSVGlobParser, PandasCSVParser, PandasExcelParser, PandasFeatherParser, @@ -49,6 +46,11 @@ ExperimentalPickleDispatcher, ExperimentalSQLDispatcher, ) +from modin.experimental.core.storage_formats.pandas.parsers import ( + ExperimentalCustomTextParser, + ExperimentalPandasCSVGlobParser, + ExperimentalPandasPickleParser, +) class PandasOnDaskIO(BaseIO): @@ -84,7 +86,9 @@ def __make_write(*classes, build_args=build_args): read_excel = __make_read(PandasExcelParser, ExcelDispatcher) # experimental methods that don't exist in pandas - read_csv_glob = __make_read(PandasCSVGlobParser, ExperimentalCSVGlobDispatcher) + read_csv_glob = __make_read( + ExperimentalPandasCSVGlobParser, ExperimentalCSVGlobDispatcher + ) read_pickle_distributed = __make_read( ExperimentalPandasPickleParser, ExperimentalPickleDispatcher ) diff --git a/modin/core/execution/ray/implementations/pandas_on_ray/io/io.py b/modin/core/execution/ray/implementations/pandas_on_ray/io/io.py index f08dc739716..8edcc05b9f6 100644 --- a/modin/core/execution/ray/implementations/pandas_on_ray/io/io.py +++ b/modin/core/execution/ray/implementations/pandas_on_ray/io/io.py @@ -30,9 +30,6 @@ SQLDispatcher, ) from modin.core.storage_formats.pandas.parsers import ( - ExperimentalCustomTextParser, - ExperimentalPandasPickleParser, - PandasCSVGlobParser, PandasCSVParser, PandasExcelParser, PandasFeatherParser, @@ -48,6 +45,11 @@ ExperimentalPickleDispatcher, ExperimentalSQLDispatcher, ) +from modin.experimental.core.storage_formats.pandas.parsers import ( + ExperimentalCustomTextParser, + ExperimentalPandasCSVGlobParser, + ExperimentalPandasPickleParser, +) from ..dataframe import PandasOnRayDataframe from ..partitioning import PandasOnRayDataframePartition @@ -86,7 +88,9 @@ def __make_write(*classes, build_args=build_args): read_excel = __make_read(PandasExcelParser, ExcelDispatcher) # experimental methods that don't exist in pandas - read_csv_glob = __make_read(PandasCSVGlobParser, ExperimentalCSVGlobDispatcher) + read_csv_glob = __make_read( + ExperimentalPandasCSVGlobParser, ExperimentalCSVGlobDispatcher + ) read_pickle_distributed = __make_read( ExperimentalPandasPickleParser, ExperimentalPickleDispatcher ) diff --git a/modin/core/execution/unidist/implementations/pandas_on_unidist/io/io.py b/modin/core/execution/unidist/implementations/pandas_on_unidist/io/io.py index ff5fbf05a29..e0858e4058b 100644 --- a/modin/core/execution/unidist/implementations/pandas_on_unidist/io/io.py +++ b/modin/core/execution/unidist/implementations/pandas_on_unidist/io/io.py @@ -29,9 +29,6 @@ SQLDispatcher, ) from modin.core.storage_formats.pandas.parsers import ( - ExperimentalCustomTextParser, - ExperimentalPandasPickleParser, - PandasCSVGlobParser, PandasCSVParser, PandasExcelParser, PandasFeatherParser, @@ -47,6 +44,11 @@ ExperimentalPickleDispatcher, ExperimentalSQLDispatcher, ) +from modin.experimental.core.storage_formats.pandas.parsers import ( + ExperimentalCustomTextParser, + ExperimentalPandasCSVGlobParser, + ExperimentalPandasPickleParser, +) from ..dataframe import PandasOnUnidistDataframe from ..partitioning import PandasOnUnidistDataframePartition @@ -85,7 +87,9 @@ def __make_write(*classes, build_args=build_args): read_excel = __make_read(PandasExcelParser, ExcelDispatcher) # experimental methods that don't exist in pandas - read_csv_glob = __make_read(PandasCSVGlobParser, ExperimentalCSVGlobDispatcher) + read_csv_glob = __make_read( + ExperimentalPandasCSVGlobParser, ExperimentalCSVGlobDispatcher + ) read_pickle_distributed = __make_read( ExperimentalPandasPickleParser, ExperimentalPickleDispatcher ) diff --git a/modin/core/storage_formats/pandas/parsers.py b/modin/core/storage_formats/pandas/parsers.py index 826d546ffd8..ceb10658381 100644 --- a/modin/core/storage_formats/pandas/parsers.py +++ b/modin/core/storage_formats/pandas/parsers.py @@ -378,97 +378,6 @@ def read_callback(*args, **kwargs): return pandas.read_csv(*args, **kwargs) -@doc(_doc_pandas_parser_class, data_type="multiple CSV files simultaneously") -class PandasCSVGlobParser(PandasCSVParser): - @staticmethod - @doc( - _doc_parse_func, - parameters="""chunks : list - List, where each element of the list is a list of tuples. The inner lists - of tuples contains the data file name of the chunk, chunk start offset, and - chunk end offsets for its corresponding file.""", - ) - def parse(chunks, **kwargs): - warnings.filterwarnings("ignore") - num_splits = kwargs.pop("num_splits", None) - index_col = kwargs.get("index_col", None) - - # `single_worker_read` just pass filename via chunks; need check - if isinstance(chunks, str): - return pandas.read_csv(chunks, **kwargs) - - # pop `compression` from kwargs because `bio` below is uncompressed - compression = kwargs.pop("compression", "infer") - storage_options = kwargs.pop("storage_options", None) or {} - pandas_dfs = [] - for fname, start, end in chunks: - if start is not None and end is not None: - with OpenFile(fname, "rb", compression, **storage_options) as bio: - if kwargs.get("encoding", None) is not None: - header = b"" + bio.readline() - else: - header = b"" - bio.seek(start) - to_read = header + bio.read(end - start) - pandas_dfs.append(pandas.read_csv(BytesIO(to_read), **kwargs)) - else: - # This only happens when we are reading with only one worker (Default) - return pandas.read_csv( - fname, - compression=compression, - storage_options=storage_options, - **kwargs, - ) - - # Combine read in data. - if len(pandas_dfs) > 1: - pandas_df = pandas.concat(pandas_dfs) - elif len(pandas_dfs) > 0: - pandas_df = pandas_dfs[0] - else: - pandas_df = pandas.DataFrame() - - # Set internal index. - if index_col is not None: - index = pandas_df.index - else: - # The lengths will become the RangeIndex - index = len(pandas_df) - return _split_result_for_readers(1, num_splits, pandas_df) + [ - index, - pandas_df.dtypes, - ] - - -@doc(_doc_pandas_parser_class, data_type="pickled pandas objects") -class ExperimentalPandasPickleParser(PandasParser): - @staticmethod - @doc(_doc_parse_func, parameters=_doc_parse_parameters_common) - def parse(fname, **kwargs): - warnings.filterwarnings("ignore") - num_splits = 1 - single_worker_read = kwargs.pop("single_worker_read", None) - df = pandas.read_pickle(fname, **kwargs) - if single_worker_read: - return df - assert isinstance( - df, pandas.DataFrame - ), f"Pickled obj type: [{type(df)}] in [{fname}]; works only with pandas.DataFrame" - - length = len(df) - width = len(df.columns) - - return _split_result_for_readers(1, num_splits, df) + [length, width] - - -@doc(_doc_pandas_parser_class, data_type="custom text") -class ExperimentalCustomTextParser(PandasParser): - @staticmethod - @doc(_doc_parse_func, parameters=_doc_parse_parameters_common) - def parse(fname, **kwargs): - return PandasParser.generic_parse(fname, **kwargs) - - @doc(_doc_pandas_parser_class, data_type="tables with fixed-width formatted lines") class PandasFWFParser(PandasParser): @staticmethod diff --git a/modin/experimental/core/storage_formats/pandas/__init__.py b/modin/experimental/core/storage_formats/pandas/__init__.py new file mode 100644 index 00000000000..0cec15f0756 --- /dev/null +++ b/modin/experimental/core/storage_formats/pandas/__init__.py @@ -0,0 +1,14 @@ +# Licensed to Modin Development Team under one or more contributor license agreements. +# See the NOTICE file distributed with this work for additional information regarding +# copyright ownership. The Modin Development Team licenses this file to you under the +# Apache License, Version 2.0 (the "License"); you may not use this file except in +# compliance with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software distributed under +# the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF +# ANY KIND, either express or implied. See the License for the specific language +# governing permissions and limitations under the License. + +"""The module represents the query compiler level for the pandas storage format (experimental).""" diff --git a/modin/experimental/core/storage_formats/pandas/parsers.py b/modin/experimental/core/storage_formats/pandas/parsers.py new file mode 100644 index 00000000000..bf09fc99ebc --- /dev/null +++ b/modin/experimental/core/storage_formats/pandas/parsers.py @@ -0,0 +1,122 @@ +# Licensed to Modin Development Team under one or more contributor license agreements. +# See the NOTICE file distributed with this work for additional information regarding +# copyright ownership. The Modin Development Team licenses this file to you under the +# Apache License, Version 2.0 (the "License"); you may not use this file except in +# compliance with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software distributed under +# the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF +# ANY KIND, either express or implied. See the License for the specific language +# governing permissions and limitations under the License. + + +"""Module houses experimental Modin parser classes, that are used for data parsing on the workers.""" + +import warnings +from io import BytesIO + +import pandas +from pandas.util._decorators import doc + +from modin.core.io.file_dispatcher import OpenFile +from modin.core.storage_formats.pandas.parsers import ( + PandasCSVParser, + PandasParser, + _doc_pandas_parser_class, + _doc_parse_func, + _doc_parse_parameters_common, + _split_result_for_readers, +) + + +@doc(_doc_pandas_parser_class, data_type="multiple CSV files simultaneously") +class ExperimentalPandasCSVGlobParser(PandasCSVParser): + @staticmethod + @doc( + _doc_parse_func, + parameters="""chunks : list + List, where each element of the list is a list of tuples. The inner lists + of tuples contains the data file name of the chunk, chunk start offset, and + chunk end offsets for its corresponding file.""", + ) + def parse(chunks, **kwargs): + warnings.filterwarnings("ignore") + num_splits = kwargs.pop("num_splits", None) + index_col = kwargs.get("index_col", None) + + # `single_worker_read` just pass filename via chunks; need check + if isinstance(chunks, str): + return pandas.read_csv(chunks, **kwargs) + + # pop `compression` from kwargs because `bio` below is uncompressed + compression = kwargs.pop("compression", "infer") + storage_options = kwargs.pop("storage_options", None) or {} + pandas_dfs = [] + for fname, start, end in chunks: + if start is not None and end is not None: + with OpenFile(fname, "rb", compression, **storage_options) as bio: + if kwargs.get("encoding", None) is not None: + header = b"" + bio.readline() + else: + header = b"" + bio.seek(start) + to_read = header + bio.read(end - start) + pandas_dfs.append(pandas.read_csv(BytesIO(to_read), **kwargs)) + else: + # This only happens when we are reading with only one worker (Default) + return pandas.read_csv( + fname, + compression=compression, + storage_options=storage_options, + **kwargs, + ) + + # Combine read in data. + if len(pandas_dfs) > 1: + pandas_df = pandas.concat(pandas_dfs) + elif len(pandas_dfs) > 0: + pandas_df = pandas_dfs[0] + else: + pandas_df = pandas.DataFrame() + + # Set internal index. + if index_col is not None: + index = pandas_df.index + else: + # The lengths will become the RangeIndex + index = len(pandas_df) + return _split_result_for_readers(1, num_splits, pandas_df) + [ + index, + pandas_df.dtypes, + ] + + +@doc(_doc_pandas_parser_class, data_type="pickled pandas objects") +class ExperimentalPandasPickleParser(PandasParser): + @staticmethod + @doc(_doc_parse_func, parameters=_doc_parse_parameters_common) + def parse(fname, **kwargs): + warnings.filterwarnings("ignore") + num_splits = 1 + single_worker_read = kwargs.pop("single_worker_read", None) + df = pandas.read_pickle(fname, **kwargs) + if single_worker_read: + return df + assert isinstance( + df, pandas.DataFrame + ), f"Pickled obj type: [{type(df)}] in [{fname}]; works only with pandas.DataFrame" + + length = len(df) + width = len(df.columns) + + return _split_result_for_readers(1, num_splits, df) + [length, width] + + +@doc(_doc_pandas_parser_class, data_type="custom text") +class ExperimentalCustomTextParser(PandasParser): + @staticmethod + @doc(_doc_parse_func, parameters=_doc_parse_parameters_common) + def parse(fname, **kwargs): + return PandasParser.generic_parse(fname, **kwargs) From 23e30e59fca2f93f85bf7da73eea18cfbde4099e Mon Sep 17 00:00:00 2001 From: Anatoly Myachev Date: Tue, 12 Dec 2023 10:20:04 +0100 Subject: [PATCH 118/201] REFACTOR-#6818: don't implicitly enable experimental mode (#6817) Signed-off-by: Anatoly Myachev --- modin/experimental/pandas/__init__.py | 5 ----- modin/experimental/pandas/io.py | 7 ------- 2 files changed, 12 deletions(-) diff --git a/modin/experimental/pandas/__init__.py b/modin/experimental/pandas/__init__.py index 091bf260967..7516563d41c 100644 --- a/modin/experimental/pandas/__init__.py +++ b/modin/experimental/pandas/__init__.py @@ -33,11 +33,6 @@ """ import functools - -from modin.config import IsExperimental - -IsExperimental.put(True) - import warnings from modin.pandas import * # noqa F401, F403 diff --git a/modin/experimental/pandas/io.py b/modin/experimental/pandas/io.py index 532b1f7beac..4d5959366f2 100644 --- a/modin/experimental/pandas/io.py +++ b/modin/experimental/pandas/io.py @@ -22,7 +22,6 @@ import pandas._libs.lib as lib from pandas._typing import CompressionOptions, StorageOptions -from modin.config import IsExperimental from modin.core.storage_formats import BaseQueryCompiler from modin.utils import expanduser_path_arg @@ -113,8 +112,6 @@ def read_sql( from modin.core.execution.dispatching.factories.dispatcher import FactoryDispatcher - assert IsExperimental.get(), "This only works in experimental mode" - result = FactoryDispatcher.read_sql_distributed(**kwargs) if isinstance(result, BaseQueryCompiler): return DataFrame(query_compiler=result) @@ -161,8 +158,6 @@ def read_custom_text( from modin.core.execution.dispatching.factories.dispatcher import FactoryDispatcher - assert IsExperimental.get(), "This only works in experimental mode" - return DataFrame(query_compiler=FactoryDispatcher.read_custom_text(**kwargs)) @@ -347,8 +342,6 @@ def read_pickle_distributed( from modin.core.execution.dispatching.factories.dispatcher import FactoryDispatcher - assert IsExperimental.get(), "This only works in experimental mode" - return DataFrame(query_compiler=FactoryDispatcher.read_pickle_distributed(**kwargs)) From 324099d8737ead092e4acacfe03b1caa82e01986 Mon Sep 17 00:00:00 2001 From: Dmitry Chigarev Date: Tue, 12 Dec 2023 16:32:18 +0100 Subject: [PATCH 119/201] REFACTOR-#6807: Rename experimental groupby and experimental numpy variables (#6809) Signed-off-by: Dmitry Chigarev --- .../actions/run-core-tests/group_3/action.yml | 2 +- docs/flow/modin/experimental/index.rst | 4 +- .../range_partitioning_groupby.rst | 74 ++++++++ .../experimental/reshuffling_groupby.rst | 78 +------- modin/config/envvars.py | 178 +++++++++++++++++- modin/config/pubsub.py | 84 ++++++++- modin/config/test/test_envvars.py | 170 ++++++++++++++++- modin/core/storage_formats/pandas/groupby.py | 8 +- .../storage_formats/pandas/query_compiler.py | 24 +-- modin/numpy/arr.py | 2 +- modin/pandas/base.py | 4 +- modin/pandas/series.py | 8 +- modin/pandas/test/test_groupby.py | 18 +- .../storage_formats/pandas/test_internals.py | 12 +- 14 files changed, 546 insertions(+), 120 deletions(-) create mode 100644 docs/flow/modin/experimental/range_partitioning_groupby.rst diff --git a/.github/actions/run-core-tests/group_3/action.yml b/.github/actions/run-core-tests/group_3/action.yml index 578673326f9..38d69ced09b 100644 --- a/.github/actions/run-core-tests/group_3/action.yml +++ b/.github/actions/run-core-tests/group_3/action.yml @@ -19,6 +19,6 @@ runs: shell: bash -l {0} - run: | echo "::group::Running experimental groupby tests (group 3)..." - MODIN_EXPERIMENTAL_GROUPBY=1 ${{ inputs.runner }} ${{ inputs.parallel }} modin/pandas/test/test_groupby.py + MODIN_RANGE_PARTITIONING_GROUPBY=1 ${{ inputs.runner }} ${{ inputs.parallel }} modin/pandas/test/test_groupby.py echo "::endgroup::" shell: bash -l {0} diff --git a/docs/flow/modin/experimental/index.rst b/docs/flow/modin/experimental/index.rst index f11cdb12d7c..6e5a1607a9d 100644 --- a/docs/flow/modin/experimental/index.rst +++ b/docs/flow/modin/experimental/index.rst @@ -15,7 +15,7 @@ and provides a limited set of functionality: * :doc:`xgboost ` * :doc:`sklearn ` * :doc:`batch ` -* :doc:`Reshuffling groupby ` +* :doc:`Range-partitioning GroupBy implementation ` .. toctree:: @@ -24,4 +24,4 @@ and provides a limited set of functionality: sklearn xgboost batch - reshuffling_groupby + range_partitioning_groupby diff --git a/docs/flow/modin/experimental/range_partitioning_groupby.rst b/docs/flow/modin/experimental/range_partitioning_groupby.rst new file mode 100644 index 00000000000..ef82967ce15 --- /dev/null +++ b/docs/flow/modin/experimental/range_partitioning_groupby.rst @@ -0,0 +1,74 @@ +Range-partitioning GroupBy +"""""""""""""""""""""""""" + +The range-partitioning GroupBy implementation utilizes Modin's reshuffling mechanism that gives an +ability to build range partitioning over a Modin DataFrame. + +In order to enable/disable the range-partitiong implementation you have to specify ``cfg.RangePartitioningGroupby`` +:doc:`configuration variable: ` + +.. code-block:: ipython + + In [4]: import modin.config as cfg; cfg.RangePartitioningGroupby.put(True) + + In [5]: # past this point, Modin will always use the range-partitiong groupby implementation + + In [6]: cfg.RangePartitioningGroupby.put(False) + + In [7]: # past this point, Modin won't use range-partitiong groupby implementation anymore + +The range-partitiong implementation appears to be quite efficient when compared to TreeReduce and FullAxis implementations: + +.. note:: + + All of the examples below were run on Intel(R) Xeon(R) Gold 6238R CPU @ 2.20GHz (112 cores), 192gb RAM + +.. code-block:: ipython + + In [4]: import modin.pandas as pd; import numpy as np + + In [5]: df = pd.DataFrame(np.random.randint(0, 1_000_000, size=(1_000_000, 10)), columns=[f"col{i}" for i in range(10)]) + + In [6]: %timeit df.groupby("col0").nunique() # full-axis implementation + Out[6]: # 2.73 s ± 28.8 ms per loop (mean ± std. dev. of 7 runs, 1 loop each) + + In [7]: import modin.config as cfg; cfg.RangePartitioningGroupby.put(True) + + In [8]: %timeit df.groupby("col0").nunique() # range-partitiong implementation + Out[8]: # 595 ms ± 61.3 ms per loop (mean ± std. dev. of 7 runs, 1 loop each) + +Although it may look like the range-partitioning implementation always outperforms the other ones, it's not actually true. +There's a decent overhead on building the range partitioning itself, meaning that the other implementations +may act better on smaller data sizes or when the grouping columns (a key column to build range partitioning) +have too few unique values (and thus fewer units of parallelization): + +.. code-block:: ipython + + In [4]: import modin.pandas as pd; import numpy as np + + In [5]: df = pd.DataFrame({"col0": np.tile(list("abcde"), 50_000), "col1": np.arange(250_000)}) + + In [6]: %timeit df.groupby("col0").sum() # TreeReduce implementation + Out[6]: # 155 ms ± 5.02 ms per loop (mean ± std. dev. of 7 runs, 10 loops each) + + In [7]: import modin.config as cfg; cfg.RangePartitioningGroupby.put(True) + + In [8]: %timeit df.groupby("col0").sum() # range-partitiong implementation + Out[8]: # 230 ms ± 22.2 ms per loop (mean ± std. dev. of 7 runs, 1 loop each) + +We're still looking for a heuristic that would be able to automatically switch to the best implementation +for each groupby case, but for now, we're offering to play with this switch on your own to see which +implementation works best for your particular case. + +The range-partitioning groupby does not yet support all of the pandas API and falls back to an other +implementation with the respective warning if it meets an unsupported case: + +.. code-block:: python + + In [14]: import modin.config as cfg; cfg.RangePartitioningGroupby.put(True) + + In [15]: df.groupby(level=0).sum() + Out[15]: # UserWarning: Can't use range-partitiong groupby implementation because of: + ... # Range-partitioning groupby is only supported when grouping on a column(s) of the same frame. + ... # https://github.com/modin-project/modin/issues/5926 + ... # Falling back to a TreeReduce implementation. diff --git a/docs/flow/modin/experimental/reshuffling_groupby.rst b/docs/flow/modin/experimental/reshuffling_groupby.rst index 3265197bc15..feb930fd26d 100644 --- a/docs/flow/modin/experimental/reshuffling_groupby.rst +++ b/docs/flow/modin/experimental/reshuffling_groupby.rst @@ -1,74 +1,8 @@ -Reshuffling GroupBy -""""""""""""""""""" +:orphan: -The experimental GroupBy implementation utilizes Modin's reshuffling mechanism that gives an -ability to build range partitioning over a Modin DataFrame. +.. redirect to the new page +.. raw:: html -In order to enable/disable this new implementation you have to specify ``cfg.ExperimentalGroupbyImpl`` -:doc:`configuration variable: ` - -.. code-block:: ipython - - In [4]: import modin.config as cfg; cfg.ExperimentalGroupbyImpl.put(True) - - In [5]: # past this point, Modin will always use the new reshuffling groupby implementation - - In [6]: cfg.ExperimentalGroupbyImpl.put(False) - - In [7]: # past this point, Modin won't use reshuffling groupby implementation anymore - -The reshuffling implementation appears to be quite efficient when compared to old TreeReduce and FullAxis implementations: - -.. note:: - - All of the examples below were run on Intel(R) Xeon(R) Gold 6238R CPU @ 2.20GHz (112 cores), 192gb RAM - -.. code-block:: ipython - - In [4]: import modin.pandas as pd; import numpy as np - - In [5]: df = pd.DataFrame(np.random.randint(0, 1_000_000, size=(1_000_000, 10)), columns=[f"col{i}" for i in range(10)]) - - In [6]: %timeit df.groupby("col0").nunique() # old full-axis implementation - Out[6]: # 2.73 s ± 28.8 ms per loop (mean ± std. dev. of 7 runs, 1 loop each) - - In [7]: import modin.config as cfg; cfg.ExperimentalGroupbyImpl.put(True) - - In [8]: %timeit df.groupby("col0").nunique() # new reshuffling implementation - Out[8]: # 595 ms ± 61.3 ms per loop (mean ± std. dev. of 7 runs, 1 loop each) - -Although it may look like the new implementation always outperforms the old ones, it's not actually true. -There's a decent overhead on building the range partitioning itself, meaning that the old implementations -may act better on smaller data sizes or when the grouping columns (a key column to build range partitioning) -have too few unique values (and thus fewer units of parallelization): - -.. code-block:: ipython - - In [4]: import modin.pandas as pd; import numpy as np - - In [5]: df = pd.DataFrame({"col0": np.tile(list("abcde"), 50_000), "col1": np.arange(250_000)}) - - In [6]: %timeit df.groupby("col0").sum() # old TreeReduce implementation - Out[6]: # 155 ms ± 5.02 ms per loop (mean ± std. dev. of 7 runs, 10 loops each) - - In [7]: import modin.config as cfg; cfg.ExperimentalGroupbyImpl.put(True) - - In [8]: %timeit df.groupby("col0").sum() # new reshuffling implementation - Out[8]: # 230 ms ± 22.2 ms per loop (mean ± std. dev. of 7 runs, 1 loop each) - -We're still looking for a heuristic that would be able to automatically switch to the best implementation -for each groupby case, but for now, we're offering to play with this switch on your own to see which -implementation works best for your particular case. - -The new experimental groupby does not yet support all of the pandas API and falls back to older -implementation with the respective warning if it meets an unsupported case: - -.. code-block:: python - - In [14]: import modin.config as cfg; cfg.ExperimentalGroupbyImpl.put(True) - - In [15]: df.groupby(level=0).sum() - Out[15]: # UserWarning: Can't use experimental reshuffling groupby implementation because of: - ... # Reshuffling groupby is only supported when grouping on a column(s) of the same frame. - ... # https://github.com/modin-project/modin/issues/5926 - ... # Falling back to a TreeReduce implementation. + diff --git a/modin/config/envvars.py b/modin/config/envvars.py index da35bbaff00..184c6805797 100644 --- a/modin/config/envvars.py +++ b/modin/config/envvars.py @@ -23,7 +23,14 @@ from packaging import version from pandas.util._decorators import doc # type: ignore[attr-defined] -from .pubsub import _TYPE_PARAMS, ExactStr, Parameter, ValueSource +from .pubsub import ( + _TYPE_PARAMS, + _UNSET, + DeprecationDescriptor, + ExactStr, + Parameter, + ValueSource, +) class EnvironmentVariable(Parameter, type=str, abstract=True): @@ -67,6 +74,96 @@ def get_help(cls) -> str: return help +class EnvWithSibilings( + EnvironmentVariable, + # 'type' is a mandatory parameter for '__init_subclasses__', so we have to pass something here, + # this doesn't force child classes to have 'str' type though, they actually can be any type + type=str, +): + """Ensure values synchronization between sibling parameters.""" + + _update_sibling = True + + @classmethod + def _sibling(cls) -> type["EnvWithSibilings"]: + """Return a sibling parameter.""" + raise NotImplementedError() + + @classmethod + def get(cls) -> Any: + """ + Get parameter's value and ensure that it's equal to the sibling's value. + + Returns + ------- + Any + """ + sibling = cls._sibling() + + if sibling._value is _UNSET and cls._value is _UNSET: + super().get() + with warnings.catch_warnings(): + # filter warnings that can potentially come from the potentially deprecated sibling + warnings.filterwarnings("ignore", category=FutureWarning) + super(EnvWithSibilings, sibling).get() + + if ( + cls._value_source + == sibling._value_source + == ValueSource.GOT_FROM_CFG_SOURCE + ): + raise ValueError( + f"Configuration is ambiguous. You cannot set '{cls.varname}' and '{sibling.varname}' at the same time." + ) + + # further we assume that there are only two valid sources for the variables: 'GOT_FROM_CFG' and 'DEFAULT', + # as otherwise we wouldn't ended-up in this branch at all, because all other ways of setting a value + # changes the '._value' attribute from '_UNSET' to something meaningful + from modin.error_message import ErrorMessage + + if cls._value_source == ValueSource.GOT_FROM_CFG_SOURCE: + ErrorMessage.catch_bugs_and_request_email( + failure_condition=sibling._value_source != ValueSource.DEFAULT + ) + sibling._value = cls._value + sibling._value_source = ValueSource.GOT_FROM_CFG_SOURCE + elif sibling._value_source == ValueSource.GOT_FROM_CFG_SOURCE: + ErrorMessage.catch_bugs_and_request_email( + failure_condition=cls._value_source != ValueSource.DEFAULT + ) + cls._value = sibling._value + cls._value_source = ValueSource.GOT_FROM_CFG_SOURCE + else: + ErrorMessage.catch_bugs_and_request_email( + failure_condition=cls._value_source != ValueSource.DEFAULT + or sibling._value_source != ValueSource.DEFAULT + ) + # propagating 'cls' default value to the sibling + sibling._value = cls._value + return super().get() + + @classmethod + def put(cls, value: Any) -> None: + """ + Set a new value to this parameter as well as to its sibling. + + Parameters + ---------- + value : Any + """ + super().put(value) + # avoid getting into an infinite recursion + if cls._update_sibling: + cls._update_sibling = False + try: + with warnings.catch_warnings(): + # filter potential future warnings of the sibling + warnings.filterwarnings("ignore", category=FutureWarning) + cls._sibling().put(value) + finally: + cls._update_sibling = True + + class IsDebug(EnvironmentVariable, type=bool): """Force Modin engine to be "Python" unless specified by $MODIN_ENGINE.""" @@ -621,16 +718,44 @@ class GithubCI(EnvironmentVariable, type=bool): default = False -class ExperimentalNumPyAPI(EnvironmentVariable, type=bool): - """Set to true to use Modin's experimental NumPy API.""" +class ModinNumpy(EnvWithSibilings, type=bool): + """Set to true to use Modin's implementation of NumPy API.""" + + varname = "MODIN_NUMPY" + default = False + + @classmethod + def _sibling(cls) -> type[EnvWithSibilings]: + """Get a parameter sibling.""" + return ExperimentalNumPyAPI + + +class ExperimentalNumPyAPI(EnvWithSibilings, type=bool): + """ + Set to true to use Modin's implementation of NumPy API. + + This parameter is deprecated. Use ``ModinNumpy`` instead. + """ varname = "MODIN_EXPERIMENTAL_NUMPY_API" default = False + @classmethod + def _sibling(cls) -> type[EnvWithSibilings]: + """Get a parameter sibling.""" + return ModinNumpy + + +# Let the parameter's handling logic know that this variable is deprecated and that +# we should raise respective warnings +ExperimentalNumPyAPI._deprecation_descriptor = DeprecationDescriptor( + ExperimentalNumPyAPI, ModinNumpy +) + -class ExperimentalGroupbyImpl(EnvironmentVariable, type=bool): +class RangePartitioningGroupby(EnvWithSibilings, type=bool): """ - Set to true to use Modin's experimental group by implementation. + Set to true to use Modin's range-partitioning group by implementation. Experimental groupby is implemented using a range-partitioning technique, note that it may not always work better than the original Modin's TreeReduce @@ -638,9 +763,37 @@ class ExperimentalGroupbyImpl(EnvironmentVariable, type=bool): of Modin's documentation: TODO: add a link to the section once it's written. """ + varname = "MODIN_RANGE_PARTITIONING_GROUPBY" + default = False + + @classmethod + def _sibling(cls) -> type[EnvWithSibilings]: + """Get a parameter sibling.""" + return ExperimentalGroupbyImpl + + +class ExperimentalGroupbyImpl(EnvWithSibilings, type=bool): + """ + Set to true to use Modin's range-partitioning group by implementation. + + This parameter is deprecated. Use ``RangePartitioningGroupby`` instead. + """ + varname = "MODIN_EXPERIMENTAL_GROUPBY" default = False + @classmethod + def _sibling(cls) -> type[EnvWithSibilings]: + """Get a parameter sibling.""" + return RangePartitioningGroupby + + +# Let the parameter's handling logic know that this variable is deprecated and that +# we should raise respective warnings +ExperimentalGroupbyImpl._deprecation_descriptor = DeprecationDescriptor( + ExperimentalGroupbyImpl, RangePartitioningGroupby +) + class CIAWSSecretAccessKey(EnvironmentVariable, type=str): """Set to AWS_SECRET_ACCESS_KEY when running mock S3 tests for Modin in GitHub CI.""" @@ -704,12 +857,27 @@ def _check_vars() -> None: } found_names = {name for name in os.environ if name.startswith("MODIN_")} unknown = found_names - valid_names + deprecated: dict[str, DeprecationDescriptor] = { + obj.varname: obj._deprecation_descriptor + for obj in globals().values() + if isinstance(obj, type) + and issubclass(obj, EnvironmentVariable) + and not obj.is_abstract + and obj.varname is not None + and obj._deprecation_descriptor is not None + } + found_deprecated = found_names & deprecated.keys() if unknown: warnings.warn( f"Found unknown environment variable{'s' if len(unknown) > 1 else ''}," + f" please check {'their' if len(unknown) > 1 else 'its'} spelling: " + ", ".join(sorted(unknown)) ) + for depr_var in found_deprecated: + warnings.warn( + deprecated[depr_var].deprecation_message(use_envvar_names=True), + FutureWarning, + ) _check_vars() diff --git a/modin/config/pubsub.py b/modin/config/pubsub.py index a0bff312ec6..7c7f2b7fce2 100644 --- a/modin/config/pubsub.py +++ b/modin/config/pubsub.py @@ -13,9 +13,80 @@ """Module houses ``Parameter`` class - base class for all configs.""" +import warnings from collections import defaultdict from enum import IntEnum -from typing import Any, Callable, DefaultDict, NamedTuple, Optional, Tuple +from typing import ( + TYPE_CHECKING, + Any, + Callable, + DefaultDict, + NamedTuple, + Optional, + Tuple, + cast, +) + +if TYPE_CHECKING: + from .envvars import EnvironmentVariable + + +class DeprecationDescriptor: + """ + Describe deprecated parameter. + + Parameters + ---------- + parameter : type[Parameter] + Deprecated parameter. + new_parameter : type[Parameter], optional + If there's a replacement parameter for the deprecated one, specify it here. + when_removed : str, optional + If known, the exact release when the deprecated parameter is planned to be removed. + """ + + _parameter: type["Parameter"] + _new_parameter: Optional[type["Parameter"]] + _when_removed: str + + def __init__( + self, + parameter: type["Parameter"], + new_parameter: Optional[type["Parameter"]] = None, + when_removed: Optional[str] = None, + ): + self._parameter = parameter + self._new_parameter = new_parameter + self._when_removed = "a future" if when_removed is None else when_removed + + def deprecation_message(self, use_envvar_names: bool = False) -> str: + """ + Generate a message to be used in a warning raised when using the deprecated parameter. + + Parameters + ---------- + use_envvar_names : bool, default: False + Whether to use environment variable names in the warning. If ``True``, both + ``self._parameter`` and ``self._new_parameter`` have to be a type of ``EnvironmentVariable``. + + Returns + ------- + str + """ + name = ( + cast("EnvironmentVariable", self._parameter).varname + if use_envvar_names + else self._parameter.__name__ + ) + msg = f"'{name}' is deprecated and will be removed in {self._when_removed} version." + if self._new_parameter is not None: + new_name = ( + cast("EnvironmentVariable", self._new_parameter).varname + if use_envvar_names + else self._new_parameter.__name__ + ) + msg += f" Use '{new_name}' instead." + return msg class TypeDescriptor(NamedTuple): @@ -134,6 +205,8 @@ class Parameter(object): _value_source : Optional[ValueSource] Source of the ``Parameter`` value, should be set by ``ValueSource``. + _deprecation_descriptor : Optional[DeprecationDescriptor] + Indicate whether this parameter is deprecated. """ choices: Optional[Tuple[str, ...]] = None @@ -144,6 +217,7 @@ class Parameter(object): _value: Any = _UNSET _subs: list = [] _once: DefaultDict[Any, list] = defaultdict(list) + _deprecation_descriptor: Optional[DeprecationDescriptor] = None @classmethod def _get_raw_from_config(cls) -> str: @@ -254,6 +328,10 @@ def get(cls) -> Any: Any Decoded and verified config value. """ + if cls._deprecation_descriptor is not None: + warnings.warn( + cls._deprecation_descriptor.deprecation_message(), FutureWarning + ) if cls._value is _UNSET: # get the value from env try: @@ -278,6 +356,10 @@ def put(cls, value: Any) -> None: value : Any Config value to set. """ + if cls._deprecation_descriptor is not None: + warnings.warn( + cls._deprecation_descriptor.deprecation_message(), FutureWarning + ) cls._check_callbacks(cls._put_nocallback(value)) cls._value_source = ValueSource.SET_BY_USER diff --git a/modin/config/test/test_envvars.py b/modin/config/test/test_envvars.py index a52b2ed5595..b470a4ed5c4 100644 --- a/modin/config/test/test_envvars.py +++ b/modin/config/test/test_envvars.py @@ -12,12 +12,33 @@ # governing permissions and limitations under the License. import os +import unittest.mock +import warnings import pytest from packaging import version import modin.config as cfg -from modin.config.envvars import EnvironmentVariable, ExactStr, _check_vars +from modin.config.envvars import ( + _UNSET, + EnvironmentVariable, + ExactStr, + Parameter, + _check_vars, +) + + +def reset_vars(*vars: tuple[Parameter]): + """ + Reset value for the passed parameters. + + Parameters + ---------- + *vars : tuple[Parameter] + """ + for var in vars: + var._value = _UNSET + _ = os.environ.pop(var.varname, None) @pytest.fixture @@ -105,3 +126,150 @@ def test_hdk_envvar(): assert params["enable_union"] == 4 assert params["enable_thrift_logs"] == 5 assert params["enable_lazy_dict_materialization"] == 6 + + +@pytest.mark.parametrize( + "deprecated_var, new_var", + [ + (cfg.ExperimentalGroupbyImpl, cfg.RangePartitioningGroupby), + (cfg.ExperimentalNumPyAPI, cfg.ModinNumpy), + ], +) +def test_deprecated_bool_vars_warnings(deprecated_var, new_var): + """Test that deprecated parameters are raising `FutureWarnings` and their replacements don't.""" + old_depr_val = deprecated_var.get() + old_new_var = new_var.get() + + try: + reset_vars(deprecated_var, new_var) + with pytest.warns(FutureWarning): + deprecated_var.get() + + with pytest.warns(FutureWarning): + deprecated_var.put(False) + + with unittest.mock.patch.dict(os.environ, {deprecated_var.varname: "1"}): + with pytest.warns(FutureWarning): + _check_vars() + + # check that the new var doesn't raise any warnings + reset_vars(deprecated_var, new_var) + with warnings.catch_warnings(): + warnings.simplefilter("error") + new_var.get() + new_var.put(False) + with unittest.mock.patch.dict(os.environ, {new_var.varname: "1"}): + _check_vars() + finally: + deprecated_var.put(old_depr_val) + new_var.put(old_new_var) + + +@pytest.mark.parametrize( + "deprecated_var, new_var", + [ + (cfg.ExperimentalGroupbyImpl, cfg.RangePartitioningGroupby), + (cfg.ExperimentalNumPyAPI, cfg.ModinNumpy), + ], +) +@pytest.mark.parametrize("get_depr_first", [True, False]) +def test_deprecated_bool_vars_equals(deprecated_var, new_var, get_depr_first): + """ + Test that deprecated parameters always have values equal to the new replacement parameters. + + Parameters + ---------- + deprecated_var : EnvironmentVariable + new_var : EnvironmentVariable + get_depr_first : bool + Defines an order in which the ``.get()`` method should be called when comparing values. + If ``True``: get deprecated value at first ``deprecated_var.get() == new_var.get() == value``. + If ``False``: get new value at first ``new_var.get() == deprecated_var.get() == value``. + The logic of the ``.get()`` method depends on which parameter was initialized first, + that's why it's worth testing both cases. + """ + old_depr_val = deprecated_var.get() + old_new_var = new_var.get() + + def get_values(): + return ( + (deprecated_var.get(), new_var.get()) + if get_depr_first + else (new_var.get(), deprecated_var.get()) + ) + + try: + # case1: initializing the value using 'deprecated_var' + reset_vars(deprecated_var, new_var) + deprecated_var.put(True) + val1, val2 = get_values() + assert val1 == val2 == True # noqa: E712 ('obj == True' comparison) + + new_var.put(False) + val1, val2 = get_values() + assert val1 == val2 == False # noqa: E712 ('obj == False' comparison) + + new_var.put(True) + val1, val2 = get_values() + assert val1 == val2 == True # noqa: E712 ('obj == True' comparison) + + deprecated_var.put(False) + val1, val2 = get_values() + assert val1 == val2 == False # noqa: E712 ('obj == False' comparison) + + # case2: initializing the value using 'new_var' + reset_vars(deprecated_var, new_var) + new_var.put(True) + val1, val2 = get_values() + assert val1 == val2 == True # noqa: E712 ('obj == True' comparison) + + deprecated_var.put(False) + val1, val2 = get_values() + assert val1 == val2 == False # noqa: E712 ('obj == False' comparison) + + deprecated_var.put(True) + val1, val2 = get_values() + assert val1 == val2 == True # noqa: E712 ('obj == True' comparison) + + new_var.put(False) + val1, val2 = get_values() + assert val1 == val2 == False # noqa: E712 ('obj == False' comparison) + + # case3: initializing the value using 'deprecated_var' with env variable + reset_vars(deprecated_var, new_var) + with unittest.mock.patch.dict(os.environ, {deprecated_var.varname: "True"}): + val1, val2 = get_values() + assert val1 == val2 == True # noqa: E712 ('obj == True' comparison) + + new_var.put(False) + val1, val2 = get_values() + assert val1 == val2 == False # noqa: E712 ('obj == False' comparison) + + new_var.put(True) + val1, val2 = get_values() + assert val1 == val2 == True # noqa: E712 ('obj == True' comparison) + + deprecated_var.put(False) + val1, val2 = get_values() + assert val1 == val2 == False # noqa: E712 ('obj == False' comparison) + + # case4: initializing the value using 'new_var' with env variable + reset_vars(deprecated_var, new_var) + with unittest.mock.patch.dict(os.environ, {new_var.varname: "True"}): + val1, val2 = get_values() + assert val1 == val2 == True # noqa: E712 ('obj == True' comparison) + + deprecated_var.put(False) + val1, val2 = get_values() + assert val1 == val2 == False # noqa: E712 ('obj == False' comparison) + + deprecated_var.put(True) + val1, val2 = get_values() + assert val1 == val2 == True # noqa: E712 ('obj == True' comparison) + + new_var.put(False) + val1, val2 = get_values() + assert val1 == val2 == False # noqa: E712 ('obj == False' comparison) + finally: + deprecated_var.put(old_depr_val) + new_var.put(old_new_var) diff --git a/modin/core/storage_formats/pandas/groupby.py b/modin/core/storage_formats/pandas/groupby.py index b89fcfc44c7..596984c743f 100644 --- a/modin/core/storage_formats/pandas/groupby.py +++ b/modin/core/storage_formats/pandas/groupby.py @@ -16,7 +16,7 @@ import numpy as np import pandas -from modin.config import ExperimentalGroupbyImpl +from modin.config import RangePartitioningGroupby from modin.core.dataframe.algebra import GroupByReduce from modin.error_message import ErrorMessage from modin.utils import hashable @@ -93,18 +93,18 @@ def build_qc_method(cls, agg_name, finalizer_fn=None): ) def method(query_compiler, *args, **kwargs): - if ExperimentalGroupbyImpl.get(): + if RangePartitioningGroupby.get(): try: if finalizer_fn is not None: raise NotImplementedError( - "Reshuffling groupby is not implemented yet when a finalizing function is specified." + "Range-partitioning groupby is not implemented yet when a finalizing function is specified." ) return query_compiler._groupby_shuffle( *args, agg_func=agg_name, **kwargs ) except NotImplementedError as e: ErrorMessage.warn( - f"Can't use experimental reshuffling groupby implementation because of: {e}" + f"Can't use range-partitioning groupby implementation because of: {e}" + "\nFalling back to a TreeReduce implementation." ) return map_reduce_method(query_compiler, *args, **kwargs) diff --git a/modin/core/storage_formats/pandas/query_compiler.py b/modin/core/storage_formats/pandas/query_compiler.py index 4f2f957bb58..50d2b477c71 100644 --- a/modin/core/storage_formats/pandas/query_compiler.py +++ b/modin/core/storage_formats/pandas/query_compiler.py @@ -43,7 +43,7 @@ from pandas.core.indexing import check_bool_indexer from pandas.errors import DataError, MergeError -from modin.config import CpuCount, ExperimentalGroupbyImpl +from modin.config import CpuCount, RangePartitioningGroupby from modin.core.dataframe.algebra import ( Binary, Fold, @@ -3521,7 +3521,7 @@ def groupby_nth( return result def groupby_mean(self, by, axis, groupby_kwargs, agg_args, agg_kwargs, drop=False): - if ExperimentalGroupbyImpl.get(): + if RangePartitioningGroupby.get(): try: return self._groupby_shuffle( by=by, @@ -3534,7 +3534,7 @@ def groupby_mean(self, by, axis, groupby_kwargs, agg_args, agg_kwargs, drop=Fals ) except NotImplementedError as e: ErrorMessage.warn( - f"Can't use experimental reshuffling groupby implementation because of: {e}" + f"Can't use range-partitioning groupby implementation because of: {e}" + "\nFalling back to a TreeReduce implementation." ) @@ -3592,7 +3592,7 @@ def groupby_size( agg_kwargs, drop=False, ): - if ExperimentalGroupbyImpl.get(): + if RangePartitioningGroupby.get(): try: return self._groupby_shuffle( by=by, @@ -3605,7 +3605,7 @@ def groupby_size( ) except NotImplementedError as e: ErrorMessage.warn( - f"Can't use experimental reshuffling groupby implementation because of: {e}" + f"Can't use range-partitioning groupby implementation because of: {e}" + "\nFalling back to a TreeReduce implementation." ) @@ -3779,7 +3779,7 @@ def _groupby_shuffle( if not is_all_column_names: raise NotImplementedError( - "Reshuffling groupby is only supported when grouping on a column(s) of the same frame. " + "Range-partitioning groupby is only supported when grouping on a column(s) of the same frame. " + "https://github.com/modin-project/modin/issues/5926" ) @@ -3790,7 +3790,7 @@ def _groupby_shuffle( by_dtypes = self.dtypes[by] if any(isinstance(dtype, pandas.CategoricalDtype) for dtype in by_dtypes): raise NotImplementedError( - "Reshuffling groupby is not yet supported when grouping on a categorical column. " + "Range-partitioning groupby is not yet supported when grouping on a categorical column. " + "https://github.com/modin-project/modin/issues/5925" ) @@ -3799,7 +3799,7 @@ def _groupby_shuffle( if is_transform: # https://github.com/modin-project/modin/issues/5924 ErrorMessage.missmatch_with_pandas( - operation="reshuffling groupby", + operation="range-partitioning groupby", message="the order of rows may be shuffled for the result", ) @@ -3962,9 +3962,9 @@ def groupby_agg( ) # 'group_wise' means 'groupby.apply()'. We're certain that range-partitioning groupby - # always works better for '.apply()', so we're using it regardless of the 'ExperimentalGroupbyImpl' + # always works better for '.apply()', so we're using it regardless of the 'RangePartitioningGroupby' # value - if how == "group_wise" or ExperimentalGroupbyImpl.get(): + if how == "group_wise" or RangePartitioningGroupby.get(): try: return self._groupby_shuffle( by=by, @@ -3980,11 +3980,11 @@ def groupby_agg( # if a user wants to use range-partitioning groupby explicitly, then we should print a visible # warning to them on a failure, otherwise we're only logging it message = ( - f"Can't use experimental reshuffling groupby implementation because of: {e}" + f"Can't use range-partitioning groupby implementation because of: {e}" + "\nFalling back to a full-axis implementation." ) get_logger().info(message) - if ExperimentalGroupbyImpl.get(): + if RangePartitioningGroupby.get(): ErrorMessage.warn(message) if isinstance(agg_func, dict) and GroupbyReduceImpl.has_impl_for(agg_func): diff --git a/modin/numpy/arr.py b/modin/numpy/arr.py index 6bf7b3186c5..8f8880a381b 100644 --- a/modin/numpy/arr.py +++ b/modin/numpy/arr.py @@ -166,7 +166,7 @@ def __init__( ): self._siblings = [] ErrorMessage.single_warning( - "Using Modin's new NumPy API. To convert from a Modin object to a NumPy array, either turn off the ExperimentalNumPyAPI flag, or use `modin.utils.to_numpy`." + "Using Modin's new NumPy API. To convert from a Modin object to a NumPy array, either turn off the ModinNumpy flag, or use `modin.pandas.io.to_numpy`." ) if isinstance(object, array): _query_compiler = object._query_compiler.copy() diff --git a/modin/pandas/base.py b/modin/pandas/base.py index aedeed9555c..7671612e004 100644 --- a/modin/pandas/base.py +++ b/modin/pandas/base.py @@ -3371,9 +3371,9 @@ def to_numpy( """ Convert the `BasePandasDataset` to a NumPy array or a Modin wrapper for NumPy array. """ - from modin.config import ExperimentalNumPyAPI + from modin.config import ModinNumpy - if ExperimentalNumPyAPI.get(): + if ModinNumpy.get(): from ..numpy.arr import array return array(self, copy=copy) diff --git a/modin/pandas/series.py b/modin/pandas/series.py index be43ef3a06f..2466be99deb 100644 --- a/modin/pandas/series.py +++ b/modin/pandas/series.py @@ -496,9 +496,9 @@ def values(self): # noqa: RT01, D200 data = self.to_numpy() if isinstance(self.dtype, pd.CategoricalDtype): - from modin.config import ExperimentalNumPyAPI + from modin.config import ModinNumpy - if ExperimentalNumPyAPI.get(): + if ModinNumpy.get(): data = data._to_numpy() data = pd.Categorical(data, dtype=self.dtype) return data @@ -1914,9 +1914,9 @@ def to_numpy( """ Return the NumPy ndarray representing the values in this Series or Index. """ - from modin.config import ExperimentalNumPyAPI + from modin.config import ModinNumpy - if not ExperimentalNumPyAPI.get(): + if not ModinNumpy.get(): return ( super(Series, self) .to_numpy( diff --git a/modin/pandas/test/test_groupby.py b/modin/pandas/test/test_groupby.py index 484cdade2f3..81241a4d2d9 100644 --- a/modin/pandas/test/test_groupby.py +++ b/modin/pandas/test/test_groupby.py @@ -22,7 +22,7 @@ import modin.pandas as pd from modin.config import NPartitions, StorageFormat -from modin.config.envvars import ExperimentalGroupbyImpl, IsRayCluster +from modin.config.envvars import IsRayCluster, RangePartitioningGroupby from modin.core.dataframe.algebra.default2pandas.groupby import GroupBy from modin.core.dataframe.pandas.partitioning.axis_partition import ( PandasDataframeAxisPartition, @@ -283,7 +283,7 @@ def test_mixed_dtypes_groupby(as_index): eval_max(modin_groupby, pandas_groupby) eval_len(modin_groupby, pandas_groupby) eval_sum(modin_groupby, pandas_groupby) - if not ExperimentalGroupbyImpl.get(): + if not RangePartitioningGroupby.get(): # `.group` fails with experimental groupby # https://github.com/modin-project/modin/issues/6083 eval_ngroup(modin_groupby, pandas_groupby) @@ -1179,7 +1179,7 @@ def sort_index_if_experimental_groupby(*dfs): the experimental implementation changes the order of rows for that: https://github.com/modin-project/modin/issues/5924 """ - if ExperimentalGroupbyImpl.get(): + if RangePartitioningGroupby.get(): return tuple(df.sort_index() for df in dfs) return dfs @@ -1440,7 +1440,7 @@ def test(grp): def eval_groups(modin_groupby, pandas_groupby): for k, v in modin_groupby.groups.items(): assert v.equals(pandas_groupby.groups[k]) - if ExperimentalGroupbyImpl.get(): + if RangePartitioningGroupby.get(): # `.get_group()` doesn't work correctly with experimental groupby: # https://github.com/modin-project/modin/issues/6093 return @@ -1751,7 +1751,7 @@ def test_agg_func_None_rename(by_and_agg_dict, as_index): False, marks=pytest.mark.skipif( get_current_execution() == "BaseOnPython" - or ExperimentalGroupbyImpl.get(), + or RangePartitioningGroupby.get(), reason="See Pandas issue #39103", ), ), @@ -2884,8 +2884,8 @@ def perform(lib): @pytest.mark.parametrize( "modify_config", [ - {ExperimentalGroupbyImpl: True, IsRayCluster: True}, - {ExperimentalGroupbyImpl: True, IsRayCluster: False}, + {RangePartitioningGroupby: True, IsRayCluster: True}, + {RangePartitioningGroupby: True, IsRayCluster: False}, ], indirect=True, ) @@ -2948,7 +2948,7 @@ def func3(group): @pytest.mark.parametrize( - "modify_config", [{ExperimentalGroupbyImpl: True}], indirect=True + "modify_config", [{RangePartitioningGroupby: True}], indirect=True ) def test_reshuffling_groupby_on_strings(modify_config): # reproducer from https://github.com/modin-project/modin/issues/6509 @@ -2965,7 +2965,7 @@ def test_reshuffling_groupby_on_strings(modify_config): @pytest.mark.parametrize( - "modify_config", [{ExperimentalGroupbyImpl: True}], indirect=True + "modify_config", [{RangePartitioningGroupby: True}], indirect=True ) def test_groupby_apply_series_result(modify_config): # reproducer from the issue: diff --git a/modin/test/storage_formats/pandas/test_internals.py b/modin/test/storage_formats/pandas/test_internals.py index c0eb02ca971..04e58242dcc 100644 --- a/modin/test/storage_formats/pandas/test_internals.py +++ b/modin/test/storage_formats/pandas/test_internals.py @@ -20,7 +20,7 @@ import pytest import modin.pandas as pd -from modin.config import Engine, ExperimentalGroupbyImpl, MinPartitionSize, NPartitions +from modin.config import Engine, MinPartitionSize, NPartitions, RangePartitioningGroupby from modin.core.dataframe.pandas.dataframe.dataframe import PandasDataframe from modin.core.dataframe.pandas.dataframe.utils import ColumnInfo, ShuffleSortFunctions from modin.core.dataframe.pandas.metadata import ( @@ -1130,7 +1130,7 @@ def test_setitem_unhashable_preserve_dtypes(): @pytest.mark.parametrize( - "modify_config", [{ExperimentalGroupbyImpl: True}], indirect=True + "modify_config", [{RangePartitioningGroupby: True}], indirect=True ) def test_groupby_size_shuffling(modify_config): # verifies that 'groupby.size()' works with reshuffling implementation @@ -2404,7 +2404,7 @@ def test_groupby_index_dtype(self): assert res_dtypes._known_dtypes["a"] == np.dtype("int64") # case 2: ExperimentalImpl impl, Series as an output of groupby - ExperimentalGroupbyImpl.put(True) + RangePartitioningGroupby.put(True) try: df = pd.DataFrame({"a": [1, 2, 2], "b": [3, 4, 5]}) res = df.groupby("a").size().reset_index(name="new_name") @@ -2412,7 +2412,7 @@ def test_groupby_index_dtype(self): assert "a" in res_dtypes._known_dtypes assert res_dtypes._known_dtypes["a"] == np.dtype("int64") finally: - ExperimentalGroupbyImpl.put(False) + RangePartitioningGroupby.put(False) # case 3: MapReduce impl, DataFrame as an output of groupby df = pd.DataFrame({"a": [1, 2, 2], "b": [3, 4, 5]}) @@ -2422,7 +2422,7 @@ def test_groupby_index_dtype(self): assert res_dtypes._known_dtypes["a"] == np.dtype("int64") # case 4: ExperimentalImpl impl, DataFrame as an output of groupby - ExperimentalGroupbyImpl.put(True) + RangePartitioningGroupby.put(True) try: df = pd.DataFrame({"a": [1, 2, 2], "b": [3, 4, 5]}) res = df.groupby("a").sum().reset_index() @@ -2430,7 +2430,7 @@ def test_groupby_index_dtype(self): assert "a" in res_dtypes._known_dtypes assert res_dtypes._known_dtypes["a"] == np.dtype("int64") finally: - ExperimentalGroupbyImpl.put(False) + RangePartitioningGroupby.put(False) # case 5: FullAxis impl, DataFrame as an output of groupby df = pd.DataFrame({"a": [1, 2, 2], "b": [3, 4, 5]}) From acfcf3447440148d44b0a65e3d2609d38a02847f Mon Sep 17 00:00:00 2001 From: Dmitry Chigarev Date: Wed, 13 Dec 2023 14:46:40 +0100 Subject: [PATCH 120/201] FIX-#6822: Do not propagate NotImplementedError to a user on a 'set_columns()' with dupl labels (#6823) Signed-off-by: Dmitry Chigarev --- modin/core/dataframe/pandas/dataframe/dataframe.py | 14 +++++++++++--- modin/core/dataframe/pandas/metadata/dtypes.py | 5 +++++ .../test/storage_formats/pandas/test_internals.py | 11 +++++++++++ 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/modin/core/dataframe/pandas/dataframe/dataframe.py b/modin/core/dataframe/pandas/dataframe/dataframe.py index 74ab2a5edd8..8a1e5b4c8cb 100644 --- a/modin/core/dataframe/pandas/dataframe/dataframe.py +++ b/modin/core/dataframe/pandas/dataframe/dataframe.py @@ -709,11 +709,19 @@ def _set_columns(self, new_columns): return new_columns = self._validate_set_axis(new_columns, self._columns_cache) if isinstance(self._dtypes, ModinDtypes): - new_value = self._dtypes.set_index(new_columns) - self.set_dtypes_cache(new_value) + try: + new_dtypes = self._dtypes.set_index(new_columns) + except NotImplementedError: + # can raise on duplicated labels + new_dtypes = None elif isinstance(self._dtypes, pandas.Series): - self.dtypes.index = new_columns + new_dtypes = self.dtypes.set_axis(new_columns) + else: + new_dtypes = None self.set_columns_cache(new_columns) + # we have to set new dtypes cache after columns, + # so the 'self.columns' and 'new_dtypes.index' indices would match + self.set_dtypes_cache(new_dtypes) self.synchronize_labels(axis=1) columns = property(_get_columns, _set_columns) diff --git a/modin/core/dataframe/pandas/metadata/dtypes.py b/modin/core/dataframe/pandas/metadata/dtypes.py index 35674441af0..6674ac3986c 100644 --- a/modin/core/dataframe/pandas/metadata/dtypes.py +++ b/modin/core/dataframe/pandas/metadata/dtypes.py @@ -294,6 +294,11 @@ def set_index( Calling this method on a descriptor that returns ``None`` for ``.columns_order`` will result into information lose. """ + if len(new_index) != len(set(new_index)): + raise NotImplementedError( + "Duplicated column names are not yet supported by DtypesDescriptor" + ) + if self.columns_order is None: # we can't map new columns to old columns and lost all dtypes :( return DtypesDescriptor( diff --git a/modin/test/storage_formats/pandas/test_internals.py b/modin/test/storage_formats/pandas/test_internals.py index 04e58242dcc..099a4fc784f 100644 --- a/modin/test/storage_formats/pandas/test_internals.py +++ b/modin/test/storage_formats/pandas/test_internals.py @@ -2171,6 +2171,17 @@ def test_set_index_dataframe(self, initial_dtypes, result_dtypes): assert df._dtypes._value.equals(result_dtypes) assert df.dtypes.index.equals(pandas.Index(["col1", "col2", "col3"])) + def test_set_index_with_dupl_labels(self): + """Verify that setting duplicated columns doesn't propagate any errors to a user.""" + df = pd.DataFrame({"a": [1, 2, 3, 4], "b": [3.5, 4.4, 5.5, 6.6]}) + # making sure that dtypes are represented by an unmaterialized dtypes-descriptor + df._query_compiler._modin_frame.set_dtypes_cache(None) + + df.columns = ["a", "a"] + assert df.dtypes.equals( + pandas.Series([np.dtype(int), np.dtype("float64")], index=["a", "a"]) + ) + class TestZeroComputationDtypes: """ From 7f2dc36a0d096ba9e472bd626f7eca7eb4549909 Mon Sep 17 00:00:00 2001 From: Anatoly Myachev Date: Wed, 13 Dec 2023 15:27:26 +0100 Subject: [PATCH 121/201] DOCS-#6819: Update Modin on cluster documentation (#6678) Co-authored-by: Iaroslav Igoshev Signed-off-by: Anatoly Myachev --- .../using_modin/using_modin_cluster.rst | 37 +++++++++++++++---- .../using_modin/using_modin_locally.rst | 22 +++++++---- modin/core/execution/ray/common/utils.py | 2 + 3 files changed, 46 insertions(+), 15 deletions(-) diff --git a/docs/getting_started/using_modin/using_modin_cluster.rst b/docs/getting_started/using_modin/using_modin_cluster.rst index 1dfc39d3182..5393bb8917a 100644 --- a/docs/getting_started/using_modin/using_modin_cluster.rst +++ b/docs/getting_started/using_modin/using_modin_cluster.rst @@ -4,7 +4,7 @@ Using Modin in a Cluster .. note:: | *Estimated Reading Time: 15 minutes* - | You can follow along in a Jupyter notebook in this two-part tutorial: [`Part 1 `_], [`Part 2 `_]. + | You can follow along in a Jupyter notebook in this two-part tutorial: `Part 1`_, `Part 2`_. Often in practice we have a need to exceed the capabilities of a single machine. Modin works and performs well in both local mode and in a cluster environment. The key @@ -15,8 +15,9 @@ transparently. Starting up a Ray Cluster ------------------------- -Modin is able to utilize Ray's built-in autoscaled cluster. To launch a Ray cluster using Amazon Web Service (AWS), you can use `this file `_ -as the config file. +Modin is able to utilize Ray's built-in autoscaled cluster. To launch a Ray cluster +using Amazon Web Service (AWS), you can use `Modin's cluster setup config`_ +(`Ray's autoscaler options`_). .. code-block:: bash @@ -31,8 +32,11 @@ To start up the Ray cluster, run the following command in your terminal: This configuration script starts 1 head node (m5.24xlarge) and 7 workers (m5.24xlarge), 768 total CPUs. For more information on how to launch a Ray cluster across different -cloud providers or on-premise, you can also refer to the Ray documentation `here `_. +cloud providers or on-premise, you can also refer to the `Ray's cluster docs`_. +.. note:: + By default, Modin on Ray uses 60% of the system memory. It is recommended to use the same + amount, when using your own cluster (for each node). Connecting to a Ray Cluster --------------------------- @@ -56,13 +60,20 @@ Modin: Congratualions! You have successfully connected to the Ray cluster. +.. note:: + Be careful when using the Ray client to connect to a remote cluster. + This connection mode may not work. Known bugs: + - https://github.com/ray-project/ray/issues/38713, + - https://github.com/modin-project/modin/issues/6641. + Using Modin on a Ray Cluster ---------------------------- Now that we have a Ray cluster up and running, we can use Modin to perform pandas operation as if we were working with pandas on a single machine. We test Modin's -performance on the 200MB `NYC Taxi dataset `_ that was provided as part of our `cluster setup script `_. We can time the following operation -in a Jupyter notebook: +performance on the 200MB `NYC Taxi dataset`_ that was provided as part of our +`Modin's cluster setup config`_. We can time the following operation in a Jupyter +notebook: .. code-block:: python @@ -78,6 +89,10 @@ in a Jupyter notebook: %%time apply_result = df.map(str) +.. note:: + When using local paths, make sure that they are available on all nodes in the + cluster, for example using distributed file system like NFS. + Modin performance scales as the number of nodes and cores increases. The following chart shows the performance of the above operations with 2, 4, and 8 nodes, with improvements in performance as we increase the number of resources Modin can use. @@ -93,7 +108,7 @@ Advanced: Configuring your Ray Environment In some cases, it may be useful to customize your Ray environment. Below, we have listed a few ways you can solve common problems in data management with Modin by customizing your Ray environment. It is possible to use any of Ray's initialization parameters, -which are all found in `Ray's documentation`_. +which are all found in `Ray's API docs`_. .. code-block:: python @@ -108,4 +123,10 @@ you can customize your Ray environment for use in Modin! .. _`DataFrame`: https://pandas.pydata.org/pandas-docs/stable/reference/api/pandas.DataFrame.html .. _`pandas`: https://pandas.pydata.org/pandas-docs/stable/ .. _`open an issue`: https://github.com/modin-project/modin/issues -.. _`Ray's documentation`: https://ray.readthedocs.io/en/latest/api.html +.. _`Ray's API docs`: https://ray.readthedocs.io/en/latest/api.html +.. _`Part 1`: https://github.com/modin-project/modin/tree/master/examples/tutorial/jupyter/execution/pandas_on_ray/cluster/exercise_5.ipynb +.. _`Part 2`: https://github.com/modin-project/modin/tree/master/examples/tutorial/jupyter/execution/pandas_on_ray/cluster/exercise_6.ipynb +.. _`Ray's autoscaler options`: https://docs.ray.io/en/latest/cluster/vms/references/ray-cluster-configuration.html#cluster-config +.. _`Ray's cluster docs`: https://docs.ray.io/en/latest/cluster/getting-started.html +.. _`NYC Taxi dataset`: https://modin-datasets.s3.amazonaws.com/testing/yellow_tripdata_2015-01.csv +.. _`Modin's cluster setup config`: https://github.com/modin-project/modin/blob/master/examples/tutorial/jupyter/execution/pandas_on_ray/cluster/modin-cluster.yaml diff --git a/docs/getting_started/using_modin/using_modin_locally.rst b/docs/getting_started/using_modin/using_modin_locally.rst index 4ea095189ef..d69cf7a6b1e 100644 --- a/docs/getting_started/using_modin/using_modin_locally.rst +++ b/docs/getting_started/using_modin/using_modin_locally.rst @@ -4,14 +4,16 @@ Using Modin Locally .. note:: | *Estimated Reading Time: 5 minutes* - | You can follow along this tutorial in a Jupyter notebook `here `. + | You can follow along this tutorial in the `Jupyter notebook`_. In our quickstart example, we have already seen how you can achieve considerable -speedup from Modin, even on a single machine. Users do not need to know how many cores their system has, nor do they need to specify how to distribute the data. In fact, +speedup from Modin, even on a single machine. Users do not need to know how many +cores their system has, nor do they need to specify how to distribute the data. In fact, users can **continue using their existing pandas code** while experiencing a considerable speedup from Modin, even on a single machine. -To use Modin on a single machine, only a modification of the import statement is needed. Once you've changed your import statement, you're ready to use Modin +To use Modin on a single machine, only a modification of the import statement is needed. +Once you've changed your import statement, you're ready to use Modin just like you would pandas, since the API is identical to pandas. .. code-block:: python @@ -66,7 +68,8 @@ cluster for you: Finally, if you already have an Ray or Dask engine initialized, Modin will automatically attach to whichever engine is available. If you are interested in using -Modin with HDK engine, please refer to :doc:`these instructions `. For additional information on other settings you can configure, see +Modin with HDK engine, please refer to :doc:`these instructions `. +For additional information on other settings you can configure, see :doc:`Modin's config ` page for more details. Advanced: Configuring the resources Modin uses @@ -81,8 +84,8 @@ the following code: import modin print(modin.config.NPartitions.get()) #prints 16 on a laptop with 16 physical cores -Modin fully utilizes the resources on your machine. To read more about how this works, see :doc:`Why Modin? ` -page for more details. +Modin fully utilizes the resources on your machine. To read more about how this works, +see :doc:`Why Modin? ` page for more details. Since Modin will use all of the resources available on your machine by default, at times, it is possible that you may like to limit the amount of resources Modin uses to @@ -116,4 +119,9 @@ specify more processors than you have available on your machine; however this wi improve the performance (and might end up hurting the performance of the system). .. note:: - Make sure to update the ``MODIN_CPUS`` configuration and initialize your preferred engine before you start working with the first operation using Modin! Otherwise, Modin will opt for the default setting. + Make sure to update the ``MODIN_CPUS`` configuration and initialize your preferred + engine before you start working with the first operation using Modin! Otherwise, + Modin will opt for the default setting. + + +.. _`Jupyter notebook`: https://github.com/modin-project/modin/tree/master/examples/quickstart.ipynb diff --git a/modin/core/execution/ray/common/utils.py b/modin/core/execution/ray/common/utils.py index bca48f97473..14254586c4c 100644 --- a/modin/core/execution/ray/common/utils.py +++ b/modin/core/execution/ray/common/utils.py @@ -50,6 +50,8 @@ _RAY_IGNORE_UNHANDLED_ERRORS_VAR = "RAY_IGNORE_UNHANDLED_ERRORS" ObjectIDType = ray.ObjectRef +# TODO: Minimum version of Ray - 1.13 +# `if` branch can be deleted if version.parse(ray.__version__) >= version.parse("1.2.0"): from ray.util.client.common import ClientObjectRef From adbf68a89350404303e9e5f99afe4e29ed9acdab Mon Sep 17 00:00:00 2001 From: Anatoly Myachev Date: Wed, 13 Dec 2023 16:43:38 +0100 Subject: [PATCH 122/201] FEAT-#6820: Make sure IO functions works with path-like filenames (#6821) Signed-off-by: Anatoly Myachev --- .../implementations/pandas_on_ray/io/io.py | 3 ++- .../pandas_on_unidist/io/io.py | 4 +++- .../io/column_stores/feather_dispatcher.py | 3 +++ .../io/column_stores/parquet_dispatcher.py | 1 + modin/core/io/text/excel_dispatcher.py | 2 ++ modin/core/io/text/json_dispatcher.py | 2 ++ modin/core/io/text/text_file_dispatcher.py | 2 ++ .../core/io/pickle/pickle_dispatcher.py | 3 +++ .../core/io/text/custom_text_dispatcher.py | 2 ++ modin/experimental/pandas/test/test_io_exp.py | 8 +++++-- modin/pandas/test/test_io.py | 21 ++++++++++++++----- 11 files changed, 42 insertions(+), 9 deletions(-) diff --git a/modin/core/execution/ray/implementations/pandas_on_ray/io/io.py b/modin/core/execution/ray/implementations/pandas_on_ray/io/io.py index 8edcc05b9f6..cca429a526b 100644 --- a/modin/core/execution/ray/implementations/pandas_on_ray/io/io.py +++ b/modin/core/execution/ray/implementations/pandas_on_ray/io/io.py @@ -16,7 +16,7 @@ import io import pandas -from pandas.io.common import get_handle +from pandas.io.common import get_handle, stringify_path from modin.core.execution.ray.common import RayWrapper, SignalActor from modin.core.execution.ray.generic.io import RayIO @@ -154,6 +154,7 @@ def to_csv(cls, qc, **kwargs): **kwargs : dict Parameters for ``pandas.to_csv(**kwargs)``. """ + kwargs["path_or_buf"] = stringify_path(kwargs["path_or_buf"]) if not cls._to_csv_check_support(kwargs): return RayIO.to_csv(qc, **kwargs) diff --git a/modin/core/execution/unidist/implementations/pandas_on_unidist/io/io.py b/modin/core/execution/unidist/implementations/pandas_on_unidist/io/io.py index e0858e4058b..12e053252f5 100644 --- a/modin/core/execution/unidist/implementations/pandas_on_unidist/io/io.py +++ b/modin/core/execution/unidist/implementations/pandas_on_unidist/io/io.py @@ -16,6 +16,7 @@ import io import pandas +from pandas.io.common import get_handle, stringify_path from modin.core.execution.unidist.common import SignalActor, UnidistWrapper from modin.core.execution.unidist.generic.io import UnidistIO @@ -153,6 +154,7 @@ def to_csv(cls, qc, **kwargs): **kwargs : dict Parameters for ``pandas.to_csv(**kwargs)``. """ + kwargs["path_or_buf"] = stringify_path(kwargs["path_or_buf"]) if not cls._to_csv_check_support(kwargs): return UnidistIO.to_csv(qc, **kwargs) @@ -196,7 +198,7 @@ def func(df, **kw): # pragma: no cover UnidistWrapper.materialize(signals.wait.remote(partition_idx)) # preparing to write data from the buffer to a file - with pandas.io.common.get_handle( + with get_handle( path_or_buf, # in case when using URL in implicit text mode # pandas try to open `path_or_buf` in binary mode diff --git a/modin/core/io/column_stores/feather_dispatcher.py b/modin/core/io/column_stores/feather_dispatcher.py index e79ec9b8c17..ce4f60efbf0 100644 --- a/modin/core/io/column_stores/feather_dispatcher.py +++ b/modin/core/io/column_stores/feather_dispatcher.py @@ -13,6 +13,8 @@ """Module houses `FeatherDispatcher` class, that is used for reading `.feather` files.""" +from pandas.io.common import stringify_path + from modin.core.io.column_stores.column_store_dispatcher import ColumnStoreDispatcher from modin.core.io.file_dispatcher import OpenFile from modin.utils import import_optional_dependency @@ -47,6 +49,7 @@ def _read(cls, path, columns=None, **kwargs): PyArrow feather is used. Please refer to the documentation here https://arrow.apache.org/docs/python/api.html#feather-format """ + path = stringify_path(path) path = cls.get_path(path) if columns is None: import_optional_dependency( diff --git a/modin/core/io/column_stores/parquet_dispatcher.py b/modin/core/io/column_stores/parquet_dispatcher.py index 801474fef8b..f894f247e6a 100644 --- a/modin/core/io/column_stores/parquet_dispatcher.py +++ b/modin/core/io/column_stores/parquet_dispatcher.py @@ -818,6 +818,7 @@ def write(cls, qc, **kwargs): **kwargs : dict Parameters for `pandas.to_parquet(**kwargs)`. """ + kwargs["path"] = stringify_path(kwargs["path"]) output_path = kwargs["path"] if not isinstance(output_path, str): return cls.base_io.to_parquet(qc, **kwargs) diff --git a/modin/core/io/text/excel_dispatcher.py b/modin/core/io/text/excel_dispatcher.py index 96628ba52e6..4a61d7f4833 100644 --- a/modin/core/io/text/excel_dispatcher.py +++ b/modin/core/io/text/excel_dispatcher.py @@ -19,6 +19,7 @@ from io import BytesIO import pandas +from pandas.io.common import stringify_path from modin.config import NPartitions from modin.core.io.text.text_file_dispatcher import TextFileDispatcher @@ -47,6 +48,7 @@ def _read(cls, io, **kwargs): new_query_compiler : BaseQueryCompiler Query compiler with imported data for further processing. """ + io = stringify_path(io) if ( kwargs.get("engine", None) is not None and kwargs.get("engine") != "openpyxl" diff --git a/modin/core/io/text/json_dispatcher.py b/modin/core/io/text/json_dispatcher.py index 9d4873c8bb8..62911f71d06 100644 --- a/modin/core/io/text/json_dispatcher.py +++ b/modin/core/io/text/json_dispatcher.py @@ -17,6 +17,7 @@ import numpy as np import pandas +from pandas.io.common import stringify_path from modin.config import NPartitions from modin.core.io.file_dispatcher import OpenFile @@ -43,6 +44,7 @@ def _read(cls, path_or_buf, **kwargs): BaseQueryCompiler Query compiler with imported data for further processing. """ + path_or_buf = stringify_path(path_or_buf) path_or_buf = cls.get_path_or_buffer(path_or_buf) if isinstance(path_or_buf, str): if not cls.file_exists(path_or_buf): diff --git a/modin/core/io/text/text_file_dispatcher.py b/modin/core/io/text/text_file_dispatcher.py index 1862862dc99..4516b1a0803 100644 --- a/modin/core/io/text/text_file_dispatcher.py +++ b/modin/core/io/text/text_file_dispatcher.py @@ -28,6 +28,7 @@ import pandas import pandas._libs.lib as lib from pandas.core.dtypes.common import is_list_like +from pandas.io.common import stringify_path from modin.config import NPartitions from modin.core.io.file_dispatcher import FileDispatcher, OpenFile @@ -997,6 +998,7 @@ def _read(cls, filepath_or_buffer, **kwargs): new_query_compiler : BaseQueryCompiler Query compiler with imported data for further processing. """ + filepath_or_buffer = stringify_path(filepath_or_buffer) filepath_or_buffer_md = ( cls.get_path(filepath_or_buffer) if isinstance(filepath_or_buffer, str) diff --git a/modin/experimental/core/io/pickle/pickle_dispatcher.py b/modin/experimental/core/io/pickle/pickle_dispatcher.py index b121e6fbccd..119171e061c 100644 --- a/modin/experimental/core/io/pickle/pickle_dispatcher.py +++ b/modin/experimental/core/io/pickle/pickle_dispatcher.py @@ -17,6 +17,7 @@ import warnings import pandas +from pandas.io.common import stringify_path from modin.config import NPartitions from modin.core.io.file_dispatcher import FileDispatcher @@ -49,6 +50,7 @@ def _read(cls, filepath_or_buffer, **kwargs): The number of partitions is equal to the number of input files. """ + filepath_or_buffer = stringify_path(filepath_or_buffer) if not (isinstance(filepath_or_buffer, str) and "*" in filepath_or_buffer): return cls.single_worker_read( filepath_or_buffer, @@ -113,6 +115,7 @@ def write(cls, qc, **kwargs): **kwargs : dict Parameters for ``pandas.to_pickle(**kwargs)``. """ + kwargs["filepath_or_buffer"] = stringify_path(kwargs["filepath_or_buffer"]) if not ( isinstance(kwargs["filepath_or_buffer"], str) and "*" in kwargs["filepath_or_buffer"] diff --git a/modin/experimental/core/io/text/custom_text_dispatcher.py b/modin/experimental/core/io/text/custom_text_dispatcher.py index 5ecead9bd8d..6e42a81d52b 100644 --- a/modin/experimental/core/io/text/custom_text_dispatcher.py +++ b/modin/experimental/core/io/text/custom_text_dispatcher.py @@ -14,6 +14,7 @@ """Module houses `ExperimentalCustomTextDispatcher` class, that is used for reading custom text files.""" import pandas +from pandas.io.common import stringify_path from modin.config import NPartitions from modin.core.io.file_dispatcher import OpenFile @@ -46,6 +47,7 @@ def _read(cls, filepath_or_buffer, columns, custom_parser, **kwargs): BaseQueryCompiler Query compiler with imported data for further processing. """ + filepath_or_buffer = stringify_path(filepath_or_buffer) filepath_or_buffer_md = ( cls.get_path(filepath_or_buffer) if isinstance(filepath_or_buffer, str) diff --git a/modin/experimental/pandas/test/test_io_exp.py b/modin/experimental/pandas/test/test_io_exp.py index 693dd170bb2..1b9bccd7efd 100644 --- a/modin/experimental/pandas/test/test_io_exp.py +++ b/modin/experimental/pandas/test/test_io_exp.py @@ -14,6 +14,7 @@ import contextlib import glob import json +from pathlib import Path import numpy as np import pandas @@ -243,11 +244,12 @@ def _pandas_read_csv_glob(path, storage_options): Engine.get() not in ("Ray", "Unidist", "Dask"), reason=f"{Engine.get()} does not have experimental API", ) +@pytest.mark.parametrize("pathlike", [False, True]) @pytest.mark.parametrize("compression", [None, "gzip"]) @pytest.mark.parametrize( "filename", [test_default_to_pickle_filename, "test_to_pickle*.pkl"] ) -def test_distributed_pickling(filename, compression): +def test_distributed_pickling(filename, compression, pathlike): data = test_data["int_data"] df = pd.DataFrame(data) @@ -255,6 +257,8 @@ def test_distributed_pickling(filename, compression): if compression: filename = f"{filename}.gz" + filename = Path(filename) if pathlike else filename + with ( warns_that_defaulting_to_pandas() if filename_param == test_default_to_pickle_filename @@ -264,7 +268,7 @@ def test_distributed_pickling(filename, compression): pickled_df = pd.read_pickle_distributed(filename, compression=compression) df_equals(pickled_df, df) - pickle_files = glob.glob(filename) + pickle_files = glob.glob(str(filename)) teardown_test_files(pickle_files) diff --git a/modin/pandas/test/test_io.py b/modin/pandas/test/test_io.py index bf2ca41f61f..61b6b614ee5 100644 --- a/modin/pandas/test/test_io.py +++ b/modin/pandas/test/test_io.py @@ -616,8 +616,11 @@ def test_read_csv_iteration(self, iterator): df_equals(modin_df, pd_df) - def test_read_csv_encoding_976(self): + @pytest.mark.parametrize("pathlike", [False, True]) + def test_read_csv_encoding_976(self, pathlike): file_name = "modin/pandas/test/data/issue_976.csv" + if pathlike: + file_name = Path(file_name) names = [str(i) for i in range(11)] kwargs = { @@ -2090,12 +2093,14 @@ def test_read_parquet_relative_to_user_home(make_parquet_file): @pytest.mark.filterwarnings(default_to_pandas_ignore_string) class TestJson: + @pytest.mark.parametrize("pathlike", [False, True]) @pytest.mark.parametrize("lines", [False, True]) - def test_read_json(self, make_json_file, lines): + def test_read_json(self, make_json_file, lines, pathlike): + unique_filename = make_json_file(lines=lines) eval_io( fn_name="read_json", # read_json kwargs - path_or_buf=make_json_file(lines=lines), + path_or_buf=Path(unique_filename) if pathlike else unique_filename, lines=lines, ) @@ -2684,7 +2689,8 @@ def test_to_html(self, tmp_path): @pytest.mark.filterwarnings(default_to_pandas_ignore_string) class TestFwf: - def test_fwf_file(self, make_fwf_file): + @pytest.mark.parametrize("pathlike", [False, True]) + def test_fwf_file(self, make_fwf_file, pathlike): fwf_data = ( "id8141 360.242940 149.910199 11950.7\n" + "id1594 444.953632 166.985655 11788.4\n" @@ -2695,7 +2701,12 @@ def test_fwf_file(self, make_fwf_file): unique_filename = make_fwf_file(fwf_data=fwf_data) colspecs = [(0, 6), (8, 20), (21, 33), (34, 43)] - df = pd.read_fwf(unique_filename, colspecs=colspecs, header=None, index_col=0) + df = pd.read_fwf( + Path(unique_filename) if pathlike else unique_filename, + colspecs=colspecs, + header=None, + index_col=0, + ) assert isinstance(df, pd.DataFrame) @pytest.mark.parametrize( From 1859a37d6b90a18410124b21e25e5c2d59b7217b Mon Sep 17 00:00:00 2001 From: Dmitry Chigarev Date: Wed, 13 Dec 2023 17:13:07 +0100 Subject: [PATCH 123/201] FIX-#6824: Invalidate 'ModinIndex._lengths_id' on empty partitions filtering (#6825) Signed-off-by: Dmitry Chigarev --- .../dataframe/pandas/dataframe/dataframe.py | 13 ++++++- modin/core/dataframe/pandas/metadata/index.py | 4 ++ .../storage_formats/pandas/test_internals.py | 37 +++++++++++++++++++ 3 files changed, 52 insertions(+), 2 deletions(-) diff --git a/modin/core/dataframe/pandas/dataframe/dataframe.py b/modin/core/dataframe/pandas/dataframe/dataframe.py index 8a1e5b4c8cb..48bf9a938ed 100644 --- a/modin/core/dataframe/pandas/dataframe/dataframe.py +++ b/modin/core/dataframe/pandas/dataframe/dataframe.py @@ -816,8 +816,17 @@ def _filter_empties(self, compute_metadata=True): if i < len(self.row_lengths) and self.row_lengths[i] != 0 ] ) - self._column_widths_cache = [w for w in self.column_widths if w != 0] - self._row_lengths_cache = [r for r in self.row_lengths if r != 0] + new_col_widths = [w for w in self.column_widths if w != 0] + new_row_lengths = [r for r in self.row_lengths if r != 0] + + # check whether an axis partitioning was modified and if we should reset the lengths id for 'ModinIndex' + if new_col_widths != self.column_widths: + self.set_columns_cache(self.copy_columns_cache(copy_lengths=False)) + if new_row_lengths != self.row_lengths: + self.set_index_cache(self.copy_index_cache(copy_lengths=False)) + + self._column_widths_cache = new_col_widths + self._row_lengths_cache = new_row_lengths def synchronize_labels(self, axis=None): """ diff --git a/modin/core/dataframe/pandas/metadata/index.py b/modin/core/dataframe/pandas/metadata/index.py index b378211fd77..d5aa37e52a0 100644 --- a/modin/core/dataframe/pandas/metadata/index.py +++ b/modin/core/dataframe/pandas/metadata/index.py @@ -144,6 +144,10 @@ def maybe_specify_new_frame_ref(self, value, axis) -> "ModinIndex": new_index = self.copy(copy_lengths=True) new_index._axis = axis new_index._value = self._get_default_callable(value, new_index._axis) + # if the '._value' was 'None' initially, then the '_is_default_callable' flag was + # also being set to 'False', since now the '._value' is a default callable, + # so we want to ensure that the flag is set to 'True' + new_index._is_default_callable = True return new_index @property diff --git a/modin/test/storage_formats/pandas/test_internals.py b/modin/test/storage_formats/pandas/test_internals.py index 099a4fc784f..71b01ea56db 100644 --- a/modin/test/storage_formats/pandas/test_internals.py +++ b/modin/test/storage_formats/pandas/test_internals.py @@ -1277,6 +1277,43 @@ def test_index_updates_axis(self): assert df2.index.equals(pandas.Index(["a", "b"])) assert df2.columns.equals(pandas.Index([0, 1, 2])) + def test_filter_empties_resets_lengths(self): + """Verify that filtering out empty partitions affects ``ModinIndex._lengths_id`` field.""" + # case1: partitioning is modified by '._filter_empties()', meaning that '._lengths_id' should be changed + md_df = construct_modin_df_by_scheme( + pandas.DataFrame({"a": [1, 1, 2, 2]}), + {"row_lengths": [2, 2], "column_widths": [1]}, + ) + mf = md_df.query("a < 2")._query_compiler._modin_frame + mf.index # trigger index materialization + + old_cache = mf._index_cache + assert mf._partitions.shape == (2, 1) + + mf._filter_empties() + new_cache = mf._index_cache + + assert new_cache._index_id == old_cache._index_id + assert new_cache._lengths_id != old_cache._lengths_id + assert new_cache._lengths_cache != old_cache._lengths_cache + + # case2: partitioning is NOT modified by '._filter_empties()', meaning that '._lengths_id' should stay the same + md_df = construct_modin_df_by_scheme( + pandas.DataFrame({"a": [1, 1, 2, 2]}), + {"row_lengths": [2, 2], "column_widths": [1]}, + ) + mf = md_df._query_compiler._modin_frame + + old_cache = mf._index_cache + assert mf._partitions.shape == (2, 1) + + mf._filter_empties() + new_cache = mf._index_cache + + assert new_cache._index_id == old_cache._index_id + assert new_cache._lengths_id == old_cache._lengths_id + assert new_cache._lengths_cache == old_cache._lengths_cache + def test_skip_set_columns(): """ From cf6cde2d46668c5480774bef877faf4efb60d4b1 Mon Sep 17 00:00:00 2001 From: Anatoly Myachev Date: Thu, 14 Dec 2023 09:50:31 +0100 Subject: [PATCH 124/201] REFACTOR-#0000: cleanup one todo and flake8 issues in modin/utils.py (#6826) Signed-off-by: Anatoly Myachev --- modin/core/execution/ray/common/utils.py | 9 ++------- modin/utils.py | 21 ++++++++------------- 2 files changed, 10 insertions(+), 20 deletions(-) diff --git a/modin/core/execution/ray/common/utils.py b/modin/core/execution/ray/common/utils.py index 14254586c4c..0e79d3da0a8 100644 --- a/modin/core/execution/ray/common/utils.py +++ b/modin/core/execution/ray/common/utils.py @@ -21,6 +21,7 @@ import psutil import ray from packaging import version +from ray.util.client.common import ClientObjectRef from modin.config import ( CIAWSAccessKeyID, @@ -49,13 +50,7 @@ _RAY_IGNORE_UNHANDLED_ERRORS_VAR = "RAY_IGNORE_UNHANDLED_ERRORS" -ObjectIDType = ray.ObjectRef -# TODO: Minimum version of Ray - 1.13 -# `if` branch can be deleted -if version.parse(ray.__version__) >= version.parse("1.2.0"): - from ray.util.client.common import ClientObjectRef - - ObjectIDType = (ray.ObjectRef, ClientObjectRef) +ObjectIDType = (ray.ObjectRef, ClientObjectRef) def initialize_ray( diff --git a/modin/utils.py b/modin/utils.py index 02ee27e4f1b..0a19c8c9411 100644 --- a/modin/utils.py +++ b/modin/utils.py @@ -49,7 +49,7 @@ ) from modin._version import get_versions -from modin.config import Engine, IsExperimental, StorageFormat +from modin.config import Engine, StorageFormat T = TypeVar("T") """Generic type parameter""" @@ -97,12 +97,10 @@ def _to_numpy(self) -> Any: # noqa: GL08 PANDAS_API_URL_TEMPLATE = f"https://pandas.pydata.org/pandas-docs/version/{pandas.__version__}/reference/api/{{}}.html" +# The '__reduced__' name is used internally by the query compiler as a column name to +# represent pandas Series objects that are not explicitly assigned a name, so as to +# distinguish between an N-element series and 1xN dataframe. MODIN_UNNAMED_SERIES_LABEL = "__reduced__" -""" -The '__reduced__' name is used internally by the query compiler as a column name to -represent pandas Series objects that are not explicitly assigned a name, so as to -distinguish between an N-element series and 1xN dataframe. -""" def _make_api_url(token: str) -> str: @@ -788,8 +786,6 @@ def _get_modin_deps_info() -> Mapping[str, Optional[JSONSerializable]]: return result -# Disable flake8 checks for print() in this file -# flake8: noqa: T001 def show_versions(as_json: Union[str, bool] = False) -> None: """ Provide useful information, important for bug reports. @@ -836,14 +832,13 @@ def show_versions(as_json: Union[str, bool] = False) -> None: sys_info["LOCALE"] = f"{language_code}.{encoding}" maxlen = max(max(len(x) for x in d) for d in (deps, modin_deps)) - print("\nINSTALLED VERSIONS") - print("------------------") + print("\nINSTALLED VERSIONS\n------------------") # noqa: T201 for k, v in sys_info.items(): - print(f"{k:<{maxlen}}: {v}") + print(f"{k:<{maxlen}}: {v}") # noqa: T201 for name, d in (("Modin", modin_deps), ("pandas", deps)): - print(f"\n{name} dependencies\n{'-' * (len(name) + 13)}") + print(f"\n{name} dependencies\n{'-' * (len(name) + 13)}") # noqa: T201 for k, v in d.items(): - print(f"{k:<{maxlen}}: {v}") + print(f"{k:<{maxlen}}: {v}") # noqa: T201 class ModinAssumptionError(Exception): From 47a9a4a294c75cd7b67f0fd7f95f846ed53fbafa Mon Sep 17 00:00:00 2001 From: Anatoly Myachev Date: Thu, 14 Dec 2023 14:39:19 +0100 Subject: [PATCH 125/201] Release version 0.26.0 (#6827) Signed-off-by: Anatoly Myachev From 992eff5fe8ec7a3f9f0cd6b0bc462f3af7b4229b Mon Sep 17 00:00:00 2001 From: Alexei Fedotov Date: Wed, 20 Dec 2023 18:03:35 +0100 Subject: [PATCH 126/201] FEAT-#6830: Remove public s3 bucket reference (#6829) Signed-off-by: Alexei Fedotov --- docs/getting_started/examples.rst | 2 +- docs/getting_started/quickstart.rst | 10 +++++----- .../using_modin/using_modin_cluster.rst | 2 +- examples/jupyter/Modin_Taxi.ipynb | 2 +- examples/jupyter/Pandas_Taxi.ipynb | 2 +- examples/jupyter/integrations/huggingface.ipynb | 2 +- examples/quickstart.ipynb | 6 +++--- .../execution/hdk_on_native/local/exercise_2.ipynb | 2 +- .../execution/pandas_on_ray/cluster/exercise_6.ipynb | 2 +- examples/tutorial/jupyter/execution/test/utils.py | 2 +- 10 files changed, 16 insertions(+), 16 deletions(-) diff --git a/docs/getting_started/examples.rst b/docs/getting_started/examples.rst index cf436cdd7d6..0170f5dc321 100644 --- a/docs/getting_started/examples.rst +++ b/docs/getting_started/examples.rst @@ -40,7 +40,7 @@ Talks & Podcasts - `Modin: Scaling the Capabilities of the Data Scientist, not the Machine `_ (1 hour, RISE Camp 2020) - `Modin: Pandas Scalability with Devin Petersohn `_ (1 hour, Software Engineering Daily Podcast 2020) - `Introduction to the DataFrame and Modin `_ (20 minute, RISECamp 2019) -- `Scaling Interactive Pandas Workflows with Modin `_ (40 minute, PyData NYC 2018) +- `Scaling Interactive Pandas Workflows with Modin `_ (40 minute, PyData NYC 2018) Community contributions ''''''''''''''''''''''' diff --git a/docs/getting_started/quickstart.rst b/docs/getting_started/quickstart.rst index 18d4a7ee278..dc6661bc7ab 100644 --- a/docs/getting_started/quickstart.rst +++ b/docs/getting_started/quickstart.rst @@ -64,14 +64,14 @@ For the purpose of demonstration, we will load in modin as ``pd`` and pandas as ray.init() ############################################# -In this toy example, we look at the NYC taxi dataset, which is around 200MB in size. You can download `this dataset `_ to run the example locally. +In this toy example, we look at the NYC taxi dataset, which is around 200MB in size. You can download `this dataset `_ to run the example locally. .. code-block:: python # This may take a few minutes to download import urllib.request - s3_path = "https://modin-datasets.s3.amazonaws.com/testing/yellow_tripdata_2015-01.csv" - urllib.request.urlretrieve(s3_path, "taxi.csv") + dataset_url = "https://modin-datasets.intel.com/testing/yellow_tripdata_2015-01.csv" + urllib.request.urlretrieve(dataset_url, "taxi.csv") Faster Data Loading with ``read_csv`` ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -80,7 +80,7 @@ Faster Data Loading with ``read_csv`` start = time.time() - pandas_df = pandas.read_csv(s3_path, parse_dates=["tpep_pickup_datetime", "tpep_dropoff_datetime"], quoting=3) + pandas_df = pandas.read_csv(dataset_url, parse_dates=["tpep_pickup_datetime", "tpep_dropoff_datetime"], quoting=3) end = time.time() pandas_duration = end - start @@ -93,7 +93,7 @@ for loading in the data in parallel. start = time.time() - modin_df = pd.read_csv(s3_path, parse_dates=["tpep_pickup_datetime", "tpep_dropoff_datetime"], quoting=3) + modin_df = pd.read_csv(dataset_url, parse_dates=["tpep_pickup_datetime", "tpep_dropoff_datetime"], quoting=3) end = time.time() modin_duration = end - start diff --git a/docs/getting_started/using_modin/using_modin_cluster.rst b/docs/getting_started/using_modin/using_modin_cluster.rst index 5393bb8917a..3bf9f5996af 100644 --- a/docs/getting_started/using_modin/using_modin_cluster.rst +++ b/docs/getting_started/using_modin/using_modin_cluster.rst @@ -128,5 +128,5 @@ you can customize your Ray environment for use in Modin! .. _`Part 2`: https://github.com/modin-project/modin/tree/master/examples/tutorial/jupyter/execution/pandas_on_ray/cluster/exercise_6.ipynb .. _`Ray's autoscaler options`: https://docs.ray.io/en/latest/cluster/vms/references/ray-cluster-configuration.html#cluster-config .. _`Ray's cluster docs`: https://docs.ray.io/en/latest/cluster/getting-started.html -.. _`NYC Taxi dataset`: https://modin-datasets.s3.amazonaws.com/testing/yellow_tripdata_2015-01.csv +.. _`NYC Taxi dataset`: https://modin-datasets.intel.com/testing/yellow_tripdata_2015-01.csv .. _`Modin's cluster setup config`: https://github.com/modin-project/modin/blob/master/examples/tutorial/jupyter/execution/pandas_on_ray/cluster/modin-cluster.yaml diff --git a/examples/jupyter/Modin_Taxi.ipynb b/examples/jupyter/Modin_Taxi.ipynb index 22fe9a9fab1..8cfc2709e35 100644 --- a/examples/jupyter/Modin_Taxi.ipynb +++ b/examples/jupyter/Modin_Taxi.ipynb @@ -13,7 +13,7 @@ "source": [ "# To run this notebook as done in the README GIFs, you must first locally download the 2015 NYC Taxi Trip Data.\n", "import urllib.request\n", - "url_path = \"https://modin-datasets.s3.amazonaws.com/testing/yellow_tripdata_2015-01.csv\"\n", + "url_path = \"https://modin-datasets.intel.com/testing/yellow_tripdata_2015-01.csv\"\n", "urllib.request.urlretrieve(url_path, \"taxi.csv\")\n", "\n", "from modin.config import Engine\n", diff --git a/examples/jupyter/Pandas_Taxi.ipynb b/examples/jupyter/Pandas_Taxi.ipynb index d6e5e61212d..fe2c72e357b 100644 --- a/examples/jupyter/Pandas_Taxi.ipynb +++ b/examples/jupyter/Pandas_Taxi.ipynb @@ -9,7 +9,7 @@ "source": [ "# To run this notebook as done in the README GIFs, you must first locally download the 2015 NYC Taxi Trip Data.\n", "import urllib.request\n", - "url_path = \"https://modin-datasets.s3.amazonaws.com/testing/yellow_tripdata_2015-01.csv\"\n", + "url_path = \"https://modin-datasets.intel.com/testing/yellow_tripdata_2015-01.csv\"\n", "urllib.request.urlretrieve(url_path, \"taxi.csv\")\n", "\n", "import warnings\n", diff --git a/examples/jupyter/integrations/huggingface.ipynb b/examples/jupyter/integrations/huggingface.ipynb index 988b4203075..69370054deb 100644 --- a/examples/jupyter/integrations/huggingface.ipynb +++ b/examples/jupyter/integrations/huggingface.ipynb @@ -47,7 +47,7 @@ ], "source": [ "import urllib.request\n", - "url_path = \"https://modin-datasets.s3.amazonaws.com/testing/IMDB_Dataset.csv\"\n", + "url_path = \"https://modin-datasets.intel.com/testing/IMDB_Dataset.csv\"\n", "urllib.request.urlretrieve(url_path, \"imdb.csv\")" ] }, diff --git a/examples/quickstart.ipynb b/examples/quickstart.ipynb index 3ea250a968c..395e7cd9f7f 100644 --- a/examples/quickstart.ipynb +++ b/examples/quickstart.ipynb @@ -83,7 +83,7 @@ "source": [ "### Dataset: NYC taxi trip data\n", "\n", - "Link to raw dataset: https://modin-datasets.s3.amazonaws.com/testing/yellow_tripdata_2015-01.csv (**Size: ~200MB**)" + "Link to raw dataset: https://modin-datasets.intel.com/testing/yellow_tripdata_2015-01.csv (**Size: ~200MB**)" ] }, { @@ -105,8 +105,8 @@ "source": [ "# This may take a few minutes to download\n", "import urllib.request\n", - "s3_path = \"https://modin-datasets.s3.amazonaws.com/testing/yellow_tripdata_2015-01.csv\"\n", - "urllib.request.urlretrieve(s3_path, \"taxi.csv\") " + "dataset_url = \"https://modin-datasets.intel.com/testing/yellow_tripdata_2015-01.csv\"\n", + "urllib.request.urlretrieve(dataset_url, \"taxi.csv\") " ] }, { diff --git a/examples/tutorial/jupyter/execution/hdk_on_native/local/exercise_2.ipynb b/examples/tutorial/jupyter/execution/hdk_on_native/local/exercise_2.ipynb index 0669d35a941..1ee31a5099e 100644 --- a/examples/tutorial/jupyter/execution/hdk_on_native/local/exercise_2.ipynb +++ b/examples/tutorial/jupyter/execution/hdk_on_native/local/exercise_2.ipynb @@ -65,7 +65,7 @@ "# Note that this may take a few minutes to download.\n", "\n", "import urllib.request\n", - "url_path = \"https://modin-datasets.s3.amazonaws.com/testing/yellow_tripdata_2015-01.csv\"\n", + "url_path = \"https://modin-datasets.intel.com/testing/yellow_tripdata_2015-01.csv\"\n", "urllib.request.urlretrieve(url_path, \"taxi.csv\")\n", "path = \"taxi.csv\"" ] diff --git a/examples/tutorial/jupyter/execution/pandas_on_ray/cluster/exercise_6.ipynb b/examples/tutorial/jupyter/execution/pandas_on_ray/cluster/exercise_6.ipynb index 6d0537a6b36..dd72518737c 100644 --- a/examples/tutorial/jupyter/execution/pandas_on_ray/cluster/exercise_6.ipynb +++ b/examples/tutorial/jupyter/execution/pandas_on_ray/cluster/exercise_6.ipynb @@ -25,7 +25,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "Modin performance scales as the number of nodes and cores increases. In this exercise, we will reproduce the data from the plot below using the 200MB [NYC Taxi dataset](https://modin-datasets.s3.amazonaws.com/testing/yellow_tripdata_2015-01.csv) that was provided as part of our [modin-cluster.yaml script](https://github.com/modin-project/modin/blob/master/examples/tutorial/jupyter/execution/pandas_on_ray/cluster/modin-cluster.yaml).\n", + "Modin performance scales as the number of nodes and cores increases. In this exercise, we will reproduce the data from the plot below using the 200MB [NYC Taxi dataset](https://modin-datasets.intel.com/testing/yellow_tripdata_2015-01.csv) that was provided as part of our [modin-cluster.yaml script](https://github.com/modin-project/modin/blob/master/examples/tutorial/jupyter/execution/pandas_on_ray/cluster/modin-cluster.yaml).\n", "\n", "![ClusterPerf](../../../img/modin_cluster_perf.png)" ] diff --git a/examples/tutorial/jupyter/execution/test/utils.py b/examples/tutorial/jupyter/execution/test/utils.py index cc1225a0030..b92033850c7 100644 --- a/examples/tutorial/jupyter/execution/test/utils.py +++ b/examples/tutorial/jupyter/execution/test/utils.py @@ -18,7 +18,7 @@ download_taxi_dataset = f"""import os import urllib.request if not os.path.exists("{test_dataset_path}"): - url_path = "https://modin-datasets.s3.amazonaws.com/testing/yellow_tripdata_2015-01.csv" + url_path = "https://modin-datasets.intel.com/testing/yellow_tripdata_2015-01.csv" urllib.request.urlretrieve(url_path, "{test_dataset_path}") """ From 7ef544f4467ddea18cfbb51ad2a6fcbbb12c0db3 Mon Sep 17 00:00:00 2001 From: Anatoly Myachev Date: Thu, 21 Dec 2023 16:39:11 +0100 Subject: [PATCH 127/201] REFACTOR-#6833: Remove `SocksProxy`, `DoLogRpyc`, `DoTraceRpyc` outdated classes (#6834) Signed-off-by: Anatoly Myachev --- modin/config/envvars.py | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/modin/config/envvars.py b/modin/config/envvars.py index 184c6805797..4dabbc20f8a 100644 --- a/modin/config/envvars.py +++ b/modin/config/envvars.py @@ -370,24 +370,6 @@ def _get_default(cls) -> int: return CpuCount.get() -class SocksProxy(EnvironmentVariable, type=ExactStr): - """SOCKS proxy address if it is needed for SSH to work.""" - - varname = "MODIN_SOCKS_PROXY" - - -class DoLogRpyc(EnvironmentVariable, type=bool): - """Whether to gather RPyC logs (applicable for remote context).""" - - varname = "MODIN_LOG_RPYC" - - -class DoTraceRpyc(EnvironmentVariable, type=bool): - """Whether to trace RPyC calls (applicable for remote context).""" - - varname = "MODIN_TRACE_RPYC" - - class HdkFragmentSize(EnvironmentVariable, type=int): """How big a fragment in HDK should be when creating a table (in rows).""" From e9cdbaaa8a36f27d6af26e7030ed1135ead90a2a Mon Sep 17 00:00:00 2001 From: Anatoly Myachev Date: Mon, 8 Jan 2024 14:24:02 +0100 Subject: [PATCH 128/201] FIX-#6840: Call 'tolist' function in 'DtypesDescriptor._merge_dtypes' (#6844) Signed-off-by: Anatoly Myachev --- .../core/dataframe/pandas/metadata/dtypes.py | 2 +- modin/pandas/test/test_concat.py | 20 +++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/modin/core/dataframe/pandas/metadata/dtypes.py b/modin/core/dataframe/pandas/metadata/dtypes.py index 6674ac3986c..a23cdeb4786 100644 --- a/modin/core/dataframe/pandas/metadata/dtypes.py +++ b/modin/core/dataframe/pandas/metadata/dtypes.py @@ -517,7 +517,7 @@ def _merge_dtypes( if dtypes_are_unknown: return DtypesDescriptor( - cols_with_unknown_dtypes=dtypes_matrix.index, + cols_with_unknown_dtypes=dtypes_matrix.index.tolist(), know_all_names=know_all_names, ) diff --git a/modin/pandas/test/test_concat.py b/modin/pandas/test/test_concat.py index c07860a1b60..19f9a8d21fc 100644 --- a/modin/pandas/test/test_concat.py +++ b/modin/pandas/test/test_concat.py @@ -197,6 +197,26 @@ def test_concat_5776(): ) +def test_concat_6840(): + groupby_objs = [] + for idx, lib in enumerate((pd, pandas)): + df1 = lib.DataFrame( + [["a", 1], ["b", 2], ["b", 4]], columns=["letter", "number"] + ) + df1_g = df1.groupby("letter", as_index=False)["number"].agg("sum") + + df2 = lib.DataFrame( + [["a", 3], ["a", 4], ["b", 1]], columns=["letter", "number"] + ) + df2_g = df2.groupby("letter", as_index=False)["number"].agg("sum") + groupby_objs.append([df1_g, df2_g]) + + df_equals( + pd.concat(groupby_objs[0]), + pandas.concat(groupby_objs[1]), + ) + + def test_concat_with_empty_frame(): modin_empty_df = pd.DataFrame() pandas_empty_df = pandas.DataFrame() From 92741feda50a34783e4c6adc3cd46a8a30f0cae2 Mon Sep 17 00:00:00 2001 From: Arun Jose <40291569+arunjose696@users.noreply.github.com> Date: Mon, 8 Jan 2024 16:20:35 +0100 Subject: [PATCH 129/201] FEAT-#6841: Fixing ray anti pattern with .length() and .width() being called in a loop (#6842) Co-authored-by: Andrey Pavlenko Co-authored-by: Anatoly Myachev --- .../dataframe/pandas/dataframe/dataframe.py | 47 +++++++++++++------ .../pandas/partitioning/axis_partition.py | 24 ++++++++-- .../pandas/partitioning/partition_manager.py | 31 ++++++++++++ .../execution/dask/common/engine_wrapper.py | 18 +++++++ .../execution/python/common/engine_wrapper.py | 16 +++++++ .../execution/ray/common/engine_wrapper.py | 19 ++++++++ .../pandas_on_ray/dataframe/dataframe.py | 18 +++++++ .../unidist/common/engine_wrapper.py | 17 +++++++ 8 files changed, 172 insertions(+), 18 deletions(-) diff --git a/modin/core/dataframe/pandas/dataframe/dataframe.py b/modin/core/dataframe/pandas/dataframe/dataframe.py index 48bf9a938ed..fc15c81496e 100644 --- a/modin/core/dataframe/pandas/dataframe/dataframe.py +++ b/modin/core/dataframe/pandas/dataframe/dataframe.py @@ -187,11 +187,29 @@ def row_lengths(self): if self._row_lengths_cache is None: if len(self._partitions.T) > 0: row_parts = self._partitions.T[0] - self._row_lengths_cache = [part.length() for part in row_parts] + self._row_lengths_cache = self._get_dimensions(row_parts, "length") else: self._row_lengths_cache = [] return self._row_lengths_cache + @classmethod + def _get_dimensions(cls, parts, dim_name): + """ + Get list of dimensions for all the provided parts. + + Parameters + ---------- + parts : list + List of parttions. + dim_name : string + Dimension name could be "length" or "width". + + Returns + ------- + list + """ + return [getattr(part, dim_name)() for part in parts] + def __len__(self) -> int: """ Return length of index axis. @@ -219,7 +237,7 @@ def column_widths(self): if self._column_widths_cache is None: if len(self._partitions) > 0: col_parts = self._partitions[0] - self._column_widths_cache = [part.width() for part in col_parts] + self._column_widths_cache = self._get_dimensions(col_parts, "width") else: self._column_widths_cache = [] return self._column_widths_cache @@ -3657,12 +3675,14 @@ def _compute_new_widths(): if not new_lengths: new_lengths = [] if new_partitions.size > 0: - for part in new_partitions.T[0]: - if part._length_cache is not None: - new_lengths.append(part.length()) - else: - new_lengths = None - break + if all( + part._length_cache is not None for part in new_partitions.T[0] + ): + new_lengths = self._get_dimensions( + new_partitions.T[0], "length" + ) + else: + new_lengths = None else: if all(obj.has_materialized_columns for obj in (self, *others)): new_columns = self.columns.append([other.columns for other in others]) @@ -3681,12 +3701,11 @@ def _compute_new_widths(): if not new_widths: new_widths = [] if new_partitions.size > 0: - for part in new_partitions[0]: - if part._width_cache is not None: - new_widths.append(part.width()) - else: - new_widths = None - break + if all(part._width_cache is not None for part in new_partitions[0]): + new_widths = self._get_dimensions(new_partitions[0], "width") + else: + new_widths = None + return self.__constructor__( new_partitions, new_index, new_columns, new_lengths, new_widths, new_dtypes ) diff --git a/modin/core/dataframe/pandas/partitioning/axis_partition.py b/modin/core/dataframe/pandas/partitioning/axis_partition.py index bfc94605744..06cb1bd65dd 100644 --- a/modin/core/dataframe/pandas/partitioning/axis_partition.py +++ b/modin/core/dataframe/pandas/partitioning/axis_partition.py @@ -575,10 +575,17 @@ def to_numpy(self): _length_cache = None - def length(self): + def length(self, materialize=True): """ Get the length of this partition. + Parameters + ---------- + materialize : bool, default: True + Whether to forcibly materialize the result into an integer. If ``False`` + was specified, may return a future of the result if it hasn't been + materialized yet. + Returns ------- int @@ -590,15 +597,24 @@ def length(self): obj.length() for obj in self.list_of_block_partitions ) else: - self._length_cache = self.list_of_block_partitions[0].length() + self._length_cache = self.list_of_block_partitions[0].length( + materialize + ) return self._length_cache _width_cache = None - def width(self): + def width(self, materialize=True): """ Get the width of this partition. + Parameters + ---------- + materialize : bool, default: True + Whether to forcibly materialize the result into an integer. If ``False`` + was specified, may return a future of the result if it hasn't been + materialized yet. + Returns ------- int @@ -610,7 +626,7 @@ def width(self): obj.width() for obj in self.list_of_block_partitions ) else: - self._width_cache = self.list_of_block_partitions[0].width() + self._width_cache = self.list_of_block_partitions[0].width(materialize) return self._width_cache def wait(self): diff --git a/modin/core/dataframe/pandas/partitioning/partition_manager.py b/modin/core/dataframe/pandas/partitioning/partition_manager.py index 400e9fad177..12a34187faf 100644 --- a/modin/core/dataframe/pandas/partitioning/partition_manager.py +++ b/modin/core/dataframe/pandas/partitioning/partition_manager.py @@ -99,6 +99,37 @@ class PandasDataframePartitionManager(ClassLogger, ABC): _column_partitions_class = None # Row partitions class is the class to use to create the row partitions. _row_partition_class = None + _execution_wrapper = None + + @classmethod + def materialize_futures(cls, input_list): + """ + Materialize all futures in the input list. + + Parameters + ---------- + input_list : list + The list that has to be manipulated. + + Returns + ------- + list + A new list with materialized objects. + """ + # Do nothing if input_list is None or []. + if input_list is None: + return None + filtered_list = [] + filtered_idx = [] + for idx, item in enumerate(input_list): + if cls._execution_wrapper.check_is_future(item): + filtered_idx.append(idx) + filtered_list.append(item) + filtered_list = cls._execution_wrapper.materialize(filtered_list) + result = input_list.copy() + for idx, item in zip(filtered_idx, filtered_list): + result[idx] = item + return result @classmethod def preprocess_func(cls, map_func): diff --git a/modin/core/execution/dask/common/engine_wrapper.py b/modin/core/execution/dask/common/engine_wrapper.py index 0090db9bb0a..fe21e1ea457 100644 --- a/modin/core/execution/dask/common/engine_wrapper.py +++ b/modin/core/execution/dask/common/engine_wrapper.py @@ -16,6 +16,7 @@ from collections import UserDict from dask.distributed import wait +from distributed import Future from distributed.client import default_client @@ -90,6 +91,23 @@ def deploy( ] return remote_task_future + @classmethod + def check_is_future(cls, item): + """ + Check if the item is a Future. + + Parameters + ---------- + item : distributed.Future or object + Future or object to check. + + Returns + ------- + boolean + If the value is a future. + """ + return isinstance(item, Future) + @classmethod def materialize(cls, future): """ diff --git a/modin/core/execution/python/common/engine_wrapper.py b/modin/core/execution/python/common/engine_wrapper.py index 427d48b4e8f..396793aa5db 100644 --- a/modin/core/execution/python/common/engine_wrapper.py +++ b/modin/core/execution/python/common/engine_wrapper.py @@ -41,6 +41,22 @@ def deploy(cls, func, f_args=None, f_kwargs=None, num_returns=1): kwargs = {} if f_kwargs is None else f_kwargs return func(*args, **kwargs) + @classmethod + def check_is_future(cls, item): + """ + Check if the item is a Future. + + Parameters + ---------- + item : object + + Returns + ------- + boolean + Always return false. + """ + return False + @classmethod def materialize(cls, obj_id): """ diff --git a/modin/core/execution/ray/common/engine_wrapper.py b/modin/core/execution/ray/common/engine_wrapper.py index b27b5d0396a..3fc1f75a906 100644 --- a/modin/core/execution/ray/common/engine_wrapper.py +++ b/modin/core/execution/ray/common/engine_wrapper.py @@ -20,6 +20,7 @@ import asyncio import ray +from ray.util.client.common import ClientObjectRef @ray.remote @@ -74,6 +75,24 @@ def deploy(cls, func, f_args=None, f_kwargs=None, num_returns=1): func, *args, **kwargs ) + @classmethod + def check_is_future(cls, item): + """ + Check if the item is a Future. + + Parameters + ---------- + item : ray.ObjectID or object + Future or object to check. + + Returns + ------- + boolean + If the value is a future. + """ + ObjectIDType = (ray.ObjectRef, ClientObjectRef) + return isinstance(item, ObjectIDType) + @classmethod def materialize(cls, obj_id): """ diff --git a/modin/core/execution/ray/implementations/pandas_on_ray/dataframe/dataframe.py b/modin/core/execution/ray/implementations/pandas_on_ray/dataframe/dataframe.py index 3a6704523c7..73b216c62c4 100644 --- a/modin/core/execution/ray/implementations/pandas_on_ray/dataframe/dataframe.py +++ b/modin/core/execution/ray/implementations/pandas_on_ray/dataframe/dataframe.py @@ -41,3 +41,21 @@ class PandasOnRayDataframe(PandasDataframe): """ _partition_mgr_cls = PandasOnRayDataframePartitionManager + + def _get_dimensions(self, parts, dim_name): + """ + Get list of dimensions for all the provided parts. + + Parameters + ---------- + parts : list + List of parttions. + dim_name : string + Dimension name could be "length" or "width". + + Returns + ------- + list + """ + dims = [getattr(part, dim_name)(False) for part in parts] + return self._partition_mgr_cls.materialize_futures(dims) diff --git a/modin/core/execution/unidist/common/engine_wrapper.py b/modin/core/execution/unidist/common/engine_wrapper.py index 298cb552058..f28115f133f 100644 --- a/modin/core/execution/unidist/common/engine_wrapper.py +++ b/modin/core/execution/unidist/common/engine_wrapper.py @@ -74,6 +74,23 @@ def deploy(cls, func, f_args=None, f_kwargs=None, num_returns=1): func, *args, **kwargs ) + @classmethod + def check_is_future(cls, item): + """ + Check if the item is a Future. + + Parameters + ---------- + item : unidist.ObjectRef or object + Future or object to check. + + Returns + ------- + boolean + If the value is a future. + """ + return unidist.is_object_ref(item) + @classmethod def materialize(cls, obj_id): """ From abf5a7c1631250e5092caf96968077836f7ace8b Mon Sep 17 00:00:00 2001 From: Anatoly Myachev Date: Mon, 8 Jan 2024 18:20:11 +0100 Subject: [PATCH 130/201] TEST-#6846: Skip unstable Unidist to_csv tests (#6847) Signed-off-by: Anatoly Myachev --- modin/pandas/test/test_io.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/modin/pandas/test/test_io.py b/modin/pandas/test/test_io.py index 61b6b614ee5..136a255654f 100644 --- a/modin/pandas/test/test_io.py +++ b/modin/pandas/test/test_io.py @@ -1103,6 +1103,10 @@ def test_read_csv_wrong_path(self): @pytest.mark.parametrize("index_label", [None, False, "New index"]) @pytest.mark.parametrize("columns", [None, ["col1", "col3", "col5"]]) @pytest.mark.exclude_in_sanity + @pytest.mark.skipif( + condition=Engine.get() == "Unidist" and os.name == "nt", + reason="https://github.com/modin-project/modin/issues/6846", + ) def test_to_csv( self, tmp_path, @@ -1137,6 +1141,10 @@ def test_to_csv( columns=columns, ) + @pytest.mark.skipif( + condition=Engine.get() == "Unidist" and os.name == "nt", + reason="https://github.com/modin-project/modin/issues/6846", + ) def test_dataframe_to_csv(self, tmp_path): pandas_df = pandas.read_csv(pytest.csvs_names["test_read_csv_regular"]) modin_df = pd.DataFrame(pandas_df) @@ -1147,6 +1155,10 @@ def test_dataframe_to_csv(self, tmp_path): extension="csv", ) + @pytest.mark.skipif( + condition=Engine.get() == "Unidist" and os.name == "nt", + reason="https://github.com/modin-project/modin/issues/6846", + ) def test_series_to_csv(self, tmp_path): pandas_s = pandas.read_csv( pytest.csvs_names["test_read_csv_regular"], usecols=["col1"] From 5c15c48536486472e8d44eb24772f1c8b49a63f8 Mon Sep 17 00:00:00 2001 From: Anatoly Myachev Date: Wed, 10 Jan 2024 08:38:58 +0100 Subject: [PATCH 131/201] REFACTOR-#6845: Fix import issues found by CodeQL (#6837) Signed-off-by: Anatoly Myachev --- modin/conftest.py | 3 +-- .../hdk_on_native/dataframe/dataframe.py | 6 ++--- .../hdk_on_native/dataframe/utils.py | 22 ++++++++----------- .../hdk_on_native/df_algebra.py | 8 +++---- 4 files changed, 16 insertions(+), 23 deletions(-) diff --git a/modin/conftest.py b/modin/conftest.py index 482cbaf690f..90bef731a80 100644 --- a/modin/conftest.py +++ b/modin/conftest.py @@ -80,7 +80,6 @@ def _saving_make_api_url(token, _make_api_url=modin.utils._make_api_url): make_default_file, teardown_test_files, ) -from modin.utils import get_current_execution # noqa: E402 def pytest_addoption(parser): @@ -275,7 +274,7 @@ def pytest_runtest_call(item): if not isinstance(executions, list): executions = [executions] - current_execution = get_current_execution() + current_execution = modin.utils.get_current_execution() reason = marker.kwargs.pop("reason", "") item.add_marker( diff --git a/modin/experimental/core/execution/native/implementations/hdk_on_native/dataframe/dataframe.py b/modin/experimental/core/execution/native/implementations/hdk_on_native/dataframe/dataframe.py index 03aae68184a..445eccac6a8 100644 --- a/modin/experimental/core/execution/native/implementations/hdk_on_native/dataframe/dataframe.py +++ b/modin/experimental/core/execution/native/implementations/hdk_on_native/dataframe/dataframe.py @@ -1887,7 +1887,7 @@ def filter(self, key): ) if self is base: - exprs = OrderedDict() + exprs = dict() for col in filtered_base._table_cols: exprs[col] = filtered_base.ref(col) else: @@ -1898,8 +1898,8 @@ def filter(self, key): exprs = replace_frame_in_exprs(exprs, base, filtered_base) if base._index_cols is None: idx_name = mangle_index_names([None])[0] - exprs[idx_name] = filtered_base.ref(idx_name) - exprs.move_to_end(idx_name, last=False) + # `idx_name` should be first + exprs = {idx_name: filtered_base.ref(idx_name)} | exprs return self.__constructor__( columns=self.columns, diff --git a/modin/experimental/core/execution/native/implementations/hdk_on_native/dataframe/utils.py b/modin/experimental/core/execution/native/implementations/hdk_on_native/dataframe/utils.py index daef14a6b4b..2063cdae0d8 100644 --- a/modin/experimental/core/execution/native/implementations/hdk_on_native/dataframe/utils.py +++ b/modin/experimental/core/execution/native/implementations/hdk_on_native/dataframe/utils.py @@ -14,15 +14,12 @@ """Utilities for internal use by the ``HdkOnNativeDataframe``.""" import re -import typing -from collections import OrderedDict from functools import lru_cache -from typing import Any, List, Tuple, Union +from typing import Any, Dict, List, Tuple, Union import numpy as np import pandas import pyarrow as pa -from pandas import Timestamp from pandas.core.arrays.arrow.extension_types import ArrowIntervalType from pandas.core.dtypes.common import _get_dtype, is_string_dtype from pyarrow.types import is_dictionary @@ -40,7 +37,7 @@ class ColNameCodec: _IDX_NAME_PATTERN = re.compile(f"{IDX_COL_NAME}\\d+_(.*)") _RESERVED_NAMES = (MODIN_UNNAMED_SERIES_LABEL, ROWID_COL_NAME) - _COL_TYPES = Union[str, int, float, Timestamp, None] + _COL_TYPES = Union[str, int, float, pandas.Timestamp, None] _COL_NAME_TYPE = Union[_COL_TYPES, Tuple[_COL_TYPES, ...]] def _encode_tuple(values: Tuple[_COL_TYPES, ...]) -> str: # noqa: GL08 @@ -73,7 +70,7 @@ def _decode_tuple(encoded: str) -> Tuple[_COL_TYPES, ...]: # noqa: GL08 str: lambda v: "_E" if len(v) == 0 else "_S" + v[1:] if v[0] == "_" else v, int: lambda v: f"_I{v}", float: lambda v: f"_F{v}", - Timestamp: lambda v: f"_D{v.timestamp()}_{v.tz}", + pandas.Timestamp: lambda v: f"_D{v.timestamp()}_{v.tz}", } _DECODERS = { @@ -83,7 +80,7 @@ def _decode_tuple(encoded: str) -> Tuple[_COL_TYPES, ...]: # noqa: GL08 "S": lambda v: "_" + v[2:], "I": lambda v: int(v[2:]), "F": lambda v: float(v[2:]), - "D": lambda v: Timestamp.fromtimestamp( + "D": lambda v: pandas.Timestamp.fromtimestamp( float(v[2 : (idx := v.index("_", 2))]), tz=v[idx + 1 :] ), } @@ -225,7 +222,7 @@ def demangle_index_name(col: str) -> _COL_NAME_TYPE: return col @staticmethod - def concat_index_names(frames) -> typing.OrderedDict[str, Any]: + def concat_index_names(frames) -> Dict[str, Any]: """ Calculate the index names and dtypes. @@ -238,10 +235,10 @@ def concat_index_names(frames) -> typing.OrderedDict[str, Any]: Returns ------- - typing.OrderedDict[str, Any] + Dict[str, Any] """ first = frames[0] - names = OrderedDict() + names = {} if first._index_width() > 1: # When we're dealing with a MultiIndex case the resulting index # inherits the levels from the first frame in concatenation. @@ -413,7 +410,7 @@ def to_empty_pandas_df(df): return pandas.DataFrame(columns=df.columns, index=idx) new_dtypes = [] - exprs = OrderedDict() + exprs = {} merged = to_empty_pandas_df(left).merge( to_empty_pandas_df(right), how=how, @@ -586,8 +583,7 @@ class _CategoricalDtypeMapper: # noqa: GL08 @staticmethod def __from_arrow__(arr): # noqa: GL08 values = [] - # Using OrderedDict as an ordered set to preserve the categories order - categories = OrderedDict() + categories = {} chunks = arr.chunks if isinstance(arr, pa.ChunkedArray) else (arr,) for chunk in chunks: assert isinstance(chunk, pa.DictionaryArray) diff --git a/modin/experimental/core/execution/native/implementations/hdk_on_native/df_algebra.py b/modin/experimental/core/execution/native/implementations/hdk_on_native/df_algebra.py index b8455558a3d..8914a8c796b 100644 --- a/modin/experimental/core/execution/native/implementations/hdk_on_native/df_algebra.py +++ b/modin/experimental/core/execution/native/implementations/hdk_on_native/df_algebra.py @@ -14,8 +14,6 @@ """Module provides classes for lazy DataFrame algebra operations.""" import abc -import typing -from collections import OrderedDict from typing import TYPE_CHECKING, Dict, List, Union import numpy as np @@ -971,7 +969,7 @@ def execute_arrow(self, tables: Union[pa.Table, List[pa.Table]]) -> pa.Table: except pa.lib.ArrowInvalid: # Probably, some tables have different column types. # Trying to find a common type and cast the columns. - fields: typing.OrderedDict[str, pa.Field] = OrderedDict() + fields: Dict[str, pa.Field] = {} for table in tables: for col_name in table.column_names: field = table.field(col_name) @@ -1178,7 +1176,7 @@ def translate_exprs_to_base(exprs, base): new_frames.discard(base) frames = new_frames - res = OrderedDict() + res = {} for col in exprs.keys(): res[col] = new_exprs[col] return res @@ -1205,7 +1203,7 @@ def replace_frame_in_exprs(exprs, old_frame, new_frame): mapper = InputMapper() mapper.add_mapper(old_frame, FrameMapper(new_frame)) - res = OrderedDict() + res = {} for col in exprs.keys(): res[col] = exprs[col].translate_input(mapper) return res From 31f8bd0e699b7b56014ac37b67f282687c8d6b43 Mon Sep 17 00:00:00 2001 From: Anatoly Myachev Date: Wed, 10 Jan 2024 15:27:42 +0100 Subject: [PATCH 132/201] REFACTOR-#6812: Remove 'PyarrowOnRay' execution in favour of pyarrow-backed pandas dataframes (#6848) Signed-off-by: Anatoly Myachev --- .github/workflows/ci-required.yml | 2 - .github/workflows/ci.yml | 30 -- asv_bench/benchmarks/utils/compatibility.py | 2 +- docs/conf.py | 1 - docs/development/architecture.rst | 12 +- docs/development/index.rst | 1 - docs/development/using_pyarrow_on_ray.rst | 4 - .../flow/modin/core/storage_formats/index.rst | 5 +- .../ray/implementations/pyarrow_on_ray.rst | 27 -- .../core/storage_formats/index.rst | 2 - .../core/storage_formats/pyarrow/index.rst | 27 -- .../core/storage_formats/pyarrow/parsers.rst | 15 - .../pyarrow/query_compiler.rst | 21 -- modin/config/envvars.py | 2 +- .../dispatching/factories/factories.py | 15 - .../pyarrow_on_ray/__init__.py | 14 - .../pyarrow_on_ray/dataframe/__init__.py | 14 - .../pyarrow_on_ray/dataframe/dataframe.py | 74 ----- .../pyarrow_on_ray/io/__init__.py | 18 -- .../implementations/pyarrow_on_ray/io/io.py | 50 --- .../pyarrow_on_ray/partitioning/__init__.py | 14 - .../partitioning/axis_partition.py | 305 ------------------ .../pyarrow_on_ray/partitioning/partition.py | 83 ----- .../partitioning/partition_manager.py | 38 --- .../core/storage_formats/pyarrow/__init__.py | 19 -- .../core/storage_formats/pyarrow/parsers.py | 77 ----- .../storage_formats/pyarrow/query_compiler.py | 95 ------ modin/test/test_executions_api.py | 3 +- scripts/doc_checker.py | 1 - setup.cfg | 3 - 30 files changed, 8 insertions(+), 966 deletions(-) delete mode 100644 docs/development/using_pyarrow_on_ray.rst delete mode 100644 docs/flow/modin/experimental/core/execution/ray/implementations/pyarrow_on_ray.rst delete mode 100644 docs/flow/modin/experimental/core/storage_formats/pyarrow/index.rst delete mode 100644 docs/flow/modin/experimental/core/storage_formats/pyarrow/parsers.rst delete mode 100644 docs/flow/modin/experimental/core/storage_formats/pyarrow/query_compiler.rst delete mode 100644 modin/experimental/core/execution/ray/implementations/pyarrow_on_ray/__init__.py delete mode 100644 modin/experimental/core/execution/ray/implementations/pyarrow_on_ray/dataframe/__init__.py delete mode 100644 modin/experimental/core/execution/ray/implementations/pyarrow_on_ray/dataframe/dataframe.py delete mode 100644 modin/experimental/core/execution/ray/implementations/pyarrow_on_ray/io/__init__.py delete mode 100644 modin/experimental/core/execution/ray/implementations/pyarrow_on_ray/io/io.py delete mode 100644 modin/experimental/core/execution/ray/implementations/pyarrow_on_ray/partitioning/__init__.py delete mode 100644 modin/experimental/core/execution/ray/implementations/pyarrow_on_ray/partitioning/axis_partition.py delete mode 100644 modin/experimental/core/execution/ray/implementations/pyarrow_on_ray/partitioning/partition.py delete mode 100644 modin/experimental/core/execution/ray/implementations/pyarrow_on_ray/partitioning/partition_manager.py delete mode 100644 modin/experimental/core/storage_formats/pyarrow/__init__.py delete mode 100644 modin/experimental/core/storage_formats/pyarrow/parsers.py delete mode 100644 modin/experimental/core/storage_formats/pyarrow/query_compiler.py diff --git a/.github/workflows/ci-required.yml b/.github/workflows/ci-required.yml index 58b33bbfb22..755199e0ef8 100644 --- a/.github/workflows/ci-required.yml +++ b/.github/workflows/ci-required.yml @@ -66,7 +66,6 @@ jobs: asv_bench/benchmarks/__init__.py asv_bench/benchmarks/io/__init__.py \ asv_bench/benchmarks/scalability/__init__.py \ modin/core/io \ - modin/experimental/core/execution/ray/implementations/pyarrow_on_ray \ modin/pandas/series.py \ modin/core/execution/python \ modin/pandas/dataframe.py \ @@ -90,7 +89,6 @@ jobs: python scripts/doc_checker.py modin/experimental/pandas/io.py \ modin/experimental/pandas/__init__.py - run: python scripts/doc_checker.py modin/core/storage_formats/base - - run: python scripts/doc_checker.py modin/experimental/core/storage_formats/pyarrow - run: python scripts/doc_checker.py modin/core/storage_formats/pandas - run: | python scripts/doc_checker.py \ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0e0f1944f6c..c51d0b22a98 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -683,36 +683,6 @@ jobs: - run: python -m pytest modin/pandas/test/test_io.py --verbose - uses: ./.github/actions/upload-coverage - test-pyarrow: - needs: [lint-flake8, lint-black-isort] - runs-on: ubuntu-latest - defaults: - run: - shell: bash -l {0} - strategy: - matrix: - python-version: ["3.9"] - env: - MODIN_STORAGE_FORMAT: pyarrow - MODIN_EXPERIMENTAL: "True" - name: test (pyarrow, python ${{matrix.python-version}}) - services: - moto: - image: motoserver/moto - ports: - - 5000:5000 - env: - AWS_ACCESS_KEY_ID: foobar_key - AWS_SECRET_ACCESS_KEY: foobar_secret - steps: - - uses: actions/checkout@v3 - - uses: ./.github/actions/mamba-env - with: - environment-file: environment-dev.yml - python-version: ${{matrix.python-version}} - - run: sudo apt update && sudo apt install -y libhdf5-dev - - run: python -m pytest modin/pandas/test/test_io.py::TestCsv --verbose - test-spreadsheet: needs: [lint-flake8, lint-black-isort] runs-on: ubuntu-latest diff --git a/asv_bench/benchmarks/utils/compatibility.py b/asv_bench/benchmarks/utils/compatibility.py index 2aa27f3d8de..0fa4bf93e68 100644 --- a/asv_bench/benchmarks/utils/compatibility.py +++ b/asv_bench/benchmarks/utils/compatibility.py @@ -47,4 +47,4 @@ assert ASV_USE_IMPL in ("modin", "pandas") assert ASV_DATASET_SIZE in ("big", "small") assert ASV_USE_ENGINE in ("ray", "dask", "python", "native", "unidist") -assert ASV_USE_STORAGE_FORMAT in ("pandas", "hdk", "pyarrow") +assert ASV_USE_STORAGE_FORMAT in ("pandas", "hdk") diff --git a/docs/conf.py b/docs/conf.py index 7993d8cf254..882527b4d56 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -29,7 +29,6 @@ def noop_decorator(*args, **kwargs): for mod_name in ( "cudf", "cupy", - "pyarrow.gandiva", "pyhdk", "pyhdk.hdk", "xgboost", diff --git a/docs/development/architecture.rst b/docs/development/architecture.rst index 4cebbca7345..cdb311d928b 100644 --- a/docs/development/architecture.rst +++ b/docs/development/architecture.rst @@ -56,7 +56,7 @@ For the simplicity the other execution systems - Dask and MPI are omitted and on on a selected storage format and mapping or compiling the Dataframe Algebra DAG to and actual execution sequence. * Storage formats module is responsible for mapping the abstract operation to an actual executor call, e.g. pandas, - PyArrow, custom format. + HDK, custom format. * Orchestration subsystem is responsible for spawning and controlling the actual execution environment for the selected execution. It spawns the actual nodes, fires up the execution environment, e.g. Ray, monitors the state of executors and provides telemetry @@ -228,10 +228,6 @@ documentation page on :doc:`contributing `. - Uses HDK as an engine. - The storage format is `hdk` and the in-memory partition type is a pyarrow Table. When defaulting to pandas, the pandas DataFrame is used. - For more information on the execution path, see the :doc:`HDK on Native ` page. -- :doc:`Pyarrow on Ray ` (experimental) - - Uses the Ray_ execution framework. - - The storage format is `pyarrow` and the in-memory partition type is a pyarrow Table. - - For more information on the execution path, see the :doc:`Pyarrow on Ray ` page. - cuDF on Ray (experimental) - Uses the Ray_ execution framework. - The storage format is `cudf` and the in-memory partition type is a cuDF DataFrame. @@ -252,7 +248,7 @@ following figure illustrates this concept. :align: center Currently, the main in-memory format of each partition is a `pandas DataFrame`_ (:doc:`pandas storage format `). -:doc:`HDK `, :doc:`PyArrow ` +:doc:`HDK ` and cuDF are also supported as experimental in-memory formats in Modin. @@ -333,8 +329,7 @@ details. The documentation covers most modules, with more docs being added every │ │ │ │ └───implementations │ │ │ │ └─── :doc:`hdk_on_native ` │ │ │ ├─── :doc:`storage_formats ` - | │ │ | ├─── :doc:`hdk ` - │ │ │ | └─── :doc:`pyarrow ` + | │ │ | └───:doc:`hdk ` | | | └─── :doc:`io ` │ │ ├─── :doc:`pandas ` │ │ ├─── :doc:`sklearn ` @@ -350,7 +345,6 @@ details. The documentation covers most modules, with more docs being added every └───stress_tests .. _pandas Dataframe: https://pandas.pydata.org/pandas-docs/stable/reference/api/pandas.DataFrame.html -.. _Arrow tables: https://arrow.apache.org/docs/python/generated/pyarrow.Table.html .. _Ray: https://github.com/ray-project/ray .. _Unidist: https://github.com/modin-project/unidist .. _MPI: https://www.mpi-forum.org/ diff --git a/docs/development/index.rst b/docs/development/index.rst index b1e5c3f1212..5e257501857 100644 --- a/docs/development/index.rst +++ b/docs/development/index.rst @@ -12,7 +12,6 @@ Development using_pandas_on_python using_pandas_on_mpi using_hdk - using_pyarrow_on_ray .. meta:: :description lang=en: diff --git a/docs/development/using_pyarrow_on_ray.rst b/docs/development/using_pyarrow_on_ray.rst deleted file mode 100644 index c21da7ec9ae..00000000000 --- a/docs/development/using_pyarrow_on_ray.rst +++ /dev/null @@ -1,4 +0,0 @@ -PyArrow on Ray -============== - -Coming Soon! diff --git a/docs/flow/modin/core/storage_formats/index.rst b/docs/flow/modin/core/storage_formats/index.rst index 833cecc84b0..1d98af0d8dc 100644 --- a/docs/flow/modin/core/storage_formats/index.rst +++ b/docs/flow/modin/core/storage_formats/index.rst @@ -8,9 +8,8 @@ of objects that are stored in the partitions of the selected Core Modin Datafram The base storage format in Modin is pandas. In that format, Modin Dataframe operates with partitions that hold ``pandas.DataFrame`` objects. Pandas is the most natural storage format since high-level DataFrame objects mirror its API, however, Modin's storage formats are not -limited to the objects that conform to pandas API. There are formats that are able to store -``pyarrow.Table`` (:doc:`pyarrow storage format `) or even instances of -SQL-like databases (:doc:`HDK storage format `) +limited to the objects that conform to pandas API. There is format that are able to store +even instances of SQL-like databases (:doc:`HDK storage format `) inside Modin Dataframe's partitions. The storage format + execution engine (Ray, Dask, etc.) form the execution backend. diff --git a/docs/flow/modin/experimental/core/execution/ray/implementations/pyarrow_on_ray.rst b/docs/flow/modin/experimental/core/execution/ray/implementations/pyarrow_on_ray.rst deleted file mode 100644 index de6cb6048ae..00000000000 --- a/docs/flow/modin/experimental/core/execution/ray/implementations/pyarrow_on_ray.rst +++ /dev/null @@ -1,27 +0,0 @@ -:orphan: - -PyArrow-on-Ray Module Description -""""""""""""""""""""""""""""""""" - -High-Level Module Overview -'''''''''''''''''''''''''' - -This module houses experimental functionality with PyArrow storage format and Ray -engine. The biggest difference from core engines is that internally each partition -is represented as ``pyarrow.Table`` put in the ``Ray`` Plasma store. - -Why to Use PyArrow Tables -''''''''''''''''''''''''' - -As it was `mentioned `_ -by the pandas creator, pandas internal architecture is not optimal and sometimes -needs up to ten times more memory than the original dataset size -(note, that pandas rule of thumb: `have 5 to 10 times as much RAM as the size of your -dataset`). In order to fix this issue (or at least to reduce needed memory amount and -needed data copying), ``PyArrow-on-Ray`` module was added. Due to the optimized architecture -of PyArrow Tables, `no additional copies are needed -`_ in some -corner cases, which can significantly improve Modin performance. The downside of this approach -is that PyArrow and pandas do not support the same APIs and some functions/parameters may have -different signatures or output different results, so for now the ``PyArrow-on-Ray`` engine is -under development and marked as experimental. diff --git a/docs/flow/modin/experimental/core/storage_formats/index.rst b/docs/flow/modin/experimental/core/storage_formats/index.rst index 176e499436f..8a5213c1ea8 100644 --- a/docs/flow/modin/experimental/core/storage_formats/index.rst +++ b/docs/flow/modin/experimental/core/storage_formats/index.rst @@ -7,11 +7,9 @@ Experimental storage formats and provides a limited set of functionality: * :doc:`hdk ` -* :doc:`pyarrow ` .. toctree:: :hidden: hdk/index - pyarrow/index diff --git a/docs/flow/modin/experimental/core/storage_formats/pyarrow/index.rst b/docs/flow/modin/experimental/core/storage_formats/pyarrow/index.rst deleted file mode 100644 index ba4ff32ae61..00000000000 --- a/docs/flow/modin/experimental/core/storage_formats/pyarrow/index.rst +++ /dev/null @@ -1,27 +0,0 @@ -PyArrow storage format -"""""""""""""""""""""" - -.. toctree:: - :hidden: - - query_compiler - parsers - -In general, PyArrow storage formats follow the flow of the pandas ones: query compiler contains an instance of Modin Dataframe, -which is internally split into partitions. The main difference is that partitions contain PyArrow tables, -instead of ``pandas.DataFrame``-s like with :doc:`pandas storage format `. To learn more about this approach please -visit :doc:`PyArrowOnRay execution ` section. - - -High-Level Module Overview -'''''''''''''''''''''''''' - -This module houses submodules which are responsible for communication between -the query compiler level and execution implementation level for PyArrow storage format: - -- :doc:`Query compiler ` is responsible for compiling efficient queries for :doc:`PyarrowOnRayDataframe `. -- :doc:`Parsers ` are responsible for parsing data on workers during IO operations. - -.. note:: - Currently the only one available PyArrow storage format factory is ``PyarrowOnRay`` which works - in :doc:`experimental mode ` only. diff --git a/docs/flow/modin/experimental/core/storage_formats/pyarrow/parsers.rst b/docs/flow/modin/experimental/core/storage_formats/pyarrow/parsers.rst deleted file mode 100644 index 62d4af8c74b..00000000000 --- a/docs/flow/modin/experimental/core/storage_formats/pyarrow/parsers.rst +++ /dev/null @@ -1,15 +0,0 @@ -Experimental PyArrow Parsers Module Description -""""""""""""""""""""""""""""""""""""""""""""""" - -This module houses parser classes that are responsible for data parsing on the workers for the PyArrow storage format. -Parsers for PyArrow storage formats follow an interface of :doc:`pandas format parsers `: -parser class of every file format implements ``parse`` method, which parses the specified part -of the file and builds PyArrow tables from the parsed data, based on the specified chunk size and number of splits. -The resulted PyArrow tables will be used as a partitions payload in the :py:class:`~modin.experimental.core.execution.ray.implementations.pyarrow_on_ray.dataframe.dataframe.PyarrowOnRayDataframe`. - -Public API -'''''''''' - -.. automodule:: modin.experimental.core.storage_formats.pyarrow.parsers - :members: - diff --git a/docs/flow/modin/experimental/core/storage_formats/pyarrow/query_compiler.rst b/docs/flow/modin/experimental/core/storage_formats/pyarrow/query_compiler.rst deleted file mode 100644 index e3c7b72ca36..00000000000 --- a/docs/flow/modin/experimental/core/storage_formats/pyarrow/query_compiler.rst +++ /dev/null @@ -1,21 +0,0 @@ -PyarrowQueryCompiler -"""""""""""""""""""" - -:py:class:`~modin.experimental.core.storage_formats.pyarrow.query_compiler.PyarrowQueryCompiler` is responsible for compiling efficient -Dataframe algebra queries for the :doc:`PyarrowOnRayDataframe `, -the frames which are backed by ``pyarrow.Table`` objects. - -Each :py:class:`~modin.experimental.core.storage_formats.pyarrow.query_compiler.PyarrowQueryCompiler` contains an instance of -:py:class:`~modin.experimental.core.execution.ray.implementations.pyarrow_on_ray.dataframe.dataframe.PyarrowOnRayDataframe` which it queries to get the result. - -Public API -'''''''''' - -:py:class:`~modin.experimental.core.storage_formats.pyarrow.query_compiler.PyarrowQueryCompiler` implements common query compilers API -defined by the :py:class:`~modin.core.storage_formats.base.query_compiler.BaseQueryCompiler`. Most functionalities -are inherited from :py:class:`~modin.core.storage_formats.pandas.query_compiler.PandasQueryCompiler`, in the following -section only overridden methods are presented. - -.. autoclass:: modin.experimental.core.storage_formats.pyarrow.query_compiler.PyarrowQueryCompiler - :members: - :show-inheritance: diff --git a/modin/config/envvars.py b/modin/config/envvars.py index 4dabbc20f8a..5ab97abda9b 100644 --- a/modin/config/envvars.py +++ b/modin/config/envvars.py @@ -266,7 +266,7 @@ class StorageFormat(EnvironmentVariable, type=str): varname = "MODIN_STORAGE_FORMAT" default = "Pandas" - choices = ("Pandas", "Hdk", "Pyarrow", "Cudf") + choices = ("Pandas", "Hdk", "Cudf") class IsExperimental(EnvironmentVariable, type=bool): diff --git a/modin/core/execution/dispatching/factories/factories.py b/modin/core/execution/dispatching/factories/factories.py index d4e2567099f..f1bc05539c2 100644 --- a/modin/core/execution/dispatching/factories/factories.py +++ b/modin/core/execution/dispatching/factories/factories.py @@ -570,21 +570,6 @@ def prepare(cls): # that have little coverage of implemented functionality or are not stable enough. -@doc(_doc_factory_class, execution_name="experimental PyarrowOnRay") -class ExperimentalPyarrowOnRayFactory(BaseFactory): # pragma: no cover - @classmethod - @doc(_doc_factory_prepare_method, io_module_name="experimental ``PyarrowOnRayIO``") - def prepare(cls): - from modin.experimental.core.execution.ray.implementations.pyarrow_on_ray.io import ( - PyarrowOnRayIO, - ) - - if not IsExperimental.get(): - raise ValueError("'PyarrowOnRay' only works in experimental mode.") - - cls.io_cls = PyarrowOnRayIO - - @doc(_doc_factory_class, execution_name="experimental HdkOnNative") class ExperimentalHdkOnNativeFactory(BaseFactory): @classmethod diff --git a/modin/experimental/core/execution/ray/implementations/pyarrow_on_ray/__init__.py b/modin/experimental/core/execution/ray/implementations/pyarrow_on_ray/__init__.py deleted file mode 100644 index 16805cc615c..00000000000 --- a/modin/experimental/core/execution/ray/implementations/pyarrow_on_ray/__init__.py +++ /dev/null @@ -1,14 +0,0 @@ -# Licensed to Modin Development Team under one or more contributor license agreements. -# See the NOTICE file distributed with this work for additional information regarding -# copyright ownership. The Modin Development Team licenses this file to you under the -# Apache License, Version 2.0 (the "License"); you may not use this file except in -# compliance with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software distributed under -# the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF -# ANY KIND, either express or implied. See the License for the specific language -# governing permissions and limitations under the License. - -"""Experimental functionality related to Ray execution engine and optimized for PyArrow storage format.""" diff --git a/modin/experimental/core/execution/ray/implementations/pyarrow_on_ray/dataframe/__init__.py b/modin/experimental/core/execution/ray/implementations/pyarrow_on_ray/dataframe/__init__.py deleted file mode 100644 index ec39f99a387..00000000000 --- a/modin/experimental/core/execution/ray/implementations/pyarrow_on_ray/dataframe/__init__.py +++ /dev/null @@ -1,14 +0,0 @@ -# Licensed to Modin Development Team under one or more contributor license agreements. -# See the NOTICE file distributed with this work for additional information regarding -# copyright ownership. The Modin Development Team licenses this file to you under the -# Apache License, Version 2.0 (the "License"); you may not use this file except in -# compliance with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software distributed under -# the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF -# ANY KIND, either express or implied. See the License for the specific language -# governing permissions and limitations under the License. - -"""Experimental Base Modin Dataframe class optimized for PyArrow on Ray execution.""" diff --git a/modin/experimental/core/execution/ray/implementations/pyarrow_on_ray/dataframe/dataframe.py b/modin/experimental/core/execution/ray/implementations/pyarrow_on_ray/dataframe/dataframe.py deleted file mode 100644 index 5c139e477ad..00000000000 --- a/modin/experimental/core/execution/ray/implementations/pyarrow_on_ray/dataframe/dataframe.py +++ /dev/null @@ -1,74 +0,0 @@ -# Licensed to Modin Development Team under one or more contributor license agreements. -# See the NOTICE file distributed with this work for additional information regarding -# copyright ownership. The Modin Development Team licenses this file to you under the -# Apache License, Version 2.0 (the "License"); you may not use this file except in -# compliance with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software distributed under -# the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF -# ANY KIND, either express or implied. See the License for the specific language -# governing permissions and limitations under the License. - -""" -Module contains class ``PyarrowOnRayDataframe``. - -``PyarrowOnRayDataframe`` is a dataframe class with PyArrow storage format and Ray engine. -""" - -from modin.core.dataframe.pandas.dataframe.dataframe import PandasDataframe - -from ..partitioning.partition_manager import PyarrowOnRayDataframePartitionManager - - -class PyarrowOnRayDataframe(PandasDataframe): - """ - Class for dataframes with PyArrow storage format and Ray engine. - - ``PyarrowOnRayDataframe`` implements interfaces specific for PyArrow and Ray, - other functionality is inherited from the ``PandasDataframe`` class. - - Parameters - ---------- - partitions : np.ndarray - A 2D NumPy array of partitions. - index : sequence - The index for the dataframe. Converted to a ``pandas.Index``. - columns : sequence - The columns object for the dataframe. Converted to a ``pandas.Index``. - row_lengths : list, optional - The length of each partition in the rows. The "height" of - each of the block partitions. Is computed if not provided. - column_widths : list, optional - The width of each partition in the columns. The "width" of - each of the block partitions. Is computed if not provided. - dtypes : pandas.Series, optional - The data types for the dataframe columns. - """ - - _partition_mgr_cls = PyarrowOnRayDataframePartitionManager - - def synchronize_labels(self, axis=None): - """ - Synchronize labels by applying the index object (Index or Columns) to the partitions lazily. - - Parameters - ---------- - axis : {0, 1}, optional - Parameter is deprecated and affects nothing. - """ - self._filter_empties() - - def to_pandas(self): - """ - Convert frame object to a ``pandas.DataFrame``. - - Returns - ------- - pandas.DataFrame - """ - df = super(PyarrowOnRayDataframe, self).to_pandas() - df.index = self.index - df.columns = self.columns - return df diff --git a/modin/experimental/core/execution/ray/implementations/pyarrow_on_ray/io/__init__.py b/modin/experimental/core/execution/ray/implementations/pyarrow_on_ray/io/__init__.py deleted file mode 100644 index da3f745ec7a..00000000000 --- a/modin/experimental/core/execution/ray/implementations/pyarrow_on_ray/io/__init__.py +++ /dev/null @@ -1,18 +0,0 @@ -# Licensed to Modin Development Team under one or more contributor license agreements. -# See the NOTICE file distributed with this work for additional information regarding -# copyright ownership. The Modin Development Team licenses this file to you under the -# Apache License, Version 2.0 (the "License"); you may not use this file except in -# compliance with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software distributed under -# the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF -# ANY KIND, either express or implied. See the License for the specific language -# governing permissions and limitations under the License. - -"""Experimental Base IO classes optimized for PyArrow on Ray execution.""" - -from .io import PyarrowOnRayIO - -__all__ = ["PyarrowOnRayIO"] diff --git a/modin/experimental/core/execution/ray/implementations/pyarrow_on_ray/io/io.py b/modin/experimental/core/execution/ray/implementations/pyarrow_on_ray/io/io.py deleted file mode 100644 index 8dd2df0756c..00000000000 --- a/modin/experimental/core/execution/ray/implementations/pyarrow_on_ray/io/io.py +++ /dev/null @@ -1,50 +0,0 @@ -# Licensed to Modin Development Team under one or more contributor license agreements. -# See the NOTICE file distributed with this work for additional information regarding -# copyright ownership. The Modin Development Team licenses this file to you under the -# Apache License, Version 2.0 (the "License"); you may not use this file except in -# compliance with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software distributed under -# the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF -# ANY KIND, either express or implied. See the License for the specific language -# governing permissions and limitations under the License. - -"""Module for housing IO classes with PyArrow storage format and Ray engine.""" - -from modin.core.execution.ray.common import RayWrapper -from modin.core.execution.ray.generic.io import RayIO -from modin.core.io import CSVDispatcher -from modin.experimental.core.execution.ray.implementations.pyarrow_on_ray.dataframe.dataframe import ( - PyarrowOnRayDataframe, -) -from modin.experimental.core.execution.ray.implementations.pyarrow_on_ray.partitioning.partition import ( - PyarrowOnRayDataframePartition, -) -from modin.experimental.core.storage_formats.pyarrow import ( - PyarrowCSVParser, - PyarrowQueryCompiler, -) - - -class PyarrowOnRayCSVDispatcher(RayWrapper, PyarrowCSVParser, CSVDispatcher): - """Class handles utils for reading `.csv` files with PyArrow storage format and Ray engine.""" - - frame_cls = PyarrowOnRayDataframe - frame_partition_cls = PyarrowOnRayDataframePartition - query_compiler_cls = PyarrowQueryCompiler - - -class PyarrowOnRayIO(RayIO): - """Class for storing IO functions operated on PyArrow storage format and Ray engine.""" - - frame_cls = PyarrowOnRayDataframe - frame_partition_cls = PyarrowOnRayDataframePartition - query_compiler_cls = PyarrowQueryCompiler - csv_reader = PyarrowOnRayCSVDispatcher - - read_parquet_remote_task = None - read_hdf_remote_task = None - read_feather_remote_task = None - read_sql_remote_task = None diff --git a/modin/experimental/core/execution/ray/implementations/pyarrow_on_ray/partitioning/__init__.py b/modin/experimental/core/execution/ray/implementations/pyarrow_on_ray/partitioning/__init__.py deleted file mode 100644 index 16805cc615c..00000000000 --- a/modin/experimental/core/execution/ray/implementations/pyarrow_on_ray/partitioning/__init__.py +++ /dev/null @@ -1,14 +0,0 @@ -# Licensed to Modin Development Team under one or more contributor license agreements. -# See the NOTICE file distributed with this work for additional information regarding -# copyright ownership. The Modin Development Team licenses this file to you under the -# Apache License, Version 2.0 (the "License"); you may not use this file except in -# compliance with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software distributed under -# the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF -# ANY KIND, either express or implied. See the License for the specific language -# governing permissions and limitations under the License. - -"""Experimental functionality related to Ray execution engine and optimized for PyArrow storage format.""" diff --git a/modin/experimental/core/execution/ray/implementations/pyarrow_on_ray/partitioning/axis_partition.py b/modin/experimental/core/execution/ray/implementations/pyarrow_on_ray/partitioning/axis_partition.py deleted file mode 100644 index 2c8ba0304b8..00000000000 --- a/modin/experimental/core/execution/ray/implementations/pyarrow_on_ray/partitioning/axis_partition.py +++ /dev/null @@ -1,305 +0,0 @@ -# Licensed to Modin Development Team under one or more contributor license agreements. -# See the NOTICE file distributed with this work for additional information regarding -# copyright ownership. The Modin Development Team licenses this file to you under the -# Apache License, Version 2.0 (the "License"); you may not use this file except in -# compliance with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software distributed under -# the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF -# ANY KIND, either express or implied. See the License for the specific language -# governing permissions and limitations under the License. - -"""The module defines interface for an axis partition with PyArrow storage format and Ray engine.""" - -import pyarrow -import ray - -from modin.core.dataframe.pandas.partitioning.axis_partition import ( - BaseDataframeAxisPartition, -) - -from .partition import PyarrowOnRayDataframePartition - - -class PyarrowOnRayDataframeAxisPartition(BaseDataframeAxisPartition): - """ - Class defines axis partition interface with PyArrow storage format and Ray engine. - - Inherits functionality from ``BaseDataframeAxisPartition`` class. - - Parameters - ---------- - list_of_blocks : list - List with partition objects to create common axis partition for. - """ - - def __init__(self, list_of_blocks): - assert all( - [len(partition.list_of_blocks) == 1 for partition in list_of_blocks] - ), "Implementation assumes that each partition contains a signle block." - # Unwrap from PandasDataframePartition object for ease of use - self.list_of_blocks = [obj.list_of_blocks[0] for obj in list_of_blocks] - - def apply(self, func, *args, num_splits=None, other_axis_partition=None, **kwargs): - """ - Apply func to the object in the Plasma store. - - Parameters - ---------- - func : callable or ray.ObjectRef - The function to apply. - *args : iterable - Positional arguments to pass with `func`. - num_splits : int, optional - The number of times to split the resulting object. - other_axis_partition : PyarrowOnRayDataframeAxisPartition, optional - Another ``PyarrowOnRayDataframeAxisPartition`` object to apply to - `func` with this one. - **kwargs : dict - Additional keyward arguments to pass with `func`. - - Returns - ------- - list - List with ``PyarrowOnRayDataframePartition`` objects. - - Notes - ----- - See notes in Parent class about this method. - """ - if num_splits is None: - num_splits = len(self.list_of_blocks) - - if other_axis_partition is not None: - return [ - PyarrowOnRayDataframePartition(obj) - for obj in deploy_ray_func_between_two_axis_partitions.options( - num_returns=num_splits - ).remote( - self.axis, - func, - args, - kwargs, - num_splits, - len(self.list_of_blocks), - *(self.list_of_blocks + other_axis_partition.list_of_blocks), - ) - ] - - return [ - PyarrowOnRayDataframePartition(obj) - for obj in deploy_ray_axis_func.options(num_returns=num_splits).remote( - self.axis, - func, - args, - kwargs, - num_splits, - *self.list_of_blocks, - ) - ] - - -class PyarrowOnRayDataframeColumnPartition(PyarrowOnRayDataframeAxisPartition): - """ - The column partition implementation for PyArrow storage format and Ray engine. - - All of the implementation for this class is in the ``PyarrowOnRayDataframeAxisPartition`` - parent class, and this class defines the axis to perform the computation over. - - Parameters - ---------- - list_of_blocks : list - List with partition objects to create common axis partition. - """ - - axis = 0 - - -class PyarrowOnRayDataframeRowPartition(PyarrowOnRayDataframeAxisPartition): - """ - The row partition implementation for PyArrow storage format and Ray engine. - - All of the implementation for this class is in the ``PyarrowOnRayDataframeAxisPartition`` - parent class, and this class defines the axis to perform the computation over. - - Parameters - ---------- - list_of_blocks : list - List with partition objects to create common axis partition. - """ - - axis = 1 - - -def concat_arrow_table_partitions(axis, partitions): - """ - Concatenate given `partitions` in a single table. - - Parameters - ---------- - axis : {0, 1} - The axis to concatenate over. - partitions : array-like - Array with partitions for concatenating. - - Returns - ------- - pyarrow.Table - ``pyarrow.Table`` constructed from the passed partitions. - """ - if axis == 0: - table = pyarrow.Table.from_batches( - [part.to_batches(part.num_rows)[0] for part in partitions] - ) - else: - table = partitions[0].drop([partitions[0].columns[-1].name]) - for obj in partitions[1:]: - i = 0 - for col in obj.itercolumns(): - if i < obj.num_columns - 1: - table = table.append_column(col) - i += 1 - table = table.append_column(partitions[0].columns[-1]) - return table - - -def split_arrow_table_result(axis, result, num_partitions, num_splits, metadata): - """ - Split ``pyarrow.Table`` according to the passed parameters. - - Parameters - ---------- - axis : {0, 1} - The axis to perform the function along. - result : pyarrow.Table - Resulting table to split. - num_partitions : int - Number of partitions that `result` was constructed from. - num_splits : int - The number of splits to return. - metadata : dict - Dictionary with ``pyarrow.Table`` metadata. - - Returns - ------- - list - List of PyArrow Tables. - """ - chunksize = ( - num_splits // num_partitions - if num_splits % num_partitions == 0 - else num_splits // num_partitions + 1 - ) - if axis == 0: - return [ - pyarrow.Table.from_batches([part]) for part in result.to_batches(chunksize) - ] - else: - return [ - result.drop( - [ - result.columns[i].name - for i in range(result.num_columns) - if i >= n * chunksize or i < (n - 1) * chunksize - ] - ) - .append_column(result.columns[-1]) - .replace_schema_metadata(metadata=metadata) - for n in range(1, num_splits) - ] + [ - result.drop( - [ - result.columns[i].name - for i in range(result.num_columns) - if i < (num_splits - 1) * chunksize - ] - ).replace_schema_metadata(metadata=metadata) - ] - - -@ray.remote -def deploy_ray_axis_func(axis, func, f_args, f_kwargs, num_splits, *partitions): - """ - Deploy a function along a full axis in Ray. - - Parameters - ---------- - axis : {0, 1} - The axis to perform the function along. - func : callable - The function to perform. - f_args : list or tuple - Positional arguments to pass to ``func``. - f_kwargs : dict - Keyword arguments to pass to ``func``. - num_splits : int - The number of splits to return. - *partitions : array-like - All partitions that make up the full axis (row or column). - - Returns - ------- - list - List of PyArrow Tables. - """ - table = concat_arrow_table_partitions(axis, partitions) - try: - result = func(table, *f_args, **f_kwargs) - except Exception: - result = pyarrow.Table.from_pandas(func(table.to_pandas(), *f_args, **f_kwargs)) - return split_arrow_table_result( - axis, result, len(partitions), num_splits, table.schema.metadata - ) - - -@ray.remote -def deploy_ray_func_between_two_axis_partitions( - axis, - func, - f_args, - f_kwargs, - num_splits, - len_of_left, - *partitions, -): - """ - Deploy a function along a full axis between two data sets in Ray. - - Parameters - ---------- - axis : {0, 1} - The axis to perform the function along. - func : callable - The function to perform. - f_args : list or tuple - Positional arguments to pass to ``func``. - f_kwargs : dict - Keyword arguments to pass to ``func``. - num_splits : int - The number of splits to return. - len_of_left : int - The number of values in `partitions` that belong to the left data set. - *partitions : array-like - All partitions that make up the full axis (row or column) - for both data sets. - - Returns - ------- - list - List of PyArrow Tables. - """ - lt_table = concat_arrow_table_partitions(axis, partitions[:len_of_left]) - rt_table = concat_arrow_table_partitions(axis, partitions[len_of_left:]) - try: - result = func(lt_table, rt_table, *f_args, **f_kwargs) - except Exception: - lt_frame = lt_table.from_pandas() - rt_frame = rt_table.from_pandas() - result = pyarrow.Table.from_pandas( - func(lt_frame, rt_frame, *f_args, **f_kwargs) - ) - return split_arrow_table_result( - axis, result, len(result.num_rows), num_splits, result.schema.metadata - ) diff --git a/modin/experimental/core/execution/ray/implementations/pyarrow_on_ray/partitioning/partition.py b/modin/experimental/core/execution/ray/implementations/pyarrow_on_ray/partitioning/partition.py deleted file mode 100644 index 3bd094498e6..00000000000 --- a/modin/experimental/core/execution/ray/implementations/pyarrow_on_ray/partitioning/partition.py +++ /dev/null @@ -1,83 +0,0 @@ -# Licensed to Modin Development Team under one or more contributor license agreements. -# See the NOTICE file distributed with this work for additional information regarding -# copyright ownership. The Modin Development Team licenses this file to you under the -# Apache License, Version 2.0 (the "License"); you may not use this file except in -# compliance with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software distributed under -# the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF -# ANY KIND, either express or implied. See the License for the specific language -# governing permissions and limitations under the License. - -"""The module defines interface for a partition with PyArrow storage format and Ray engine.""" - -import pyarrow - -from modin.core.execution.ray.common import RayWrapper -from modin.core.execution.ray.implementations.pandas_on_ray.partitioning import ( - PandasOnRayDataframePartition, -) - - -class PyarrowOnRayDataframePartition(PandasOnRayDataframePartition): - """ - Class provides partition interface specific for PyArrow storage format and Ray engine. - - Inherits functionality from the ``PandasOnRayDataframePartition`` class. - - Parameters - ---------- - data : ray.ObjectRef - A reference to ``pyarrow.Table`` that needs to be wrapped with this class. - length : ray.ObjectRef or int, optional - Length or reference to it of wrapped ``pyarrow.Table``. - width : ray.ObjectRef or int, optional - Width or reference to it of wrapped ``pyarrow.Table``. - ip : ray.ObjectRef or str, optional - Node IP address or reference to it that holds wrapped ``pyarrow.Table``. - call_queue : list, optional - Call queue that needs to be executed on wrapped ``pyarrow.Table``. - """ - - @classmethod - def put(cls, obj): - """ - Put an object in the Plasma store and wrap it in this object. - - Parameters - ---------- - obj : object - The object to be put. - - Returns - ------- - PyarrowOnRayDataframePartition - A ``PyarrowOnRayDataframePartition`` object. - """ - return PyarrowOnRayDataframePartition( - RayWrapper.put(pyarrow.Table.from_pandas(obj)) - ) - - @classmethod - def _length_extraction_fn(cls): - """ - Return the callable that extracts the number of rows from the given ``pyarrow.Table``. - - Returns - ------- - callable - """ - return lambda table: table.num_rows - - @classmethod - def _width_extraction_fn(cls): - """ - Return the callable that extracts the number of columns from the given ``pyarrow.Table``. - - Returns - ------- - callable - """ - return lambda table: table.num_columns - (1 if "index" in table.columns else 0) diff --git a/modin/experimental/core/execution/ray/implementations/pyarrow_on_ray/partitioning/partition_manager.py b/modin/experimental/core/execution/ray/implementations/pyarrow_on_ray/partitioning/partition_manager.py deleted file mode 100644 index 4c45e9096fd..00000000000 --- a/modin/experimental/core/execution/ray/implementations/pyarrow_on_ray/partitioning/partition_manager.py +++ /dev/null @@ -1,38 +0,0 @@ -# Licensed to Modin Development Team under one or more contributor license agreements. -# See the NOTICE file distributed with this work for additional information regarding -# copyright ownership. The Modin Development Team licenses this file to you under the -# Apache License, Version 2.0 (the "License"); you may not use this file except in -# compliance with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software distributed under -# the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF -# ANY KIND, either express or implied. See the License for the specific language -# governing permissions and limitations under the License. - -"""Module houses class for tracking partitions with PyArrow storage format and Ray engine.""" - -from modin.core.execution.ray.generic.partitioning import ( - GenericRayDataframePartitionManager, -) - -from .axis_partition import ( - PyarrowOnRayDataframeColumnPartition, - PyarrowOnRayDataframeRowPartition, -) -from .partition import PyarrowOnRayDataframePartition - - -class PyarrowOnRayDataframePartitionManager(GenericRayDataframePartitionManager): - """ - Class for tracking partitions with PyArrow storage format and Ray engine. - - Inherits all functionality from ``GenericRayDataframePartitionManager`` and ``PandasDataframePartitionManager`` base - classes. - """ - - # This object uses PyarrowOnRayDataframePartition objects as the underlying store. - _partition_class = PyarrowOnRayDataframePartition - _column_partitions_class = PyarrowOnRayDataframeColumnPartition - _row_partition_class = PyarrowOnRayDataframeRowPartition diff --git a/modin/experimental/core/storage_formats/pyarrow/__init__.py b/modin/experimental/core/storage_formats/pyarrow/__init__.py deleted file mode 100644 index 12bae94625f..00000000000 --- a/modin/experimental/core/storage_formats/pyarrow/__init__.py +++ /dev/null @@ -1,19 +0,0 @@ -# Licensed to Modin Development Team under one or more contributor license agreements. -# See the NOTICE file distributed with this work for additional information regarding -# copyright ownership. The Modin Development Team licenses this file to you under the -# Apache License, Version 2.0 (the "License"); you may not use this file except in -# compliance with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software distributed under -# the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF -# ANY KIND, either express or implied. See the License for the specific language -# governing permissions and limitations under the License. - -"""Experimental Modin functionality specific to PyArrow storage format.""" - -from .parsers import PyarrowCSVParser -from .query_compiler import PyarrowQueryCompiler - -__all__ = ["PyarrowQueryCompiler", "PyarrowCSVParser"] diff --git a/modin/experimental/core/storage_formats/pyarrow/parsers.py b/modin/experimental/core/storage_formats/pyarrow/parsers.py deleted file mode 100644 index ecefd2f63a5..00000000000 --- a/modin/experimental/core/storage_formats/pyarrow/parsers.py +++ /dev/null @@ -1,77 +0,0 @@ -# Licensed to Modin Development Team under one or more contributor license agreements. -# See the NOTICE file distributed with this work for additional information regarding -# copyright ownership. The Modin Development Team licenses this file to you under the -# Apache License, Version 2.0 (the "License"); you may not use this file except in -# compliance with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software distributed under -# the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF -# ANY KIND, either express or implied. See the License for the specific language -# governing permissions and limitations under the License. - -"""Module houses Modin parser classes, that are used for data parsing on the workers.""" - -from io import BytesIO - -import pandas - -from modin.core.storage_formats.pandas.utils import compute_chunksize - - -class PyarrowCSVParser: - """Class for handling CSV files on the workers using PyArrow storage format.""" - - def parse(self, fname, num_splits, start, end, header, **kwargs): - """ - Parse CSV file into PyArrow tables. - - Parameters - ---------- - fname : str - Name of the CSV file to parse. - num_splits : int - Number of partitions to split the resulted PyArrow table into. - start : int - Position in the specified file to start parsing from. - end : int - Position in the specified file to end parsing at. - header : str - Header line that will be interpret as the first line of the parsed CSV file. - **kwargs : kwargs - Serves the compatibility purpose. Does not affect the result. - - Returns - ------- - list - List with split parse results and it's metadata: - - - First `num_split` elements are PyArrow tables, representing the corresponding chunk. - - Next element is the number of rows in the parsed table. - - Last element is the pandas Series, containing the data-types for each column of the parsed table. - """ - import pyarrow as pa - import pyarrow.csv as csv - - with open(fname, "rb") as bio: - # The header line for the CSV file - first_line = bio.readline() - bio.seek(start) - to_read = header + first_line + bio.read(end - start) - - table = csv.read_csv( - BytesIO(to_read), parse_options=csv.ParseOptions(header_rows=1) - ) - chunksize = compute_chunksize(table.num_columns, num_splits) - chunks = [ - pa.Table.from_arrays(table.columns[chunksize * i : chunksize * (i + 1)]) - for i in range(num_splits) - ] - return chunks + [ - table.num_rows, - pandas.Series( - [t.to_pandas_dtype() for t in table.schema.types], - index=table.schema.names, - ), - ] diff --git a/modin/experimental/core/storage_formats/pyarrow/query_compiler.py b/modin/experimental/core/storage_formats/pyarrow/query_compiler.py deleted file mode 100644 index 75e42e5a6c0..00000000000 --- a/modin/experimental/core/storage_formats/pyarrow/query_compiler.py +++ /dev/null @@ -1,95 +0,0 @@ -# Licensed to Modin Development Team under one or more contributor license agreements. -# See the NOTICE file distributed with this work for additional information regarding -# copyright ownership. The Modin Development Team licenses this file to you under the -# Apache License, Version 2.0 (the "License"); you may not use this file except in -# compliance with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software distributed under -# the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF -# ANY KIND, either express or implied. See the License for the specific language -# governing permissions and limitations under the License. - -""" -Module contains ``PyarrowQueryCompiler`` class. - -``PyarrowQueryCompiler`` is responsible for compiling efficient DataFrame algebra -queries for the ``PyarrowOnRayDataframe``. -""" - -import pandas - -from modin.core.storage_formats.pandas.query_compiler import PandasQueryCompiler -from modin.utils import _inherit_docstrings - - -class FakeSeries: - """ - Series metadata class. - - Parameters - ---------- - dtype : dtype - Data-type of the represented Series. - """ - - def __init__(self, dtype): - self.dtype = dtype - - -@_inherit_docstrings(PandasQueryCompiler) -class PyarrowQueryCompiler(PandasQueryCompiler): - """ - Query compiler for the PyArrow storage format. - - This class translates common query compiler API into the DataFrame Algebra - queries, that is supposed to be executed by - :py:class:`~modin.experimental.core.execution.ray.implementations.pyarrow_on_ray.dataframe.dataframe.PyarrowOnRayDataframe`. - - Parameters - ---------- - modin_frame : PyarrowOnRayDataframe - Modin Frame to query with the compiled queries. - shape_hint : {"row", "column", None}, default: None - Shape hint for frames known to be a column or a row, otherwise None. - """ - - def _compute_index(self, axis, data_object, compute_diff=True): - """ - Compute index labels of the passed Modin Frame along specified axis. - - Parameters - ---------- - axis : {0, 1} - Axis to compute index labels along. 0 is for index and 1 is for column. - data_object : PyarrowOnRayDataframe - Modin Frame object to build indices from. - compute_diff : bool, default: True - Whether to cut the resulted indices to a subset of the self indices. - - Returns - ------- - pandas.Index - """ - - def arrow_index_extraction(table, axis): - """Extract index labels from the passed pyarrow table the along specified axis.""" - if not axis: - return pandas.Index(table.column(table.num_columns - 1)) - else: - try: - return pandas.Index(table.columns) - except AttributeError: - return [] - - index_obj = self.index if not axis else self.columns - old_blocks = self.data if compute_diff else None - # FIXME: `PandasDataframe.get_indices` was deprecated, this call should be - # replaced either by `PandasDataframe._compute_axis_label` or by `PandasDataframe.axes`. - new_indices, _ = data_object.get_indices( - axis=axis, - index_func=lambda df: arrow_index_extraction(df, axis), - old_blocks=old_blocks, - ) - return index_obj[new_indices] if compute_diff else new_indices diff --git a/modin/test/test_executions_api.py b/modin/test/test_executions_api.py index 35013132eea..f1cd635232b 100644 --- a/modin/test/test_executions_api.py +++ b/modin/test/test_executions_api.py @@ -14,10 +14,9 @@ import pytest from modin.core.storage_formats import BaseQueryCompiler, PandasQueryCompiler -from modin.experimental.core.storage_formats.pyarrow import PyarrowQueryCompiler BASE_EXECUTION = BaseQueryCompiler -EXECUTIONS = [PandasQueryCompiler, PyarrowQueryCompiler] +EXECUTIONS = [PandasQueryCompiler] def test_base_abstract_methods(): diff --git a/scripts/doc_checker.py b/scripts/doc_checker.py index 0979274d973..70db787b0b3 100644 --- a/scripts/doc_checker.py +++ b/scripts/doc_checker.py @@ -514,7 +514,6 @@ def monkeypatch(*args, **kwargs): pandas.util.cache_readonly = property # We are mocking packages we don't need for docs checking in order to avoid import errors - sys.modules["pyarrow.gandiva"] = Mock() sys.modules["sqlalchemy"] = Mock() modin.utils.instancer = functools.wraps(modin.utils.instancer)(lambda cls: cls) diff --git a/setup.cfg b/setup.cfg index 55de39100b6..3acc554836f 100644 --- a/setup.cfg +++ b/setup.cfg @@ -58,9 +58,6 @@ omit = modin/experimental/core/execution/native/implementations/hdk_on_native/test/* # Plotting is not tested modin/pandas/plotting.py - # Skip Gandiva because it is experimental - modin/experimental/core/execution/ray/implementations/pyarrow_on_ray/* - modin/core/storage_formats/pyarrow/* # Skip CUDF tests modin/storage_formats/cudf/* modin/core/execution/ray/implementations/cudf_on_ray/* From c7acce54dac0d953a46964c192b9c21b4de09a13 Mon Sep 17 00:00:00 2001 From: Anatoly Myachev Date: Thu, 11 Jan 2024 09:12:16 +0100 Subject: [PATCH 133/201] REFACTOR-#6852: Remove OrderedDict in favor of builtin dict (#6853) Signed-off-by: Anatoly Myachev --- examples/docker/modin-hdk/plasticc-hdk.py | 5 ++- examples/docker/modin-ray/plasticc.py | 5 ++- .../dataframe/pandas/dataframe/dataframe.py | 29 +++++++-------- modin/core/io/io.py | 7 ++-- modin/core/storage_formats/cudf/parser.py | 3 +- modin/core/storage_formats/pandas/parsers.py | 3 +- .../hdk_on_native/dataframe/dataframe.py | 35 +++++++++---------- modin/experimental/core/io/sql/utils.py | 12 +++---- modin/pandas/io.py | 3 +- modin/pandas/test/test_io.py | 5 ++- 10 files changed, 46 insertions(+), 61 deletions(-) diff --git a/examples/docker/modin-hdk/plasticc-hdk.py b/examples/docker/modin-hdk/plasticc-hdk.py index bf39a41d44b..704e0fe49cd 100644 --- a/examples/docker/modin-hdk/plasticc-hdk.py +++ b/examples/docker/modin-hdk/plasticc-hdk.py @@ -12,7 +12,6 @@ # governing permissions and limitations under the License. import sys -from collections import OrderedDict from functools import partial import numpy as np @@ -23,7 +22,7 @@ ################ helper functions ############################### def create_dtypes(): - dtypes = OrderedDict( + dtypes = dict( [ ("object_id", "int32"), ("mjd", "float32"), @@ -50,7 +49,7 @@ def create_dtypes(): "target", ] meta_dtypes = ["int32"] + ["float32"] * 4 + ["int32"] + ["float32"] * 5 + ["int32"] - meta_dtypes = OrderedDict( + meta_dtypes = dict( [(columns_names[i], meta_dtypes[i]) for i in range(len(meta_dtypes))] ) return dtypes, meta_dtypes diff --git a/examples/docker/modin-ray/plasticc.py b/examples/docker/modin-ray/plasticc.py index fc55be84b8a..1c0cddadd1b 100644 --- a/examples/docker/modin-ray/plasticc.py +++ b/examples/docker/modin-ray/plasticc.py @@ -13,7 +13,6 @@ import sys import time -from collections import OrderedDict from functools import partial import numpy as np @@ -29,7 +28,7 @@ ################ helper functions ############################### def create_dtypes(): - dtypes = OrderedDict( + dtypes = dict( [ ("object_id", "int32"), ("mjd", "float32"), @@ -56,7 +55,7 @@ def create_dtypes(): "target", ] meta_dtypes = ["int32"] + ["float32"] * 4 + ["int32"] + ["float32"] * 5 + ["int32"] - meta_dtypes = OrderedDict( + meta_dtypes = dict( [(columns_names[i], meta_dtypes[i]) for i in range(len(meta_dtypes))] ) return dtypes, meta_dtypes diff --git a/modin/core/dataframe/pandas/dataframe/dataframe.py b/modin/core/dataframe/pandas/dataframe/dataframe.py index fc15c81496e..e3b92afdf2c 100644 --- a/modin/core/dataframe/pandas/dataframe/dataframe.py +++ b/modin/core/dataframe/pandas/dataframe/dataframe.py @@ -18,7 +18,6 @@ for pandas storage format. """ import datetime -from collections import OrderedDict from typing import TYPE_CHECKING, Callable, Dict, Hashable, List, Optional, Union import numpy as np @@ -1686,7 +1685,7 @@ def _get_dict_of_block_index(self, axis, indices, are_indices_sorted=False): Returns ------- - OrderedDict + dict A mapping from partition index to list of internal indices which correspond to `indices` in each partition. """ @@ -1700,7 +1699,7 @@ def _get_dict_of_block_index(self, axis, indices, are_indices_sorted=False): # Converting range-like indexer to slice indices = slice(indices.start, indices.stop, indices.step) if is_full_grab_slice(indices, sequence_len=len(self.get_axis(axis))): - return OrderedDict( + return dict( zip( range(self._partitions.shape[axis]), [slice(None)] * self._partitions.shape[axis], @@ -1708,25 +1707,23 @@ def _get_dict_of_block_index(self, axis, indices, are_indices_sorted=False): ) # Empty selection case if indices.start == indices.stop and indices.start is not None: - return OrderedDict() + return dict() if indices.start is None or indices.start == 0: last_part, last_idx = list( self._get_dict_of_block_index(axis, [indices.stop]).items() )[0] - dict_of_slices = OrderedDict( - zip(range(last_part), [slice(None)] * last_part) - ) + dict_of_slices = dict(zip(range(last_part), [slice(None)] * last_part)) dict_of_slices.update({last_part: slice(last_idx[0])}) return dict_of_slices elif indices.stop is None or indices.stop >= len(self.get_axis(axis)): first_part, first_idx = list( self._get_dict_of_block_index(axis, [indices.start]).items() )[0] - dict_of_slices = OrderedDict({first_part: slice(first_idx[0], None)}) + dict_of_slices = dict({first_part: slice(first_idx[0], None)}) num_partitions = np.size(self._partitions, axis=axis) part_list = range(first_part + 1, num_partitions) dict_of_slices.update( - OrderedDict(zip(part_list, [slice(None)] * len(part_list))) + dict(zip(part_list, [slice(None)] * len(part_list))) ) return dict_of_slices else: @@ -1737,10 +1734,10 @@ def _get_dict_of_block_index(self, axis, indices, are_indices_sorted=False): self._get_dict_of_block_index(axis, [indices.stop]).items() )[0] if first_part == last_part: - return OrderedDict({first_part: slice(first_idx[0], last_idx[0])}) + return dict({first_part: slice(first_idx[0], last_idx[0])}) else: if last_part - first_part == 1: - return OrderedDict( + return dict( # FIXME: this dictionary creation feels wrong - it might not maintain the order { first_part: slice(first_idx[0], None), @@ -1748,12 +1745,10 @@ def _get_dict_of_block_index(self, axis, indices, are_indices_sorted=False): } ) else: - dict_of_slices = OrderedDict( - {first_part: slice(first_idx[0], None)} - ) + dict_of_slices = dict({first_part: slice(first_idx[0], None)}) part_list = range(first_part + 1, last_part) dict_of_slices.update( - OrderedDict(zip(part_list, [slice(None)] * len(part_list))) + dict(zip(part_list, [slice(None)] * len(part_list))) ) dict_of_slices.update({last_part: slice(None, last_idx[0])}) return dict_of_slices @@ -1765,7 +1760,7 @@ def _get_dict_of_block_index(self, axis, indices, are_indices_sorted=False): # This will help preserve metadata stored in empty dataframes (indexes and dtypes) # Otherwise, we will get an empty `new_partitions` array, from which it will # no longer be possible to obtain metadata - return OrderedDict([(0, np.array([], dtype=np.int64))]) + return dict([(0, np.array([], dtype=np.int64))]) negative_mask = np.less(indices, 0) has_negative = np.any(negative_mask) if has_negative: @@ -1827,7 +1822,7 @@ def internal(block_idx: int, global_index): for i in range(1, len(count_for_each_partition)) if count_for_each_partition[i] > count_for_each_partition[i - 1] ] - return OrderedDict(partition_ids_with_indices) + return dict(partition_ids_with_indices) @staticmethod def _join_index_objects(axis, indexes, how, sort): diff --git a/modin/core/io/io.py b/modin/core/io/io.py index a024d0c6c0e..5d31a64f437 100644 --- a/modin/core/io/io.py +++ b/modin/core/io/io.py @@ -17,7 +17,6 @@ `BaseIO` is base class for IO classes, that stores IO functions. """ -from collections import OrderedDict from typing import Any import pandas @@ -273,8 +272,8 @@ def read_clipboard(cls, sep=r"\s+", **kwargs): # pragma: no cover # noqa: PR01 @doc( _doc_default_io_method, summary="Read an Excel file into query compiler", - returns="""BaseQueryCompiler or dict/OrderedDict : - QueryCompiler or OrderedDict/dict with read data.""", + returns="""BaseQueryCompiler or dict : + QueryCompiler or dict with read data.""", ) def read_excel(cls, **kwargs): # noqa: PR01 ErrorMessage.default_to_pandas("`read_excel`") @@ -285,7 +284,7 @@ def read_excel(cls, **kwargs): # noqa: PR01 # pd.ExcelFile in `read_excel` isn't supported kwargs["io"]._set_pandas_mode() intermediate = pandas.read_excel(**kwargs) - if isinstance(intermediate, (OrderedDict, dict)): + if isinstance(intermediate, dict): parsed = type(intermediate)() for key in intermediate.keys(): parsed[key] = cls.from_pandas(intermediate.get(key)) diff --git a/modin/core/storage_formats/cudf/parser.py b/modin/core/storage_formats/cudf/parser.py index 1cfa7680893..ac206cfacb8 100644 --- a/modin/core/storage_formats/cudf/parser.py +++ b/modin/core/storage_formats/cudf/parser.py @@ -12,7 +12,6 @@ # governing permissions and limitations under the License. import warnings -from collections import OrderedDict from io import BytesIO import numpy as np @@ -83,7 +82,7 @@ def single_worker_read(cls, fname, *, reason, **kwargs): ) ) return pandas_frame - elif isinstance(pandas_frame, (OrderedDict, dict)): + elif isinstance(pandas_frame, dict): return { i: cls.query_compiler_cls.from_pandas(frame, cls.frame_cls) for i, frame in pandas_frame.items() diff --git a/modin/core/storage_formats/pandas/parsers.py b/modin/core/storage_formats/pandas/parsers.py index ceb10658381..b677a541d2e 100644 --- a/modin/core/storage_formats/pandas/parsers.py +++ b/modin/core/storage_formats/pandas/parsers.py @@ -43,7 +43,6 @@ import json import os import warnings -from collections import OrderedDict from io import BytesIO, IOBase, TextIOWrapper from typing import Any, NamedTuple @@ -313,7 +312,7 @@ def single_worker_read(cls, fname, *args, reason: str, **kwargs): ) ) return pandas_frame - elif isinstance(pandas_frame, (OrderedDict, dict)): + elif isinstance(pandas_frame, dict): return { i: cls.query_compiler_cls.from_pandas(frame, cls.frame_cls) for i, frame in pandas_frame.items() diff --git a/modin/experimental/core/execution/native/implementations/hdk_on_native/dataframe/dataframe.py b/modin/experimental/core/execution/native/implementations/hdk_on_native/dataframe/dataframe.py index 445eccac6a8..b5932a632ed 100644 --- a/modin/experimental/core/execution/native/implementations/hdk_on_native/dataframe/dataframe.py +++ b/modin/experimental/core/execution/native/implementations/hdk_on_native/dataframe/dataframe.py @@ -14,7 +14,6 @@ """Module provides ``HdkOnNativeDataframe`` class implementing lazy frame.""" import re -from collections import OrderedDict from typing import Hashable, Iterable, List, Optional, Tuple, Union import numpy as np @@ -464,7 +463,7 @@ def take_2d_labels_or_positional( # Sort by the rowid column base = base.copy(op=SortNode(base, [rowid_col], [False], "last")) # Remove the rowid column - exprs = OrderedDict() + exprs = dict() for col in table_cols: exprs[col] = base.ref(col) base = base.copy( @@ -614,7 +613,7 @@ def generate_by_name(by): else: return by - exprs = OrderedDict( + exprs = dict( ((generate_by_name(col), by_frame.ref(col)) for col in groupby_cols) ) groupby_cols = list(exprs.keys()) @@ -647,7 +646,7 @@ def generate_by_name(by): new_dtypes = base._dtypes[groupby_cols].tolist() - agg_exprs = OrderedDict() + agg_exprs = dict() if isinstance(agg, str): col_to_ref = {col: base.ref(col) for col in agg_cols} self._add_agg_exprs(agg, col_to_ref, kwargs, agg_exprs) @@ -799,7 +798,7 @@ def agg(self, agg): """ assert isinstance(agg, str) - agg_exprs = OrderedDict() + agg_exprs = dict() for col in self.columns: agg_exprs[col] = AggregateExpr(agg, self.ref(col)) @@ -1089,7 +1088,7 @@ def join( if isinstance(self._op, FrameNode): other = self.copy() else: - exprs = OrderedDict((c, self.ref(c)) for c in self._table_cols) + exprs = dict((c, self.ref(c)) for c in self._table_cols) other = self.__constructor__( columns=self.columns, dtypes=self._dtypes_for_exprs(exprs), @@ -1129,7 +1128,7 @@ def join( else: ignore_index = True index_cols = None - exprs = OrderedDict() + exprs = dict() new_dtypes = [] new_columns, left_renamer, right_renamer = join_columns( @@ -1235,7 +1234,7 @@ def _union_all( The new frame. """ index_cols = None - col_name_to_dtype = OrderedDict() + col_name_to_dtype = dict() for col in self.columns: col_name_to_dtype[col] = self._dtypes[col] @@ -1287,7 +1286,7 @@ def _union_all( ) if sort: - col_name_to_dtype = OrderedDict( + col_name_to_dtype = dict( (col, col_name_to_dtype[col]) for col in sorted(col_name_to_dtype) ) @@ -1308,7 +1307,7 @@ def _union_all( or any(frame_dtypes.index != dtypes.index) or any(frame_dtypes.values != dtypes.values) ): - exprs = OrderedDict() + exprs = dict() uses_rowid = False for col in table_col_name_to_dtype: if col in frame_dtypes: @@ -1785,7 +1784,7 @@ def sort_rows(self, columns, ascending, ignore_index, na_position): drop_index_cols_after = None if drop_index_cols_before: - exprs = OrderedDict() + exprs = dict() index_cols = ( drop_index_cols_after if drop_index_cols_after else None ) @@ -1810,7 +1809,7 @@ def sort_rows(self, columns, ascending, ignore_index, na_position): ) if drop_index_cols_after: - exprs = OrderedDict() + exprs = dict() for col in base.columns: exprs[col] = base.ref(col) base = base.__constructor__( @@ -1950,7 +1949,7 @@ def _materialize_rowid(self): """ name = self._index_cache.get().name if self.has_materialized_index else None name = mangle_index_names([name])[0] - exprs = OrderedDict() + exprs = dict() exprs[name] = self.ref(ROWID_COL_NAME) for col in self._table_cols: exprs[col] = self.ref(col) @@ -1974,7 +1973,7 @@ def _index_exprs(self): ------- dict """ - exprs = OrderedDict() + exprs = dict() if self._index_cols: for col in self._index_cols: exprs[col] = self.ref(col) @@ -2290,7 +2289,7 @@ def reset_index(self, drop): The new frame. """ if drop: - exprs = OrderedDict() + exprs = dict() for c in self.columns: exprs[c] = self.ref(c) return self.__constructor__( @@ -2306,7 +2305,7 @@ def reset_index(self, drop): "default index reset with no drop is not supported" ) # Need to demangle index names. - exprs = OrderedDict() + exprs = dict() for i, c in enumerate(self._index_cols): name = ColNameCodec.demangle_index_name(c) if name is None: @@ -2542,7 +2541,7 @@ def set_index_name(self, name): return self names = mangle_index_names([name]) - exprs = OrderedDict() + exprs = dict() if self._index_cols is None: exprs[names[0]] = self.ref(ROWID_COL_NAME) else: @@ -2597,7 +2596,7 @@ def set_index_names(self, names): ) names = mangle_index_names(names) - exprs = OrderedDict() + exprs = dict() for old, new in zip(self._index_cols, names): exprs[new] = self.ref(old) for col in self.columns: diff --git a/modin/experimental/core/io/sql/utils.py b/modin/experimental/core/io/sql/utils.py index c201bd29fc1..530f300df3e 100644 --- a/modin/experimental/core/io/sql/utils.py +++ b/modin/experimental/core/io/sql/utils.py @@ -13,8 +13,6 @@ """Utilities for experimental SQL format type IO functions implementations.""" -from collections import OrderedDict - import pandas import pandas._libs.lib as lib from sqlalchemy import MetaData, Table, create_engine, inspect @@ -109,10 +107,10 @@ def get_table_columns(metadata): Returns ------- - OrderedDict + dict Dictionary with columns names and python types. """ - cols = OrderedDict() + cols = dict() for col in metadata.c: name = str(col).rpartition(".")[2] cols[name] = col.type.python_type.__name__ @@ -165,14 +163,14 @@ def get_query_columns(engine, query): Returns ------- - OrderedDict + dict Dictionary with columns names and python types. """ con = engine.connect() result = con.execute(query).fetchone() values = list(result) cols_names = list(result.keys()) - cols = OrderedDict() + cols = dict() for i in range(len(cols_names)): cols[cols_names[i]] = type(values[i]).__name__ return cols @@ -186,7 +184,7 @@ def check_partition_column(partition_column, cols): ---------- partition_column : str Column name used for data partitioning between the workers. - cols : OrderedDict/dict + cols : dict Dictionary with columns names and python types. """ for k, v in cols.items(): diff --git a/modin/pandas/io.py b/modin/pandas/io.py index 76d7416e027..8045d8203af 100644 --- a/modin/pandas/io.py +++ b/modin/pandas/io.py @@ -25,7 +25,6 @@ import inspect import pathlib import pickle -from collections import OrderedDict from typing import ( IO, TYPE_CHECKING, @@ -491,7 +490,7 @@ def read_excel( from modin.core.execution.dispatching.factories.dispatcher import FactoryDispatcher intermediate = FactoryDispatcher.read_excel(**kwargs) - if isinstance(intermediate, (OrderedDict, dict)): + if isinstance(intermediate, dict): parsed = type(intermediate)() for key in intermediate.keys(): parsed[key] = ModinObjects.DataFrame(query_compiler=intermediate.get(key)) diff --git a/modin/pandas/test/test_io.py b/modin/pandas/test/test_io.py index 136a255654f..14ad75ee463 100644 --- a/modin/pandas/test/test_io.py +++ b/modin/pandas/test/test_io.py @@ -17,7 +17,7 @@ import os import sys import unittest.mock as mock -from collections import OrderedDict, defaultdict +from collections import defaultdict from io import BytesIO, StringIO from pathlib import Path from typing import Dict @@ -2282,7 +2282,7 @@ def test_read_excel_all_sheets(self, make_excel_file): pandas_df = pandas.read_excel(unique_filename, sheet_name=None) modin_df = pd.read_excel(unique_filename, sheet_name=None) - assert isinstance(pandas_df, (OrderedDict, dict)) + assert isinstance(pandas_df, dict) assert isinstance(modin_df, type(pandas_df)) assert pandas_df.keys() == modin_df.keys() @@ -3220,7 +3220,6 @@ def test_to_dict_dataframe(): [ pytest.param({}, id="no_kwargs"), pytest.param({"into": dict}, id="into_dict"), - pytest.param({"into": OrderedDict}, id="into_ordered_dict"), pytest.param({"into": defaultdict(list)}, id="into_defaultdict"), ], ) From ff13d6f6fc982efcc99fb01cfed183da384cb5d5 Mon Sep 17 00:00:00 2001 From: Anatoly Myachev Date: Thu, 11 Jan 2024 14:59:38 +0100 Subject: [PATCH 134/201] TEST-#6777: Make `to_csv` tests on Unidist more stable (for `test-all-unidist` CI job) (#6851) Signed-off-by: Anatoly Myachev --- .github/workflows/ci.yml | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c51d0b22a98..83c4de34a3a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -378,7 +378,15 @@ jobs: - run: ./.github/workflows/sql_server/set_up_sql_server.sh # need an extra argument "genv" to set environment variables for mpiexec. We need # these variables to test writing to the mock s3 filesystem. - - run: mpiexec -n 1 -genv AWS_ACCESS_KEY_ID foobar_key -genv AWS_SECRET_ACCESS_KEY foobar_secret python -m pytest modin/pandas/test/test_io.py --verbose + - uses: nick-fields/retry@v2 + # to avoid issues with non-stable `to_csv` tests for unidist on MPI backend. + # for details see: https://github.com/modin-project/modin/pull/6776 + with: + timeout_minutes: 15 + max_attempts: 3 + command: | + conda run --no-capture-output -n modin_on_unidist mpiexec -n 1 -genv AWS_ACCESS_KEY_ID foobar_key \ + -genv AWS_SECRET_ACCESS_KEY foobar_secret python -m pytest modin/pandas/test/test_io.py --verbose - run: mpiexec -n 1 python -m pytest modin/experimental/pandas/test/test_io_exp.py - run: mpiexec -n 1 python -m pytest modin/experimental/sql/test/test_sql.py - run: mpiexec -n 1 python -m pytest modin/test/interchange/dataframe_protocol/test_general.py From 45ad9deced24fa0e5c8e119b15fcec8bb95dbd8d Mon Sep 17 00:00:00 2001 From: Arun Jose <40291569+arunjose696@users.noreply.github.com> Date: Thu, 11 Jan 2024 17:18:27 +0100 Subject: [PATCH 135/201] FEAT-#6849: Removing `to_pandas` call in `merge` and `join` functions (#6850) Signed-off-by: arunjose696 --- .../storage_formats/pandas/query_compiler.py | 38 +++++++++---------- 1 file changed, 18 insertions(+), 20 deletions(-) diff --git a/modin/core/storage_formats/pandas/query_compiler.py b/modin/core/storage_formats/pandas/query_compiler.py index 50d2b477c71..4566037205f 100644 --- a/modin/core/storage_formats/pandas/query_compiler.py +++ b/modin/core/storage_formats/pandas/query_compiler.py @@ -522,8 +522,6 @@ def merge(self, right, **kwargs): sort = kwargs.get("sort", False) if how in ["left", "inner"] and left_index is False and right_index is False: - right_pandas = right.to_pandas() - kwargs["sort"] = False def should_keep_index(left, right): @@ -542,7 +540,7 @@ def should_keep_index(left, right): return keep_index def map_func( - left, *axis_lengths, right=right_pandas, kwargs=kwargs, **service_kwargs + left, right, *axis_lengths, kwargs=kwargs, **service_kwargs ): # pragma: no cover df = pandas.merge(left, right, **kwargs) @@ -571,7 +569,7 @@ def map_func( if self._modin_frame.has_materialized_columns: if left_on is None and right_on is None: if on is None: - on = [c for c in self.columns if c in right_pandas.columns] + on = [c for c in self.columns if c in right.columns] _left_on, _right_on = on, on else: if left_on is None or right_on is None: @@ -583,7 +581,7 @@ def map_func( try: new_columns, left_renamer, right_renamer = join_columns( self.columns, - right_pandas.columns, + right.columns, _left_on, _right_on, kwargs.get("suffixes", ("_x", "_y")), @@ -595,15 +593,15 @@ def map_func( else: # renamers may contain columns from 'index', so trying to merge index and column dtypes here right_index_dtypes = ( - right_pandas.index.dtypes - if isinstance(right_pandas.index, pandas.MultiIndex) + right.index.dtypes + if isinstance(right.index, pandas.MultiIndex) else pandas.Series( - [right_pandas.index.dtype], index=[right_pandas.index.name] + [right.index.dtype], index=[right.index.name] ) ) - right_dtypes = pandas.concat( - [right_pandas.dtypes, right_index_dtypes] - )[right_renamer.keys()].rename(right_renamer) + right_dtypes = pandas.concat([right.dtypes, right_index_dtypes])[ + right_renamer.keys() + ].rename(right_renamer) left_index_dtypes = ( self._modin_frame._index_cache.maybe_get_dtypes() @@ -618,10 +616,11 @@ def map_func( new_dtypes = ModinDtypes.concat([left_dtypes, right_dtypes]) new_self = self.__constructor__( - self._modin_frame.apply_full_axis( + self._modin_frame.broadcast_apply_full_axis( axis=1, func=map_func, enumerate_partitions=how == "left", + other=right._modin_frame, # We're going to explicitly change the shape across the 1-axis, # so we want for partitioning to adapt as well keep_partitioning=False, @@ -629,8 +628,8 @@ def map_func( self._modin_frame, right._modin_frame, axis=1 ), new_columns=new_columns, - dtypes=new_dtypes, sync_labels=False, + dtypes=new_dtypes, pass_axis_lengths_to_partitions=how == "left", ) ) @@ -641,20 +640,19 @@ def map_func( # materialized quite often compared to the indexes. keep_index = False if self._modin_frame.has_materialized_index: - keep_index = should_keep_index(self, right_pandas) + keep_index = should_keep_index(self, right) else: # Have to trigger columns materialization. Hope they're already available at this point. if left_on is not None and right_on is not None: keep_index = any( - o not in right_pandas.columns + o not in right.columns and o in left_on and o not in self.columns for o in right_on ) elif on is not None: keep_index = any( - o not in right_pandas.columns and o not in self.columns - for o in on + o not in right.columns and o not in self.columns for o in on ) if sort: @@ -685,13 +683,12 @@ def join(self, right, **kwargs): sort = kwargs.get("sort", False) if how in ["left", "inner"]: - right_pandas = right.to_pandas() - def map_func(left, right=right_pandas, kwargs=kwargs): # pragma: no cover + def map_func(left, right, kwargs=kwargs): # pragma: no cover return pandas.DataFrame.join(left, right, **kwargs) new_self = self.__constructor__( - self._modin_frame.apply_full_axis( + self._modin_frame.broadcast_apply_full_axis( axis=1, func=map_func, # We're going to explicitly change the shape across the 1-axis, @@ -700,6 +697,7 @@ def map_func(left, right=right_pandas, kwargs=kwargs): # pragma: no cover num_splits=merge_partitioning( self._modin_frame, right._modin_frame, axis=1 ), + other=right._modin_frame, ) ) return new_self.sort_rows_by_column_values(on) if sort else new_self From f6b31d62e555033f383416da20b7241fec96a81e Mon Sep 17 00:00:00 2001 From: Anatoly Myachev Date: Sat, 13 Jan 2024 13:43:54 +0100 Subject: [PATCH 136/201] FEAT-#6831: Implement read_parquet_glob and to_parquet_glob (#6854) Co-authored-by: Iaroslav Igoshev Signed-off-by: Anatoly Myachev --- docs/flow/modin/experimental/pandas.rst | 2 + docs/supported_apis/dataframe_supported.rst | 2 + docs/supported_apis/io_supported.rst | 1 + docs/usage_guide/advanced_usage/index.rst | 6 +- .../implementations/pandas_on_dask/io/io.py | 17 +++- .../dispatching/factories/dispatcher.py | 10 +++ .../dispatching/factories/factories.py | 33 +++++++ .../implementations/pandas_on_ray/io/io.py | 17 +++- .../pandas_on_unidist/io/io.py | 17 +++- modin/core/io/io.py | 4 +- modin/experimental/core/io/__init__.py | 4 +- .../core/io/{pickle => glob}/__init__.py | 2 +- .../glob_dispatcher.py} | 48 +++++----- .../core/storage_formats/pandas/parsers.py | 18 ++++ modin/experimental/pandas/__init__.py | 1 + modin/experimental/pandas/io.py | 90 ++++++++++++++++++- modin/experimental/pandas/test/test_io_exp.py | 27 ++++++ modin/pandas/accessor.py | 43 ++++++++- modin/pandas/dataframe.py | 2 +- 19 files changed, 297 insertions(+), 47 deletions(-) rename modin/experimental/core/io/{pickle => glob}/__init__.py (90%) rename modin/experimental/core/io/{pickle/pickle_dispatcher.py => glob/glob_dispatcher.py} (75%) diff --git a/docs/flow/modin/experimental/pandas.rst b/docs/flow/modin/experimental/pandas.rst index 25d9d8f3bcc..d429003c735 100644 --- a/docs/flow/modin/experimental/pandas.rst +++ b/docs/flow/modin/experimental/pandas.rst @@ -13,4 +13,6 @@ Experimental API Reference .. autofunction:: read_csv_glob .. autofunction:: read_custom_text .. autofunction:: read_pickle_distributed +.. autofunction:: read_parquet_glob .. automethod:: modin.pandas.DataFrame.modin::to_pickle_distributed +.. automethod:: modin.pandas.DataFrame.modin::to_parquet_glob diff --git a/docs/supported_apis/dataframe_supported.rst b/docs/supported_apis/dataframe_supported.rst index bcd5a364221..7d29af21265 100644 --- a/docs/supported_apis/dataframe_supported.rst +++ b/docs/supported_apis/dataframe_supported.rst @@ -421,6 +421,8 @@ default to pandas. | | | | ``path`` parameter specifies a directory where one | | | | | file is written per row partition of the Modin | | | | | dataframe. | +| | | | Experimental implementation: | +| | | | DataFrame.modin.to_parquet_glob | +----------------------------+---------------------------+------------------------+----------------------------------------------------+ | ``to_period`` | `to_period`_ | D | | +----------------------------+---------------------------+------------------------+----------------------------------------------------+ diff --git a/docs/supported_apis/io_supported.rst b/docs/supported_apis/io_supported.rst index c29c0792ef6..11f2a99f5e7 100644 --- a/docs/supported_apis/io_supported.rst +++ b/docs/supported_apis/io_supported.rst @@ -46,6 +46,7 @@ default to pandas. | | | passed via ``**kwargs`` are not supported. | | | | ``use_nullable_dtypes`` == True is not supported. | | | | | +| | | Experimental implementation: read_parquet_glob | +-------------------+---------------------------------+--------------------------------------------------------+ | `read_json`_ | P | Implemented for ``lines=True`` | +-------------------+---------------------------------+--------------------------------------------------------+ diff --git a/docs/usage_guide/advanced_usage/index.rst b/docs/usage_guide/advanced_usage/index.rst index 66560bebbe8..3151c28cff7 100644 --- a/docs/usage_guide/advanced_usage/index.rst +++ b/docs/usage_guide/advanced_usage/index.rst @@ -30,8 +30,10 @@ Modin also supports these experimental APIs on top of pandas that are under acti - :py:func:`~modin.experimental.pandas.read_csv_glob` -- read multiple files in a directory - :py:func:`~modin.experimental.pandas.read_sql` -- add optional parameters for the database connection - :py:func:`~modin.experimental.pandas.read_custom_text` -- read custom text data from file -- :py:func:`~modin.experimental.pandas.read_pickle_distributed` -- read multiple files in a directory -- :py:meth:`~modin.pandas.DataFrame.modin.to_pickle_distributed` -- write to multiple files in a directory +- :py:func:`~modin.experimental.pandas.read_pickle_distributed` -- read multiple pickle files in a directory +- :py:func:`~modin.experimental.pandas.read_parquet_glob` -- read multiple parquet files in a directory +- :py:meth:`~modin.pandas.DataFrame.modin.to_pickle_distributed` -- write to multiple pickle files in a directory +- :py:meth:`~modin.pandas.DataFrame.modin.to_parquet_glob` -- write to multiple parquet files in a directory DataFrame partitioning API -------------------------- diff --git a/modin/core/execution/dask/implementations/pandas_on_dask/io/io.py b/modin/core/execution/dask/implementations/pandas_on_dask/io/io.py index e5242458b65..4c51ebe395d 100644 --- a/modin/core/execution/dask/implementations/pandas_on_dask/io/io.py +++ b/modin/core/execution/dask/implementations/pandas_on_dask/io/io.py @@ -43,12 +43,13 @@ from modin.experimental.core.io import ( ExperimentalCSVGlobDispatcher, ExperimentalCustomTextDispatcher, - ExperimentalPickleDispatcher, + ExperimentalGlobDispatcher, ExperimentalSQLDispatcher, ) from modin.experimental.core.storage_formats.pandas.parsers import ( ExperimentalCustomTextParser, ExperimentalPandasCSVGlobParser, + ExperimentalPandasParquetParser, ExperimentalPandasPickleParser, ) @@ -89,10 +90,20 @@ def __make_write(*classes, build_args=build_args): read_csv_glob = __make_read( ExperimentalPandasCSVGlobParser, ExperimentalCSVGlobDispatcher ) + read_parquet_glob = __make_read( + ExperimentalPandasParquetParser, ExperimentalGlobDispatcher + ) + to_parquet_glob = __make_write( + ExperimentalGlobDispatcher, + build_args={**build_args, "base_write": BaseIO.to_parquet}, + ) read_pickle_distributed = __make_read( - ExperimentalPandasPickleParser, ExperimentalPickleDispatcher + ExperimentalPandasPickleParser, ExperimentalGlobDispatcher + ) + to_pickle_distributed = __make_write( + ExperimentalGlobDispatcher, + build_args={**build_args, "base_write": BaseIO.to_pickle}, ) - to_pickle_distributed = __make_write(ExperimentalPickleDispatcher) read_custom_text = __make_read( ExperimentalCustomTextParser, ExperimentalCustomTextDispatcher ) diff --git a/modin/core/execution/dispatching/factories/dispatcher.py b/modin/core/execution/dispatching/factories/dispatcher.py index 5cdbf65f821..8c4ecfa7ace 100644 --- a/modin/core/execution/dispatching/factories/dispatcher.py +++ b/modin/core/execution/dispatching/factories/dispatcher.py @@ -296,6 +296,16 @@ def to_pickle(cls, *args, **kwargs): def to_pickle_distributed(cls, *args, **kwargs): return cls.get_factory()._to_pickle_distributed(*args, **kwargs) + @classmethod + @_inherit_docstrings(factories.PandasOnRayFactory._read_parquet_glob) + def read_parquet_glob(cls, *args, **kwargs): + return cls.get_factory()._read_parquet_glob(*args, **kwargs) + + @classmethod + @_inherit_docstrings(factories.PandasOnRayFactory._to_parquet_glob) + def to_parquet_glob(cls, *args, **kwargs): + return cls.get_factory()._to_parquet_glob(*args, **kwargs) + @classmethod @_inherit_docstrings(factories.PandasOnRayFactory._read_custom_text) def read_custom_text(cls, **kwargs): diff --git a/modin/core/execution/dispatching/factories/factories.py b/modin/core/execution/dispatching/factories/factories.py index f1bc05539c2..91d6273421a 100644 --- a/modin/core/execution/dispatching/factories/factories.py +++ b/modin/core/execution/dispatching/factories/factories.py @@ -516,6 +516,39 @@ def _to_pickle_distributed(cls, *args, **kwargs): ) return cls.io_cls.to_pickle_distributed(*args, **kwargs) + @classmethod + @doc( + _doc_io_method_raw_template, + source="Parquet files", + params=_doc_io_method_kwargs_params, + ) + def _read_parquet_glob(cls, **kwargs): + current_execution = get_current_execution() + if current_execution not in supported_executions: + raise NotImplementedError( + f"`_read_parquet_glob()` is not implemented for {current_execution} execution." + ) + return cls.io_cls.read_parquet_glob(**kwargs) + + @classmethod + def _to_parquet_glob(cls, *args, **kwargs): + """ + Write query compiler content to several parquet files. + + Parameters + ---------- + *args : args + Arguments to pass to the writer method. + **kwargs : kwargs + Arguments to pass to the writer method. + """ + current_execution = get_current_execution() + if current_execution not in supported_executions: + raise NotImplementedError( + f"`_to_parquet_glob()` is not implemented for {current_execution} execution." + ) + return cls.io_cls.to_parquet_glob(*args, **kwargs) + @doc(_doc_factory_class, execution_name="PandasOnRay") class PandasOnRayFactory(BaseFactory): diff --git a/modin/core/execution/ray/implementations/pandas_on_ray/io/io.py b/modin/core/execution/ray/implementations/pandas_on_ray/io/io.py index cca429a526b..4a90cc54025 100644 --- a/modin/core/execution/ray/implementations/pandas_on_ray/io/io.py +++ b/modin/core/execution/ray/implementations/pandas_on_ray/io/io.py @@ -42,12 +42,13 @@ from modin.experimental.core.io import ( ExperimentalCSVGlobDispatcher, ExperimentalCustomTextDispatcher, - ExperimentalPickleDispatcher, + ExperimentalGlobDispatcher, ExperimentalSQLDispatcher, ) from modin.experimental.core.storage_formats.pandas.parsers import ( ExperimentalCustomTextParser, ExperimentalPandasCSVGlobParser, + ExperimentalPandasParquetParser, ExperimentalPandasPickleParser, ) @@ -91,10 +92,20 @@ def __make_write(*classes, build_args=build_args): read_csv_glob = __make_read( ExperimentalPandasCSVGlobParser, ExperimentalCSVGlobDispatcher ) + read_parquet_glob = __make_read( + ExperimentalPandasParquetParser, ExperimentalGlobDispatcher + ) + to_parquet_glob = __make_write( + ExperimentalGlobDispatcher, + build_args={**build_args, "base_write": RayIO.to_parquet}, + ) read_pickle_distributed = __make_read( - ExperimentalPandasPickleParser, ExperimentalPickleDispatcher + ExperimentalPandasPickleParser, ExperimentalGlobDispatcher + ) + to_pickle_distributed = __make_write( + ExperimentalGlobDispatcher, + build_args={**build_args, "base_write": RayIO.to_pickle}, ) - to_pickle_distributed = __make_write(ExperimentalPickleDispatcher) read_custom_text = __make_read( ExperimentalCustomTextParser, ExperimentalCustomTextDispatcher ) diff --git a/modin/core/execution/unidist/implementations/pandas_on_unidist/io/io.py b/modin/core/execution/unidist/implementations/pandas_on_unidist/io/io.py index 12e053252f5..4311acecbfa 100644 --- a/modin/core/execution/unidist/implementations/pandas_on_unidist/io/io.py +++ b/modin/core/execution/unidist/implementations/pandas_on_unidist/io/io.py @@ -42,12 +42,13 @@ from modin.experimental.core.io import ( ExperimentalCSVGlobDispatcher, ExperimentalCustomTextDispatcher, - ExperimentalPickleDispatcher, + ExperimentalGlobDispatcher, ExperimentalSQLDispatcher, ) from modin.experimental.core.storage_formats.pandas.parsers import ( ExperimentalCustomTextParser, ExperimentalPandasCSVGlobParser, + ExperimentalPandasParquetParser, ExperimentalPandasPickleParser, ) @@ -91,10 +92,20 @@ def __make_write(*classes, build_args=build_args): read_csv_glob = __make_read( ExperimentalPandasCSVGlobParser, ExperimentalCSVGlobDispatcher ) + read_parquet_glob = __make_read( + ExperimentalPandasParquetParser, ExperimentalGlobDispatcher + ) + to_parquet_glob = __make_write( + ExperimentalGlobDispatcher, + build_args={**build_args, "base_write": UnidistIO.to_parquet}, + ) read_pickle_distributed = __make_read( - ExperimentalPandasPickleParser, ExperimentalPickleDispatcher + ExperimentalPandasPickleParser, ExperimentalGlobDispatcher + ) + to_pickle_distributed = __make_write( + ExperimentalGlobDispatcher, + build_args={**build_args, "base_write": UnidistIO.to_pickle}, ) - to_pickle_distributed = __make_write(ExperimentalPickleDispatcher) read_custom_text = __make_read( ExperimentalCustomTextParser, ExperimentalCustomTextDispatcher ) diff --git a/modin/core/io/io.py b/modin/core/io/io.py index 5d31a64f437..92836a0bf18 100644 --- a/modin/core/io/io.py +++ b/modin/core/io/io.py @@ -651,7 +651,7 @@ def to_csv(cls, obj, **kwargs): # noqa: PR01 @_inherit_docstrings( pandas.DataFrame.to_parquet, apilink="pandas.DataFrame.to_parquet" ) - def to_parquet(cls, obj, **kwargs): # noqa: PR01 + def to_parquet(cls, obj, path, **kwargs): # noqa: PR01 """ Write object to the binary parquet format using pandas. @@ -661,4 +661,4 @@ def to_parquet(cls, obj, **kwargs): # noqa: PR01 if isinstance(obj, BaseQueryCompiler): obj = obj.to_pandas() - return obj.to_parquet(**kwargs) + return obj.to_parquet(path, **kwargs) diff --git a/modin/experimental/core/io/__init__.py b/modin/experimental/core/io/__init__.py index 9f3c64b00a8..cdd44bb6213 100644 --- a/modin/experimental/core/io/__init__.py +++ b/modin/experimental/core/io/__init__.py @@ -13,7 +13,7 @@ """Experimental IO functions implementations.""" -from .pickle.pickle_dispatcher import ExperimentalPickleDispatcher +from .glob.glob_dispatcher import ExperimentalGlobDispatcher from .sql.sql_dispatcher import ExperimentalSQLDispatcher from .text.csv_glob_dispatcher import ExperimentalCSVGlobDispatcher from .text.custom_text_dispatcher import ExperimentalCustomTextDispatcher @@ -21,6 +21,6 @@ __all__ = [ "ExperimentalCSVGlobDispatcher", "ExperimentalSQLDispatcher", - "ExperimentalPickleDispatcher", + "ExperimentalGlobDispatcher", "ExperimentalCustomTextDispatcher", ] diff --git a/modin/experimental/core/io/pickle/__init__.py b/modin/experimental/core/io/glob/__init__.py similarity index 90% rename from modin/experimental/core/io/pickle/__init__.py rename to modin/experimental/core/io/glob/__init__.py index 39b28ecda49..3b8ba1b4fa7 100644 --- a/modin/experimental/core/io/pickle/__init__.py +++ b/modin/experimental/core/io/glob/__init__.py @@ -11,4 +11,4 @@ # ANY KIND, either express or implied. See the License for the specific language # governing permissions and limitations under the License. -"""Experimental Pickle format type IO functions implementations.""" +"""Experimental module that allows to work with various formats using glob syntax.""" diff --git a/modin/experimental/core/io/pickle/pickle_dispatcher.py b/modin/experimental/core/io/glob/glob_dispatcher.py similarity index 75% rename from modin/experimental/core/io/pickle/pickle_dispatcher.py rename to modin/experimental/core/io/glob/glob_dispatcher.py index 119171e061c..29cb4896290 100644 --- a/modin/experimental/core/io/pickle/pickle_dispatcher.py +++ b/modin/experimental/core/io/glob/glob_dispatcher.py @@ -11,7 +11,7 @@ # ANY KIND, either express or implied. See the License for the specific language # governing permissions and limitations under the License. -"""Module houses ``ExperimentalPickleDispatcher`` class that is used for reading `.pkl` files.""" +"""Module houses ``ExperimentalGlobDispatcher`` class that is used to read/write files of different formats in parallel.""" import glob import warnings @@ -24,20 +24,20 @@ from modin.core.storage_formats.pandas.query_compiler import PandasQueryCompiler -class ExperimentalPickleDispatcher(FileDispatcher): - """Class handles utils for reading pickle files.""" +class ExperimentalGlobDispatcher(FileDispatcher): + """Class implements reading/writing different formats, parallelizing by the number of files.""" @classmethod - def _read(cls, filepath_or_buffer, **kwargs): + def _read(cls, **kwargs): """ Read data from `filepath_or_buffer` according to `kwargs` parameters. Parameters ---------- filepath_or_buffer : str, path object or file-like object - `filepath_or_buffer` parameter of `read_pickle` function. + `filepath_or_buffer` parameter of `read_*` function. **kwargs : dict - Parameters of `read_pickle` function. + Parameters of `read_*` function. Returns ------- @@ -46,10 +46,10 @@ def _read(cls, filepath_or_buffer, **kwargs): Notes ----- - In experimental mode, we can use `*` in the filename. - The number of partitions is equal to the number of input files. """ + path_key = "filepath_or_buffer" if "filepath_or_buffer" in kwargs else "path" + filepath_or_buffer = kwargs.pop(path_key) filepath_or_buffer = stringify_path(filepath_or_buffer) if not (isinstance(filepath_or_buffer, str) and "*" in filepath_or_buffer): return cls.single_worker_read( @@ -104,37 +104,33 @@ def write(cls, qc, **kwargs): - if `*` is in the filename, then it will be replaced by the ascending sequence 0, 1, 2, … - if `*` is not in the filename, then the default implementation will be used. - Example: 4 partitions and input filename="partition*.pkl.gz", then filenames will be: - `partition0.pkl.gz`, `partition1.pkl.gz`, `partition2.pkl.gz`, `partition3.pkl.gz`. - Parameters ---------- qc : BaseQueryCompiler The query compiler of the Modin dataframe that we want - to run ``to_pickle_distributed`` on. + to run ``to__glob`` on. **kwargs : dict - Parameters for ``pandas.to_pickle(**kwargs)``. + Parameters for ``pandas.to_(**kwargs)``. """ - kwargs["filepath_or_buffer"] = stringify_path(kwargs["filepath_or_buffer"]) + path_key = "filepath_or_buffer" if "filepath_or_buffer" in kwargs else "path" + filepath_or_buffer = kwargs.pop(path_key) + filepath_or_buffer = stringify_path(filepath_or_buffer) if not ( - isinstance(kwargs["filepath_or_buffer"], str) - and "*" in kwargs["filepath_or_buffer"] + isinstance(filepath_or_buffer, str) and "*" in filepath_or_buffer ) or not isinstance(qc, PandasQueryCompiler): warnings.warn("Defaulting to Modin core implementation") - cls.base_io.to_pickle(qc, **kwargs) + cls.base_write(qc, filepath_or_buffer, **kwargs) return + # Be careful, this is a kind of limitation, but at the time of the first implementation, + # getting a name in this way is quite convenient. + # We can use this attribute because the names of the BaseIO's methods match pandas API. + write_func_name = cls.base_write.__name__ + def func(df, **kw): # pragma: no cover idx = str(kw["partition_idx"]) - # dask doesn't make a copy of kwargs on serialization; - # so take a copy ourselves, otherwise the error is: - # kwargs["path"] = kwargs.pop("filepath_or_buffer").replace("*", idx) - # KeyError: 'filepath_or_buffer' - dask_kwargs = dict(kwargs) - dask_kwargs["path"] = dask_kwargs.pop("filepath_or_buffer").replace( - "*", idx - ) - df.to_pickle(**dask_kwargs) + path = filepath_or_buffer.replace("*", idx) + getattr(df, write_func_name)(path, **kwargs) return pandas.DataFrame() result = qc._modin_frame.apply_full_axis( diff --git a/modin/experimental/core/storage_formats/pandas/parsers.py b/modin/experimental/core/storage_formats/pandas/parsers.py index bf09fc99ebc..be2ec01489a 100644 --- a/modin/experimental/core/storage_formats/pandas/parsers.py +++ b/modin/experimental/core/storage_formats/pandas/parsers.py @@ -114,6 +114,24 @@ def parse(fname, **kwargs): return _split_result_for_readers(1, num_splits, df) + [length, width] +@doc(_doc_pandas_parser_class, data_type="parquet files") +class ExperimentalPandasParquetParser(PandasParser): + @staticmethod + @doc(_doc_parse_func, parameters=_doc_parse_parameters_common) + def parse(fname, **kwargs): + warnings.filterwarnings("ignore") + num_splits = 1 + single_worker_read = kwargs.pop("single_worker_read", None) + df = pandas.read_parquet(fname, **kwargs) + if single_worker_read: + return df + + length = len(df) + width = len(df.columns) + + return _split_result_for_readers(1, num_splits, df) + [length, width] + + @doc(_doc_pandas_parser_class, data_type="custom text") class ExperimentalCustomTextParser(PandasParser): @staticmethod diff --git a/modin/experimental/pandas/__init__.py b/modin/experimental/pandas/__init__.py index 7516563d41c..4c484fddb89 100644 --- a/modin/experimental/pandas/__init__.py +++ b/modin/experimental/pandas/__init__.py @@ -40,6 +40,7 @@ from .io import ( # noqa F401 read_csv_glob, read_custom_text, + read_parquet_glob, read_pickle_distributed, read_sql, to_pickle_distributed, diff --git a/modin/experimental/pandas/io.py b/modin/experimental/pandas/io.py index 4d5959366f2..92349b5f7b1 100644 --- a/modin/experimental/pandas/io.py +++ b/modin/experimental/pandas/io.py @@ -13,6 +13,8 @@ """Implement experimental I/O public API.""" +from __future__ import annotations + import inspect import pathlib import pickle @@ -352,7 +354,7 @@ def to_pickle_distributed( compression: CompressionOptions = "infer", protocol: int = pickle.HIGHEST_PROTOCOL, storage_options: StorageOptions = None, -): +) -> None: """ Pickle (serialize) object to file. @@ -361,7 +363,7 @@ def to_pickle_distributed( Parameters ---------- - filepath_or_buffer : str, path object or file-like object + filepath_or_buffer : str File path where the pickled object will be stored. compression : {{'infer', 'gzip', 'bz2', 'zip', 'xz', None}}, default: 'infer' A string representing the compression to use in the output file. By @@ -397,3 +399,87 @@ def to_pickle_distributed( protocol=protocol, storage_options=storage_options, ) + + +@expanduser_path_arg("path") +def read_parquet_glob( + path, + engine: str = "auto", + columns: list[str] | None = None, + storage_options: StorageOptions = None, + use_nullable_dtypes: bool = lib.no_default, + dtype_backend=lib.no_default, + filesystem=None, + filters=None, + **kwargs, +) -> DataFrame: # noqa: PR01 + """ + Load a parquet object from the file path, returning a DataFrame. + + This experimental feature provides parallel reading from multiple parquet files which are + defined by glob pattern. The files must contain parts of one dataframe, which can be + obtained, for example, by `DataFrame.modin.to_parquet_glob` function. + + Returns + ------- + DataFrame + + Notes + ----- + * Only string type supported for `path` argument. + * The rest of the arguments are the same as for `pandas.read_parquet`. + """ + from modin.core.execution.dispatching.factories.dispatcher import FactoryDispatcher + + return DataFrame( + query_compiler=FactoryDispatcher.read_parquet_glob( + path=path, + engine=engine, + columns=columns, + storage_options=storage_options, + use_nullable_dtypes=use_nullable_dtypes, + dtype_backend=dtype_backend, + filesystem=filesystem, + filters=filters, + **kwargs, + ) + ) + + +@expanduser_path_arg("path") +def to_parquet_glob( + self, + path, + engine="auto", + compression="snappy", + index=None, + partition_cols=None, + storage_options: StorageOptions = None, + **kwargs, +) -> None: # noqa: PR01 + """ + Write a DataFrame to the binary parquet format. + + This experimental feature provides parallel writing into multiple parquet files which are + defined by glob pattern, otherwise (without glob pattern) default pandas implementation is used. + + Notes + ----- + * Only string type supported for `path` argument. + * The rest of the arguments are the same as for `pandas.to_parquet`. + """ + obj = self + from modin.core.execution.dispatching.factories.dispatcher import FactoryDispatcher + + if isinstance(self, DataFrame): + obj = self._query_compiler + FactoryDispatcher.to_parquet_glob( + obj, + path=path, + engine=engine, + compression=compression, + index=index, + partition_cols=partition_cols, + storage_options=storage_options, + **kwargs, + ) diff --git a/modin/experimental/pandas/test/test_io_exp.py b/modin/experimental/pandas/test/test_io_exp.py index 1b9bccd7efd..28bd18fc615 100644 --- a/modin/experimental/pandas/test/test_io_exp.py +++ b/modin/experimental/pandas/test/test_io_exp.py @@ -272,6 +272,33 @@ def test_distributed_pickling(filename, compression, pathlike): teardown_test_files(pickle_files) +@pytest.mark.skipif( + Engine.get() not in ("Ray", "Unidist", "Dask"), + reason=f"{Engine.get()} does not have experimental API", +) +@pytest.mark.parametrize( + "filename", + ["test_parquet_glob.parquet", "test_parquet_glob*.parquet"], +) +def test_parquet_glob(filename): + data = test_data["int_data"] + df = pd.DataFrame(data) + + filename_param = filename + + with ( + warns_that_defaulting_to_pandas() + if filename_param == "test_parquet_glob.parquet" + else contextlib.nullcontext() + ): + df.modin.to_parquet_glob(filename) + read_df = pd.read_parquet_glob(filename) + df_equals(read_df, df) + + parquet_files = glob.glob(str(filename)) + teardown_test_files(parquet_files) + + @pytest.mark.skipif( Engine.get() not in ("Ray", "Unidist", "Dask"), reason=f"{Engine.get()} does not have experimental read_custom_text API", diff --git a/modin/pandas/accessor.py b/modin/pandas/accessor.py index 3d09424f160..83e43cff8f7 100644 --- a/modin/pandas/accessor.py +++ b/modin/pandas/accessor.py @@ -215,7 +215,7 @@ def to_pickle_distributed( compression: CompressionOptions = "infer", protocol: int = pickle.HIGHEST_PROTOCOL, storage_options: StorageOptions = None, - ): + ) -> None: """ Pickle (serialize) object to file. @@ -224,7 +224,7 @@ def to_pickle_distributed( Parameters ---------- - filepath_or_buffer : str, path object or file-like object + filepath_or_buffer : str File path where the pickled object will be stored. compression : {{'infer', 'gzip', 'bz2', 'zip', 'xz', None}}, default: 'infer' A string representing the compression to use in the output file. By @@ -257,3 +257,42 @@ def to_pickle_distributed( protocol=protocol, storage_options=storage_options, ) + + def to_parquet_glob( + self, + path, + engine="auto", + compression="snappy", + index=None, + partition_cols=None, + storage_options: StorageOptions = None, + **kwargs, + ) -> None: # noqa: PR01 + """ + Write a DataFrame to the binary parquet format. + + This experimental feature provides parallel writing into multiple parquet files which are + defined by glob pattern, otherwise (without glob pattern) default pandas implementation is used. + + Notes + ----- + * Only string type supported for `path` argument. + * The rest of the arguments are the same as for `pandas.to_parquet`. + """ + from modin.experimental.pandas.io import to_parquet_glob + + if path is None: + raise NotImplementedError( + "`to_parquet_glob` doesn't support path=None, use `to_parquet` in that case." + ) + + to_parquet_glob( + self._data, + path=path, + engine=engine, + compression=compression, + index=index, + partition_cols=partition_cols, + storage_options=storage_options, + **kwargs, + ) diff --git a/modin/pandas/dataframe.py b/modin/pandas/dataframe.py index 0ddc868305d..0c2a2e1539b 100644 --- a/modin/pandas/dataframe.py +++ b/modin/pandas/dataframe.py @@ -3187,4 +3187,4 @@ def __reduce__(self): # Persistance support methods - END # Namespace for experimental functions - modin = CachedAccessor("modin", ExperimentalFunctions) + modin: ExperimentalFunctions = CachedAccessor("modin", ExperimentalFunctions) From 43134efbee3b384db4344eb3c16bf21068a67fd2 Mon Sep 17 00:00:00 2001 From: Arun Jose <40291569+arunjose696@users.noreply.github.com> Date: Tue, 16 Jan 2024 12:45:39 +0100 Subject: [PATCH 137/201] REFACTOR-#6858: Rename _get_dimensions and change arguments (#6859) Signed-off-by: arunjose696 --- .../dataframe/pandas/dataframe/dataframe.py | 21 ++++++++++--------- .../pandas/partitioning/partition_manager.py | 2 +- .../execution/dask/common/engine_wrapper.py | 2 +- .../execution/python/common/engine_wrapper.py | 2 +- .../execution/ray/common/engine_wrapper.py | 2 +- .../pandas_on_ray/dataframe/dataframe.py | 13 ++++++++---- .../unidist/common/engine_wrapper.py | 2 +- 7 files changed, 25 insertions(+), 19 deletions(-) diff --git a/modin/core/dataframe/pandas/dataframe/dataframe.py b/modin/core/dataframe/pandas/dataframe/dataframe.py index e3b92afdf2c..7da33f52284 100644 --- a/modin/core/dataframe/pandas/dataframe/dataframe.py +++ b/modin/core/dataframe/pandas/dataframe/dataframe.py @@ -186,13 +186,13 @@ def row_lengths(self): if self._row_lengths_cache is None: if len(self._partitions.T) > 0: row_parts = self._partitions.T[0] - self._row_lengths_cache = self._get_dimensions(row_parts, "length") + self._row_lengths_cache = self._get_lengths(row_parts, Axis.ROW_WISE) else: self._row_lengths_cache = [] return self._row_lengths_cache @classmethod - def _get_dimensions(cls, parts, dim_name): + def _get_lengths(cls, parts, axis): """ Get list of dimensions for all the provided parts. @@ -200,14 +200,17 @@ def _get_dimensions(cls, parts, dim_name): ---------- parts : list List of parttions. - dim_name : string - Dimension name could be "length" or "width". + axis : {0, 1} + The axis along which to get the lengths (0 - length across rows or, 1 - width across columns). Returns ------- list """ - return [getattr(part, dim_name)() for part in parts] + if axis == Axis.ROW_WISE: + return [part.length() for part in parts] + else: + return [part.width() for part in parts] def __len__(self) -> int: """ @@ -236,7 +239,7 @@ def column_widths(self): if self._column_widths_cache is None: if len(self._partitions) > 0: col_parts = self._partitions[0] - self._column_widths_cache = self._get_dimensions(col_parts, "width") + self._column_widths_cache = self._get_lengths(col_parts, Axis.COL_WISE) else: self._column_widths_cache = [] return self._column_widths_cache @@ -3673,9 +3676,7 @@ def _compute_new_widths(): if all( part._length_cache is not None for part in new_partitions.T[0] ): - new_lengths = self._get_dimensions( - new_partitions.T[0], "length" - ) + new_lengths = self._get_lengths(new_partitions.T[0], axis) else: new_lengths = None else: @@ -3697,7 +3698,7 @@ def _compute_new_widths(): new_widths = [] if new_partitions.size > 0: if all(part._width_cache is not None for part in new_partitions[0]): - new_widths = self._get_dimensions(new_partitions[0], "width") + new_widths = self._get_lengths(new_partitions[0], axis) else: new_widths = None diff --git a/modin/core/dataframe/pandas/partitioning/partition_manager.py b/modin/core/dataframe/pandas/partitioning/partition_manager.py index 12a34187faf..3a1dd63e555 100644 --- a/modin/core/dataframe/pandas/partitioning/partition_manager.py +++ b/modin/core/dataframe/pandas/partitioning/partition_manager.py @@ -122,7 +122,7 @@ def materialize_futures(cls, input_list): filtered_list = [] filtered_idx = [] for idx, item in enumerate(input_list): - if cls._execution_wrapper.check_is_future(item): + if cls._execution_wrapper.is_future(item): filtered_idx.append(idx) filtered_list.append(item) filtered_list = cls._execution_wrapper.materialize(filtered_list) diff --git a/modin/core/execution/dask/common/engine_wrapper.py b/modin/core/execution/dask/common/engine_wrapper.py index fe21e1ea457..f35f7ae2714 100644 --- a/modin/core/execution/dask/common/engine_wrapper.py +++ b/modin/core/execution/dask/common/engine_wrapper.py @@ -92,7 +92,7 @@ def deploy( return remote_task_future @classmethod - def check_is_future(cls, item): + def is_future(cls, item): """ Check if the item is a Future. diff --git a/modin/core/execution/python/common/engine_wrapper.py b/modin/core/execution/python/common/engine_wrapper.py index 396793aa5db..fe7c49cd5fe 100644 --- a/modin/core/execution/python/common/engine_wrapper.py +++ b/modin/core/execution/python/common/engine_wrapper.py @@ -42,7 +42,7 @@ def deploy(cls, func, f_args=None, f_kwargs=None, num_returns=1): return func(*args, **kwargs) @classmethod - def check_is_future(cls, item): + def is_future(cls, item): """ Check if the item is a Future. diff --git a/modin/core/execution/ray/common/engine_wrapper.py b/modin/core/execution/ray/common/engine_wrapper.py index 3fc1f75a906..8e20033d20d 100644 --- a/modin/core/execution/ray/common/engine_wrapper.py +++ b/modin/core/execution/ray/common/engine_wrapper.py @@ -76,7 +76,7 @@ def deploy(cls, func, f_args=None, f_kwargs=None, num_returns=1): ) @classmethod - def check_is_future(cls, item): + def is_future(cls, item): """ Check if the item is a Future. diff --git a/modin/core/execution/ray/implementations/pandas_on_ray/dataframe/dataframe.py b/modin/core/execution/ray/implementations/pandas_on_ray/dataframe/dataframe.py index 73b216c62c4..6838fd9edca 100644 --- a/modin/core/execution/ray/implementations/pandas_on_ray/dataframe/dataframe.py +++ b/modin/core/execution/ray/implementations/pandas_on_ray/dataframe/dataframe.py @@ -13,6 +13,7 @@ """Module houses class that implements ``PandasDataframe`` using Ray.""" +from modin.core.dataframe.base.dataframe.utils import Axis from modin.core.dataframe.pandas.dataframe.dataframe import PandasDataframe from ..partitioning.partition_manager import PandasOnRayDataframePartitionManager @@ -42,7 +43,7 @@ class PandasOnRayDataframe(PandasDataframe): _partition_mgr_cls = PandasOnRayDataframePartitionManager - def _get_dimensions(self, parts, dim_name): + def _get_lengths(self, parts, axis): """ Get list of dimensions for all the provided parts. @@ -50,12 +51,16 @@ def _get_dimensions(self, parts, dim_name): ---------- parts : list List of parttions. - dim_name : string - Dimension name could be "length" or "width". + axis : {0, 1} + The axis along which to get the lengths (0 - length across rows or, 1 - width across columns). Returns ------- list """ - dims = [getattr(part, dim_name)(False) for part in parts] + if axis == Axis.ROW_WISE: + dims = [part.length(False) for part in parts] + else: + dims = [part.width(False) for part in parts] + return self._partition_mgr_cls.materialize_futures(dims) diff --git a/modin/core/execution/unidist/common/engine_wrapper.py b/modin/core/execution/unidist/common/engine_wrapper.py index f28115f133f..08937cf30a6 100644 --- a/modin/core/execution/unidist/common/engine_wrapper.py +++ b/modin/core/execution/unidist/common/engine_wrapper.py @@ -75,7 +75,7 @@ def deploy(cls, func, f_args=None, f_kwargs=None, num_returns=1): ) @classmethod - def check_is_future(cls, item): + def is_future(cls, item): """ Check if the item is a Future. From 783a6ee421dfa42ad793f9a77167815081ec1b23 Mon Sep 17 00:00:00 2001 From: Iaroslav Igoshev Date: Wed, 17 Jan 2024 19:07:35 +0100 Subject: [PATCH 138/201] DOCS-#6860: Add an ecosystem page to the docs (#6861) Signed-off-by: Igoshev, Iaroslav --- docs/ecosystem.rst | 49 ++++++++++++++++++++++++++++++++++++++++++++++ docs/index.rst | 1 + 2 files changed, 50 insertions(+) create mode 100644 docs/ecosystem.rst diff --git a/docs/ecosystem.rst b/docs/ecosystem.rst new file mode 100644 index 00000000000..73b878dd16c --- /dev/null +++ b/docs/ecosystem.rst @@ -0,0 +1,49 @@ +Ecosystem +========= + +There is a constantly growing number of users and packages using pandas +to address their specific needs in data preparation, analysis and visualization. +pandas is being used ubiquitously and is a good choise to handle small-sized data. +However, pandas scales poorly and is non-interactive on moderate to large datasets. +Modin provides a drop-in replacement API for pandas and scales computation across nodes and +CPUs available. What you need to do to switch to Modin is just replace a single line of code. + +.. code-block:: python + + # import pandas as pd + import modin.pandas as pd + +While most packages can consume a pandas DataFrame and operate it efficiently, +this is not the case with a Modin DataFrame due to its distributed nature. +Thus, some packages may lack support for handling Modin DataFrame(s) correctly and, +moreover, efficiently. Modin implements such methods as ``__array__``, ``__dataframe__``, etc. +to facilitate other libraries to consume a Modin DataFrame. If you feel that a certain library +can operate efficiently with a specific format of data, it is possible to convert a Modin DataFrame +to the format preferred. + +to_pandas +--------- + +You can refer to `pandas ecosystem`_ page to get more details on +where pandas can be used and what libraries it powers. + +.. code-block:: python + + from modin.pandas.io import to_pandas + + pandas_df = to_pandas(modin_df) + +to_numpy +-------- + +You can refer to `NumPy ecosystem`_ section of NumPy documentation to get more details on +where NumPy can be used and what libraries it powers. + +.. code-block:: python + + from modin.pandas.io import to_numpy + + numpy_arr = to_numpy(modin_df) + +.. _pandas ecosystem: https://pandas.pydata.org/community/ecosystem.html +.. _NumPy ecosystem: https://numpy.org diff --git a/docs/index.rst b/docs/index.rst index ff5d56e476d..24422e7e194 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -12,6 +12,7 @@ usage_guide/index supported_apis/index development/index + ecosystem contact .. raw:: html From 602f8664e480def8edac7ad45aa9d68e1b3ad7c8 Mon Sep 17 00:00:00 2001 From: Anatoly Myachev Date: Wed, 17 Jan 2024 20:42:23 +0100 Subject: [PATCH 139/201] TEST-#6708: Create test files using `tmp_path` fixture (#6709) Co-authored-by: Iaroslav Igoshev Signed-off-by: Anatoly Myachev --- modin/conftest.py | 62 +-- modin/experimental/pandas/test/test_io_exp.py | 39 +- modin/pandas/test/test_io.py | 393 ++++++++---------- modin/pandas/test/utils.py | 45 +- 4 files changed, 222 insertions(+), 317 deletions(-) diff --git a/modin/conftest.py b/modin/conftest.py index 90bef731a80..3f7b394ea59 100644 --- a/modin/conftest.py +++ b/modin/conftest.py @@ -78,7 +78,6 @@ def _saving_make_api_url(token, _make_api_url=modin.utils._make_api_url): _make_csv_file, get_unique_filename, make_default_file, - teardown_test_files, ) @@ -295,65 +294,44 @@ def pytest_runtest_call(item): @pytest.fixture(scope="class") -def TestReadCSVFixture(): - filenames = [] - files_ids = [ - "test_read_csv_regular", - "test_read_csv_blank_lines", - "test_read_csv_yes_no", - "test_read_csv_nans", - "test_read_csv_bad_lines", - ] +def TestReadCSVFixture(tmp_path_factory): + tmp_path = tmp_path_factory.mktemp("TestReadCSVFixture") + + creator = _make_csv_file(data_dir=tmp_path) # each xdist worker spawned in separate process with separate namespace and dataset - pytest.csvs_names = {file_id: get_unique_filename() for file_id in files_ids} + pytest.csvs_names = {} # test_read_csv_col_handling, test_read_csv_parsing - _make_csv_file(filenames)( - filename=pytest.csvs_names["test_read_csv_regular"], - ) + pytest.csvs_names["test_read_csv_regular"] = creator() # test_read_csv_parsing - _make_csv_file(filenames)( - filename=pytest.csvs_names["test_read_csv_yes_no"], + pytest.csvs_names["test_read_csv_yes_no"] = creator( additional_col_values=["Yes", "true", "No", "false"], ) # test_read_csv_col_handling - _make_csv_file(filenames)( - filename=pytest.csvs_names["test_read_csv_blank_lines"], + pytest.csvs_names["test_read_csv_blank_lines"] = creator( add_blank_lines=True, ) # test_read_csv_nans_handling - _make_csv_file(filenames)( - filename=pytest.csvs_names["test_read_csv_nans"], + pytest.csvs_names["test_read_csv_nans"] = creator( add_blank_lines=True, additional_col_values=["", "N/A", "NA", "NULL", "custom_nan", "73"], ) # test_read_csv_error_handling - _make_csv_file(filenames)( - filename=pytest.csvs_names["test_read_csv_bad_lines"], + pytest.csvs_names["test_read_csv_bad_lines"] = creator( add_bad_lines=True, ) - yield - # Delete csv files that were created - teardown_test_files(filenames) @pytest.fixture @doc(_doc_pytest_fixture, file_type="csv") -def make_csv_file(): - filenames = [] - - yield _make_csv_file(filenames) - - # Delete csv files that were created - teardown_test_files(filenames) +def make_csv_file(tmp_path): + yield _make_csv_file(data_dir=tmp_path) def create_fixture(file_type): @doc(_doc_pytest_fixture, file_type=file_type) - def fixture(): - func, filenames = make_default_file(file_type=file_type) - yield func - teardown_test_files(filenames) + def fixture(tmp_path): + yield make_default_file(file_type=file_type, data_dir=tmp_path) return fixture @@ -465,20 +443,18 @@ def _sql_connection(filename, table=""): @pytest.fixture(scope="class") -def TestReadGlobCSVFixture(): - filenames = [] +def TestReadGlobCSVFixture(tmp_path_factory): + tmp_path = tmp_path_factory.mktemp("TestReadGlobCSVFixture") base_name = get_unique_filename(extension="") - pytest.glob_path = "{}_*.csv".format(base_name) - pytest.files = ["{}_{}.csv".format(base_name, i) for i in range(11)] + pytest.glob_path = str(tmp_path / "{}_*.csv".format(base_name)) + pytest.files = [str(tmp_path / "{}_{}.csv".format(base_name, i)) for i in range(11)] for fname in pytest.files: # Glob does not guarantee ordering so we have to remove the randomness in the generated csvs. - _make_csv_file(filenames)(fname, row_size=11, remove_randomness=True) + _make_csv_file(data_dir=tmp_path)(fname, row_size=11, remove_randomness=True) yield - teardown_test_files(filenames) - @pytest.fixture def get_generated_doc_urls(): diff --git a/modin/experimental/pandas/test/test_io_exp.py b/modin/experimental/pandas/test/test_io_exp.py index 28bd18fc615..ff3d79d9e5b 100644 --- a/modin/experimental/pandas/test/test_io_exp.py +++ b/modin/experimental/pandas/test/test_io_exp.py @@ -12,7 +12,6 @@ # governing permissions and limitations under the License. import contextlib -import glob import json from pathlib import Path @@ -27,7 +26,6 @@ df_equals, eval_general, parse_dates_values_by_id, - teardown_test_files, test_data, time_parsing_csv_path, ) @@ -42,7 +40,7 @@ def test_from_sql_distributed(tmp_path, make_sql_connection): filename = "test_from_sql_distributed.db" table = "test_from_sql_distributed" - conn = make_sql_connection(tmp_path / filename, table) + conn = make_sql_connection(str(tmp_path / filename), table) query = "select * from {0}".format(table) pandas_df = pandas.read_sql(query, conn) @@ -74,7 +72,7 @@ def test_from_sql_distributed(tmp_path, make_sql_connection): def test_from_sql_defaults(tmp_path, make_sql_connection): filename = "test_from_sql_distributed.db" table = "test_from_sql_distributed" - conn = make_sql_connection(tmp_path / filename, table) + conn = make_sql_connection(str(tmp_path / filename), table) query = "select * from {0}".format(table) pandas_df = pandas.read_sql(query, conn) @@ -135,8 +133,8 @@ def test_read_csv_without_glob(self): storage_options={"anon": True}, ) - def test_read_csv_glob_4373(self): - columns, filename = ["col0"], "1x1.csv" + def test_read_csv_glob_4373(self, tmp_path): + columns, filename = ["col0"], str(tmp_path / "1x1.csv") df = pd.DataFrame([[1]], columns=columns) with ( warns_that_defaulting_to_pandas() @@ -204,9 +202,6 @@ def _pandas_read_csv_glob(path, storage_options): ) -test_default_to_pickle_filename = "test_default_to_pickle.pkl" - - @pytest.mark.skipif( Engine.get() not in ("Ray", "Unidist", "Dask"), reason=f"{Engine.get()} does not have experimental API", @@ -247,9 +242,9 @@ def _pandas_read_csv_glob(path, storage_options): @pytest.mark.parametrize("pathlike", [False, True]) @pytest.mark.parametrize("compression", [None, "gzip"]) @pytest.mark.parametrize( - "filename", [test_default_to_pickle_filename, "test_to_pickle*.pkl"] + "filename", ["test_default_to_pickle.pkl", "test_to_pickle*.pkl"] ) -def test_distributed_pickling(filename, compression, pathlike): +def test_distributed_pickling(tmp_path, filename, compression, pathlike): data = test_data["int_data"] df = pd.DataFrame(data) @@ -261,16 +256,17 @@ def test_distributed_pickling(filename, compression, pathlike): with ( warns_that_defaulting_to_pandas() - if filename_param == test_default_to_pickle_filename + if filename_param == "test_default_to_pickle.pkl" else contextlib.nullcontext() ): - df.modin.to_pickle_distributed(filename, compression=compression) - pickled_df = pd.read_pickle_distributed(filename, compression=compression) + df.modin.to_pickle_distributed( + str(tmp_path / filename), compression=compression + ) + pickled_df = pd.read_pickle_distributed( + str(tmp_path / filename), compression=compression + ) df_equals(pickled_df, df) - pickle_files = glob.glob(str(filename)) - teardown_test_files(pickle_files) - @pytest.mark.skipif( Engine.get() not in ("Ray", "Unidist", "Dask"), @@ -280,7 +276,7 @@ def test_distributed_pickling(filename, compression, pathlike): "filename", ["test_parquet_glob.parquet", "test_parquet_glob*.parquet"], ) -def test_parquet_glob(filename): +def test_parquet_glob(tmp_path, filename): data = test_data["int_data"] df = pd.DataFrame(data) @@ -291,13 +287,10 @@ def test_parquet_glob(filename): if filename_param == "test_parquet_glob.parquet" else contextlib.nullcontext() ): - df.modin.to_parquet_glob(filename) - read_df = pd.read_parquet_glob(filename) + df.modin.to_parquet_glob(str(tmp_path / filename)) + read_df = pd.read_parquet_glob(str(tmp_path / filename)) df_equals(read_df, df) - parquet_files = glob.glob(str(filename)) - teardown_test_files(parquet_files) - @pytest.mark.skipif( Engine.get() not in ("Ray", "Unidist", "Dask"), diff --git a/modin/pandas/test/test_io.py b/modin/pandas/test/test_io.py index 14ad75ee463..2f60079a122 100644 --- a/modin/pandas/test/test_io.py +++ b/modin/pandas/test/test_io.py @@ -51,7 +51,6 @@ from modin.test.test_utils import warns_that_defaulting_to_pandas from .utils import ( - COMP_TO_EXT, check_file_leaks, create_test_dfs, default_to_pandas_ignore_string, @@ -267,42 +266,38 @@ class TestCsv: def test_read_csv_delimiters( self, make_csv_file, sep, delimiter, decimal, thousands ): - with ensure_clean(".csv") as unique_filename: - make_csv_file( - filename=unique_filename, - delimiter=delimiter, - thousands_separator=thousands, - decimal_separator=decimal, - ) - - eval_io( - fn_name="read_csv", - # read_csv kwargs - filepath_or_buffer=unique_filename, - delimiter=delimiter, - sep=sep, - decimal=decimal, - thousands=thousands, - ) + unique_filename = make_csv_file( + delimiter=delimiter, + thousands_separator=thousands, + decimal_separator=decimal, + ) + eval_io( + fn_name="read_csv", + # read_csv kwargs + filepath_or_buffer=unique_filename, + delimiter=delimiter, + sep=sep, + decimal=decimal, + thousands=thousands, + ) @pytest.mark.parametrize( "dtype_backend", [lib.no_default, "numpy_nullable", "pyarrow"] ) def test_read_csv_dtype_backend(self, make_csv_file, dtype_backend): - with ensure_clean(".csv") as unique_filename: - make_csv_file(filename=unique_filename) + unique_filename = make_csv_file() - def comparator(df1, df2): - df_equals(df1, df2) - df_equals(df1.dtypes, df2.dtypes) + def comparator(df1, df2): + df_equals(df1, df2) + df_equals(df1.dtypes, df2.dtypes) - eval_io( - fn_name="read_csv", - # read_csv kwargs - filepath_or_buffer=unique_filename, - dtype_backend=dtype_backend, - comparator=comparator, - ) + eval_io( + fn_name="read_csv", + # read_csv kwargs + filepath_or_buffer=unique_filename, + dtype_backend=dtype_backend, + comparator=comparator, + ) # Column and Index Locations and Names tests @pytest.mark.parametrize("header", ["infer", None, 0]) @@ -420,38 +415,32 @@ def test_read_csv_parsing_2( names, encoding, ): - with ensure_clean(".csv") as unique_filename: - if encoding: - make_csv_file( - filename=unique_filename, - encoding=encoding, - ) - kwargs = { - "filepath_or_buffer": unique_filename - if encoding - else pytest.csvs_names["test_read_csv_regular"], - "header": header, - "skiprows": skiprows, - "nrows": nrows, - "names": names, - "encoding": encoding, - } + if encoding: + unique_filename = make_csv_file(encoding=encoding) + else: + unique_filename = pytest.csvs_names["test_read_csv_regular"] + kwargs = { + "filepath_or_buffer": unique_filename, + "header": header, + "skiprows": skiprows, + "nrows": nrows, + "names": names, + "encoding": encoding, + } - if Engine.get() != "Python": - df = pandas.read_csv(**dict(kwargs, nrows=1)) - # in that case first partition will contain str - if df[df.columns[0]][df.index[0]] in ["c1", "col1", "c3", "col3"]: - pytest.xfail( - "read_csv incorrect output with float data - issue #2634" - ) + if Engine.get() != "Python": + df = pandas.read_csv(**dict(kwargs, nrows=1)) + # in that case first partition will contain str + if df[df.columns[0]][df.index[0]] in ["c1", "col1", "c3", "col3"]: + pytest.xfail("read_csv incorrect output with float data - issue #2634") - eval_io( - fn_name="read_csv", - raising_exceptions=None, - check_kwargs_callable=not callable(skiprows), - # read_csv kwargs - **kwargs, - ) + eval_io( + fn_name="read_csv", + raising_exceptions=None, + check_kwargs_callable=not callable(skiprows), + # read_csv kwargs + **kwargs, + ) @pytest.mark.parametrize("true_values", [["Yes"], ["Yes", "true"], None]) @pytest.mark.parametrize("false_values", [["No"], ["No", "false"], None]) @@ -642,24 +631,16 @@ def test_read_csv_encoding_976(self, pathlike): @pytest.mark.parametrize("encoding", [None, "latin8", "utf16"]) @pytest.mark.parametrize("engine", [None, "python", "c"]) def test_read_csv_compression(self, make_csv_file, compression, encoding, engine): - with ensure_clean(".csv") as unique_filename: - make_csv_file( - filename=unique_filename, encoding=encoding, compression=compression - ) - compressed_file_path = ( - f"{unique_filename}.{COMP_TO_EXT[compression]}" - if compression != "infer" - else unique_filename - ) + unique_filename = make_csv_file(encoding=encoding, compression=compression) - eval_io( - fn_name="read_csv", - # read_csv kwargs - filepath_or_buffer=compressed_file_path, - compression=compression, - encoding=encoding, - engine=engine, - ) + eval_io( + fn_name="read_csv", + # read_csv kwargs + filepath_or_buffer=unique_filename, + compression=compression, + encoding=encoding, + engine=engine, + ) @pytest.mark.parametrize( "encoding", @@ -687,15 +668,13 @@ def test_read_csv_compression(self, make_csv_file, compression, encoding, engine ], ) def test_read_csv_encoding(self, make_csv_file, encoding): - with ensure_clean(".csv") as unique_filename: - make_csv_file(filename=unique_filename, encoding=encoding) - - eval_io( - fn_name="read_csv", - # read_csv kwargs - filepath_or_buffer=unique_filename, - encoding=encoding, - ) + unique_filename = make_csv_file(encoding=encoding) + eval_io( + fn_name="read_csv", + # read_csv kwargs + filepath_or_buffer=unique_filename, + encoding=encoding, + ) @pytest.mark.parametrize("thousands", [None, ",", "_", " "]) @pytest.mark.parametrize("decimal", [".", "_"]) @@ -711,56 +690,50 @@ def test_read_csv_file_format( escapechar, dialect, ): - with ensure_clean(".csv") as unique_filename: - if dialect: - test_csv_dialect_params = { - "delimiter": "_", - "doublequote": False, - "escapechar": "\\", - "quotechar": "d", - "quoting": csv.QUOTE_ALL, - } - csv.register_dialect(dialect, **test_csv_dialect_params) - if dialect != "use_dialect_name": - # otherwise try with dialect name instead of `_csv.Dialect` object - dialect = csv.get_dialect(dialect) - make_csv_file(filename=unique_filename, **test_csv_dialect_params) - else: - make_csv_file( - filename=unique_filename, - thousands_separator=thousands, - decimal_separator=decimal, - escapechar=escapechar, - lineterminator=lineterminator, - ) - - if ( - (StorageFormat.get() == "Hdk") - and (escapechar is not None) - and (lineterminator is None) - and (thousands is None) - and (decimal == ".") - ): - with open(unique_filename, "r") as f: - if any( - line.find(f',"{escapechar}') != -1 for _, line in enumerate(f) - ): - pytest.xfail( - "Tests with this character sequence fail due to #5649" - ) - - eval_io( - raising_exceptions=None, - fn_name="read_csv", - # read_csv kwargs - filepath_or_buffer=unique_filename, - thousands=thousands, - decimal=decimal, - lineterminator=lineterminator, + if dialect: + test_csv_dialect_params = { + "delimiter": "_", + "doublequote": False, + "escapechar": "\\", + "quotechar": "d", + "quoting": csv.QUOTE_ALL, + } + csv.register_dialect(dialect, **test_csv_dialect_params) + if dialect != "use_dialect_name": + # otherwise try with dialect name instead of `_csv.Dialect` object + dialect = csv.get_dialect(dialect) + unique_filename = make_csv_file(**test_csv_dialect_params) + else: + unique_filename = make_csv_file( + thousands_separator=thousands, + decimal_separator=decimal, escapechar=escapechar, - dialect=dialect, + lineterminator=lineterminator, ) + if ( + (StorageFormat.get() == "Hdk") + and (escapechar is not None) + and (lineterminator is None) + and (thousands is None) + and (decimal == ".") + ): + with open(unique_filename, "r") as f: + if any(line.find(f',"{escapechar}') != -1 for _, line in enumerate(f)): + pytest.xfail("Tests with this character sequence fail due to #5649") + + eval_io( + raising_exceptions=None, + fn_name="read_csv", + # read_csv kwargs + filepath_or_buffer=unique_filename, + thousands=thousands, + decimal=decimal, + lineterminator=lineterminator, + escapechar=escapechar, + dialect=dialect, + ) + @pytest.mark.parametrize( "quoting", [csv.QUOTE_ALL, csv.QUOTE_MINIMAL, csv.QUOTE_NONNUMERIC, csv.QUOTE_NONE], @@ -782,26 +755,24 @@ def test_read_csv_quoting( not doublequote and quotechar != '"' and quoting != csv.QUOTE_NONE ) escapechar = "\\" if use_escapechar else None - with ensure_clean(".csv") as unique_filename: - make_csv_file( - filename=unique_filename, - quoting=quoting, - quotechar=quotechar, - doublequote=doublequote, - escapechar=escapechar, - comment_col_char=comment, - ) + unique_filename = make_csv_file( + quoting=quoting, + quotechar=quotechar, + doublequote=doublequote, + escapechar=escapechar, + comment_col_char=comment, + ) - eval_io( - fn_name="read_csv", - # read_csv kwargs - filepath_or_buffer=unique_filename, - quoting=quoting, - quotechar=quotechar, - doublequote=doublequote, - escapechar=escapechar, - comment=comment, - ) + eval_io( + fn_name="read_csv", + # read_csv kwargs + filepath_or_buffer=unique_filename, + quoting=quoting, + quotechar=quotechar, + doublequote=doublequote, + escapechar=escapechar, + comment=comment, + ) # Error Handling parameters tests @pytest.mark.skip(reason="https://github.com/modin-project/modin/issues/6239") @@ -841,6 +812,7 @@ def test_read_csv_internal( low_memory, memory_map, float_precision, + tmp_path, ): # In this case raised TypeError: cannot use a string pattern on a bytes-like object, # so TypeError should be excluded from raising_exceptions list in order to check, that @@ -868,29 +840,28 @@ def test_read_csv_internal( "float_precision": float_precision, } - with ensure_clean(".csv") as unique_filename: - if use_str_data: - str_delim_whitespaces = ( - "col1 col2 col3 col4\n5 6 7 8\n9 10 11 12\n" - ) - eval_io_from_str( - str_delim_whitespaces, - unique_filename, - raising_exceptions=raising_exceptions, - **kwargs, - ) - else: - make_csv_file( - filename=unique_filename, - delimiter=delimiter, - ) - - eval_io( - filepath_or_buffer=unique_filename, - fn_name="read_csv", - raising_exceptions=raising_exceptions, - **kwargs, - ) + if use_str_data: + str_delim_whitespaces = ( + "col1 col2 col3 col4\n5 6 7 8\n9 10 11 12\n" + ) + unique_filename = get_unique_filename(data_dir=tmp_path) + eval_io_from_str( + str_delim_whitespaces, + unique_filename, + raising_exceptions=raising_exceptions, + **kwargs, + ) + else: + unique_filename = make_csv_file( + delimiter=delimiter, + ) + + eval_io( + filepath_or_buffer=unique_filename, + fn_name="read_csv", + raising_exceptions=raising_exceptions, + **kwargs, + ) # Issue related, specific or corner cases @pytest.mark.parametrize("nrows", [2, None]) @@ -1206,21 +1177,13 @@ def wrapped_read_csv(file, method): def test_read_csv_file_handle( self, read_mode, make_csv_file, buffer_start_pos, set_async_read_mode ): - with ensure_clean() as unique_filename: - make_csv_file(filename=unique_filename) - - with open(unique_filename, mode=read_mode) as buffer: - buffer.seek(buffer_start_pos) - pandas_df = pandas.read_csv(buffer) - buffer.seek(buffer_start_pos) - modin_df = pd.read_csv(buffer) - if AsyncReadMode.get(): - # If read operations are asynchronous, then the dataframes - # check should be inside `ensure_clean` context - # because the file may be deleted before actual reading starts - df_equals(modin_df, pandas_df) - if not AsyncReadMode.get(): - df_equals(modin_df, pandas_df) + unique_filename = make_csv_file() + with open(unique_filename, mode=read_mode) as buffer: + buffer.seek(buffer_start_pos) + pandas_df = pandas.read_csv(buffer) + buffer.seek(buffer_start_pos) + modin_df = pd.read_csv(buffer) + df_equals(modin_df, pandas_df) def test_unnamed_index(self): def get_internal_df(df): @@ -1344,21 +1307,19 @@ def _check_relative_io(fn_name, unique_filename, path_arg, storage_default=()): # TODO(https://github.com/modin-project/modin/issues/3655): Get rid of this # commment once we turn all default to pandas messages into errors. def test_read_csv_relative_to_user_home(make_csv_file): - with ensure_clean(".csv") as unique_filename: - make_csv_file(filename=unique_filename) - _check_relative_io("read_csv", unique_filename, "filepath_or_buffer") + unique_filename = make_csv_file() + _check_relative_io("read_csv", unique_filename, "filepath_or_buffer") @pytest.mark.filterwarnings(default_to_pandas_ignore_string) class TestTable: def test_read_table(self, make_csv_file): - with ensure_clean() as unique_filename: - make_csv_file(filename=unique_filename, delimiter="\t") - eval_io( - fn_name="read_table", - # read_table kwargs - filepath_or_buffer=unique_filename, - ) + unique_filename = make_csv_file(delimiter="\t") + eval_io( + fn_name="read_table", + # read_table kwargs + filepath_or_buffer=unique_filename, + ) @pytest.mark.parametrize("set_async_read_mode", [False, True], indirect=True) def test_read_table_within_decorator(self, make_csv_file, set_async_read_mode): @@ -1370,34 +1331,26 @@ def wrapped_read_table(file, method): if method == "modin": return pd.read_table(file) - with ensure_clean() as unique_filename: - make_csv_file(filename=unique_filename, delimiter="\t") + unique_filename = make_csv_file(delimiter="\t") - pandas_df = wrapped_read_table(unique_filename, method="pandas") - modin_df = wrapped_read_table(unique_filename, method="modin") + pandas_df = wrapped_read_table(unique_filename, method="pandas") + modin_df = wrapped_read_table(unique_filename, method="modin") if StorageFormat.get() == "Hdk": modin_df, pandas_df = align_datetime_dtypes(modin_df, pandas_df) - if AsyncReadMode.get(): - # If read operations are asynchronous, then the dataframes - # check should be inside `ensure_clean` context - # because the file may be deleted before actual reading starts - df_equals(modin_df, pandas_df) - if not AsyncReadMode.get(): - df_equals(modin_df, pandas_df) + df_equals(modin_df, pandas_df) def test_read_table_empty_frame(self, make_csv_file): - with ensure_clean() as unique_filename: - make_csv_file(filename=unique_filename, delimiter="\t") + unique_filename = make_csv_file(delimiter="\t") - eval_io( - fn_name="read_table", - # read_table kwargs - filepath_or_buffer=unique_filename, - usecols=["col1"], - index_col="col1", - ) + eval_io( + fn_name="read_table", + # read_table kwargs + filepath_or_buffer=unique_filename, + usecols=["col1"], + index_col="col1", + ) @pytest.mark.parametrize("engine", ["pyarrow", "fastparquet"]) diff --git a/modin/pandas/test/utils.py b/modin/pandas/test/utils.py index 279157c9c56..1b448ee491c 100644 --- a/modin/pandas/test/utils.py +++ b/modin/pandas/test/utils.py @@ -1340,9 +1340,9 @@ def generate_dataframe(row_size=NROWS, additional_col_values=None, idx_name=None return pandas.DataFrame(data, index=index) -def _make_csv_file(filenames): +def _make_csv_file(data_dir): def _csv_file_maker( - filename, + filename=None, row_size=NROWS, force=True, delimiter=",", @@ -1362,8 +1362,10 @@ def _csv_file_maker( escapechar=None, lineterminator=None, ): + if filename is None: + filename = get_unique_filename(data_dir=data_dir) if os.path.exists(filename) and not force: - pass + return None else: df = generate_dataframe(row_size, additional_col_values) if remove_randomness: @@ -1433,26 +1435,11 @@ def _csv_file_maker( encoding=encoding, **csv_reader_writer_params, ) - filenames.append(filename) - return df + return filename return _csv_file_maker -def teardown_test_file(test_path): - if os.path.exists(test_path): - # PermissionError can occure because of issue #2533 - try: - os.remove(test_path) - except PermissionError: - pass - - -def teardown_test_files(test_paths: list): - for path in test_paths: - teardown_test_file(path) - - def sort_index_for_equal_values(df, ascending=True): """Sort `df` indices of equal rows.""" if df.index.dtype == np.float64: @@ -1490,11 +1477,10 @@ def rotate_decimal_digits_or_symbols(value): return tens + ones * 10 -def make_default_file(file_type: str): +def make_default_file(file_type: str, data_dir: str): """Helper function for pytest fixtures.""" - filenames = [] - def _create_file(filenames, filename, force, nrows, ncols, func: str, func_kw=None): + def _create_file(filename, force, nrows, ncols, func: str, func_kw=None): """ Helper function that creates a dataframe before writing it to a file. @@ -1511,7 +1497,6 @@ def _create_file(filenames, filename, force, nrows, ncols, func: str, func_kw=No {f"col{x + 1}": np.arange(nrows) for x in range(ncols)} ) getattr(df, func)(filename, **func_kw if func_kw else {}) - filenames.append(filename) file_type_to_extension = { "excel": "xlsx", @@ -1520,19 +1505,18 @@ def _create_file(filenames, filename, force, nrows, ncols, func: str, func_kw=No } extension = file_type_to_extension.get(file_type, file_type) - def _make_default_file(filename=None, nrows=NROWS, ncols=2, force=True, **kwargs): - if filename is None: - filename = get_unique_filename(extension=extension) + def _make_default_file(nrows=NROWS, ncols=2, force=True, **kwargs): + filename = get_unique_filename(extension=extension, data_dir=data_dir) if file_type == "json": lines = kwargs.get("lines") func_kw = {"lines": lines, "orient": "records"} if lines else {} - _create_file(filenames, filename, force, nrows, ncols, "to_json", func_kw) + _create_file(filename, force, nrows, ncols, "to_json", func_kw) elif file_type in ("html", "excel", "feather", "stata", "pickle"): - _create_file(filenames, filename, force, nrows, ncols, f"to_{file_type}") + _create_file(filename, force, nrows, ncols, f"to_{file_type}") elif file_type == "hdf": func_kw = {"key": "df", "format": kwargs.get("format")} - _create_file(filenames, filename, force, nrows, ncols, "to_hdf", func_kw) + _create_file(filename, force, nrows, ncols, "to_hdf", func_kw) elif file_type == "fwf": if force or not os.path.exists(filename): fwf_data = kwargs.get("fwf_data") @@ -1541,12 +1525,11 @@ def _make_default_file(filename=None, nrows=NROWS, ncols=2, force=True, **kwargs fwf_data = fwf_file.read() with open(filename, "w") as f: f.write(fwf_data) - filenames.append(filename) else: raise ValueError(f"Unsupported file type: {file_type}") return filename - return _make_default_file, filenames + return _make_default_file def value_equals(obj1, obj2): From 4f91d24230b4f05b6b7a3ba0796e6ecfaef6ccf4 Mon Sep 17 00:00:00 2001 From: Anatoly Myachev Date: Thu, 18 Jan 2024 20:42:58 +0100 Subject: [PATCH 140/201] TEST-#6830: Use local s3 server instead of public s3 buckets (#6863) Signed-off-by: Anatoly Myachev --- modin/conftest.py | 18 ++++- .../io/column_stores/feather_dispatcher.py | 6 +- modin/core/io/text/json_dispatcher.py | 17 ++++- .../core/io/text/csv_glob_dispatcher.py | 18 +++-- modin/experimental/pandas/test/test_io_exp.py | 28 +++++--- ...44c5b23d806c4dc8a97d70c4fb2219f5-0.parquet | Bin 0 -> 1893 bytes ...44c5b23d806c4dc8a97d70c4fb2219f5-0.parquet | Bin 0 -> 1893 bytes .../test/data/multiple_csv/test_data0.csv | 5 ++ .../test/data/multiple_csv/test_data1.csv | 5 ++ modin/pandas/test/data/test_data.feather | Bin 0 -> 2466 bytes modin/pandas/test/data/test_data.json | 6 ++ modin/pandas/test/data/test_data.parquet | Bin 0 -> 3720 bytes .../data/test_data_dir.parquet/part_0.parquet | Bin 0 -> 98851 bytes .../data/test_data_dir.parquet/part_1.parquet | Bin 0 -> 98951 bytes .../test_data_dir.parquet/part_10.parquet | Bin 0 -> 98802 bytes .../test_data_dir.parquet/part_11.parquet | Bin 0 -> 98877 bytes .../test_data_dir.parquet/part_12.parquet | Bin 0 -> 98935 bytes .../test_data_dir.parquet/part_13.parquet | Bin 0 -> 98922 bytes .../test_data_dir.parquet/part_14.parquet | Bin 0 -> 98839 bytes .../test_data_dir.parquet/part_15.parquet | Bin 0 -> 98983 bytes .../data/test_data_dir.parquet/part_2.parquet | Bin 0 -> 98818 bytes .../data/test_data_dir.parquet/part_3.parquet | Bin 0 -> 98998 bytes .../data/test_data_dir.parquet/part_4.parquet | Bin 0 -> 98963 bytes .../data/test_data_dir.parquet/part_5.parquet | Bin 0 -> 99045 bytes .../data/test_data_dir.parquet/part_6.parquet | Bin 0 -> 98893 bytes .../data/test_data_dir.parquet/part_7.parquet | Bin 0 -> 98945 bytes .../data/test_data_dir.parquet/part_8.parquet | Bin 0 -> 98855 bytes .../data/test_data_dir.parquet/part_9.parquet | Bin 0 -> 98949 bytes modin/pandas/test/test_io.py | 65 ++++++++++-------- modin/test/test_headers.py | 2 + 30 files changed, 122 insertions(+), 48 deletions(-) create mode 100644 modin/pandas/test/data/issue5159.parquet/part-0000.snappy.parquet/par=a/44c5b23d806c4dc8a97d70c4fb2219f5-0.parquet create mode 100644 modin/pandas/test/data/issue5159.parquet/part-0000.snappy.parquet/par=b/44c5b23d806c4dc8a97d70c4fb2219f5-0.parquet create mode 100644 modin/pandas/test/data/multiple_csv/test_data0.csv create mode 100644 modin/pandas/test/data/multiple_csv/test_data1.csv create mode 100644 modin/pandas/test/data/test_data.feather create mode 100644 modin/pandas/test/data/test_data.json create mode 100644 modin/pandas/test/data/test_data.parquet create mode 100644 modin/pandas/test/data/test_data_dir.parquet/part_0.parquet create mode 100644 modin/pandas/test/data/test_data_dir.parquet/part_1.parquet create mode 100644 modin/pandas/test/data/test_data_dir.parquet/part_10.parquet create mode 100644 modin/pandas/test/data/test_data_dir.parquet/part_11.parquet create mode 100644 modin/pandas/test/data/test_data_dir.parquet/part_12.parquet create mode 100644 modin/pandas/test/data/test_data_dir.parquet/part_13.parquet create mode 100644 modin/pandas/test/data/test_data_dir.parquet/part_14.parquet create mode 100644 modin/pandas/test/data/test_data_dir.parquet/part_15.parquet create mode 100644 modin/pandas/test/data/test_data_dir.parquet/part_2.parquet create mode 100644 modin/pandas/test/data/test_data_dir.parquet/part_3.parquet create mode 100644 modin/pandas/test/data/test_data_dir.parquet/part_4.parquet create mode 100644 modin/pandas/test/data/test_data_dir.parquet/part_5.parquet create mode 100644 modin/pandas/test/data/test_data_dir.parquet/part_6.parquet create mode 100644 modin/pandas/test/data/test_data_dir.parquet/part_7.parquet create mode 100644 modin/pandas/test/data/test_data_dir.parquet/part_8.parquet create mode 100644 modin/pandas/test/data/test_data_dir.parquet/part_9.parquet diff --git a/modin/conftest.py b/modin/conftest.py index 3f7b394ea59..392be2d5146 100644 --- a/modin/conftest.py +++ b/modin/conftest.py @@ -671,10 +671,26 @@ def s3_resource(s3_base): raise RuntimeError("Could not create bucket") s3fs.S3FileSystem.clear_instance_cache() - yield conn s3 = s3fs.S3FileSystem(client_kwargs={"endpoint_url": s3_base}) + test_s3_files = [ + ("modin-bugs/multiple_csv/", "modin/pandas/test/data/multiple_csv/"), + ( + "modin-bugs/test_data_dir.parquet/", + "modin/pandas/test/data/test_data_dir.parquet/", + ), + ("modin-bugs/test_data.parquet", "modin/pandas/test/data/test_data.parquet"), + ("modin-bugs/test_data.json", "modin/pandas/test/data/test_data.json"), + ("modin-bugs/test_data.fwf", "modin/pandas/test/data/test_data.fwf"), + ("modin-bugs/test_data.feather", "modin/pandas/test/data/test_data.feather"), + ("modin-bugs/issue5159.parquet/", "modin/pandas/test/data/issue5159.parquet/"), + ] + for s3_key, file_name in test_s3_files: + s3.put(file_name, f"{bucket}/{s3_key}", recursive=s3_key.endswith("/")) + + yield conn + s3.rm(bucket, recursive=True) for _ in range(20): # We want to wait until the deletion finishes. diff --git a/modin/core/io/column_stores/feather_dispatcher.py b/modin/core/io/column_stores/feather_dispatcher.py index ce4f60efbf0..e8d4879c46d 100644 --- a/modin/core/io/column_stores/feather_dispatcher.py +++ b/modin/core/io/column_stores/feather_dispatcher.py @@ -75,5 +75,9 @@ def _read(cls, path, columns=None, **kwargs): # Filtering out the columns that describe the frame's index columns = [col for col in reader.schema.names if col not in index_cols] return cls.build_query_compiler( - path, columns, use_threads=False, dtype_backend=kwargs["dtype_backend"] + path, + columns, + use_threads=False, + storage_options=kwargs["storage_options"], + dtype_backend=kwargs["dtype_backend"], ) diff --git a/modin/core/io/text/json_dispatcher.py b/modin/core/io/text/json_dispatcher.py index 62911f71d06..95473d662af 100644 --- a/modin/core/io/text/json_dispatcher.py +++ b/modin/core/io/text/json_dispatcher.py @@ -47,7 +47,9 @@ def _read(cls, path_or_buf, **kwargs): path_or_buf = stringify_path(path_or_buf) path_or_buf = cls.get_path_or_buffer(path_or_buf) if isinstance(path_or_buf, str): - if not cls.file_exists(path_or_buf): + if not cls.file_exists( + path_or_buf, storage_options=kwargs.get("storage_options") + ): return cls.single_worker_read( path_or_buf, reason=cls._file_not_found_msg(path_or_buf), **kwargs ) @@ -60,12 +62,21 @@ def _read(cls, path_or_buf, **kwargs): return cls.single_worker_read( path_or_buf, reason="`lines` argument not supported", **kwargs ) - with OpenFile(path_or_buf, "rb") as f: + with OpenFile( + path_or_buf, + "rb", + **(kwargs.get("storage_options", None) or {}), + ) as f: columns = pandas.read_json(BytesIO(b"" + f.readline()), lines=True).columns kwargs["columns"] = columns empty_pd_df = pandas.DataFrame(columns=columns) - with OpenFile(path_or_buf, "rb", kwargs.get("compression", "infer")) as f: + with OpenFile( + path_or_buf, + "rb", + kwargs.get("compression", "infer"), + **(kwargs.get("storage_options", None) or {}), + ) as f: column_widths, num_splits = cls._define_metadata(empty_pd_df, columns) args = {"fname": path_or_buf, "num_splits": num_splits, **kwargs} splits, _ = cls.partitioned_file( diff --git a/modin/experimental/core/io/text/csv_glob_dispatcher.py b/modin/experimental/core/io/text/csv_glob_dispatcher.py index e8750e98e85..89b76d72d1d 100644 --- a/modin/experimental/core/io/text/csv_glob_dispatcher.py +++ b/modin/experimental/core/io/text/csv_glob_dispatcher.py @@ -68,7 +68,9 @@ def _read(cls, filepath_or_buffer, **kwargs): reason=cls._file_not_found_msg(filepath_or_buffer), **kwargs, ) - filepath_or_buffer = cls.get_path(filepath_or_buffer) + filepath_or_buffer = cls.get_path( + filepath_or_buffer, kwargs.get("storage_options") + ) elif not cls.pathlib_or_pypath(filepath_or_buffer): return cls.single_worker_read( filepath_or_buffer, @@ -314,7 +316,7 @@ def file_exists(cls, file_path: str, storage_options=None) -> bool: return exists or len(fs.glob(file_path)) > 0 @classmethod - def get_path(cls, file_path: str) -> list: + def get_path(cls, file_path: str, storage_options=None) -> list: """ Return the path of the file(s). @@ -322,6 +324,8 @@ def get_path(cls, file_path: str) -> list: ---------- file_path : str String representing a path. + storage_options : dict, optional + Keyword from `read_*` functions. Returns ------- @@ -363,11 +367,17 @@ def get_file_path(fs_handle) -> List[str]: fs_addresses = [fs_handle.unstrip_protocol(path) for path in file_paths] return fs_addresses - fs, _ = fsspec.core.url_to_fs(file_path) + if storage_options is not None: + new_storage_options = dict(storage_options) + new_storage_options.pop("anon", None) + else: + new_storage_options = {} + + fs, _ = fsspec.core.url_to_fs(file_path, **new_storage_options) try: return get_file_path(fs) except credential_error_type: - fs, _ = fsspec.core.url_to_fs(file_path, anon=True) + fs, _ = fsspec.core.url_to_fs(file_path, anon=True, **new_storage_options) return get_file_path(fs) @classmethod diff --git a/modin/experimental/pandas/test/test_io_exp.py b/modin/experimental/pandas/test/test_io_exp.py index ff3d79d9e5b..cb361194f22 100644 --- a/modin/experimental/pandas/test/test_io_exp.py +++ b/modin/experimental/pandas/test/test_io_exp.py @@ -178,11 +178,15 @@ def test_read_single_csv_with_parse_dates(self, parse_dates): @pytest.mark.parametrize( "path", [ - "s3://modin-datasets/testing/multiple_csv/test_data*.csv", + "s3://modin-test/modin-bugs/multiple_csv/test_data*.csv", "gs://modin-testing/testing/multiple_csv/test_data*.csv", ], ) -def test_read_multiple_csv_cloud_store(path): +def test_read_multiple_csv_cloud_store(path, s3_resource, s3_storage_options): + storage_options_new = {"anon": True} + if path.startswith("s3"): + storage_options_new = s3_storage_options + def _pandas_read_csv_glob(path, storage_options): pandas_dfs = [ pandas.read_csv( @@ -198,7 +202,7 @@ def _pandas_read_csv_glob(path, storage_options): lambda module, **kwargs: pd.read_csv_glob(path, **kwargs).reset_index(drop=True) if hasattr(module, "read_csv_glob") else _pandas_read_csv_glob(path, **kwargs), - storage_options={"anon": True}, + storage_options=storage_options_new, ) @@ -207,17 +211,19 @@ def _pandas_read_csv_glob(path, storage_options): reason=f"{Engine.get()} does not have experimental API", ) @pytest.mark.parametrize( - "storage_options", - [{"anon": False}, {"anon": True}, {"key": "123", "secret": "123"}, None], + "storage_options_extra", + [{"anon": False}, {"anon": True}, {"key": "123", "secret": "123"}], ) -def test_read_multiple_csv_s3_storage_opts(storage_options): - path = "s3://modin-datasets/testing/multiple_csv/" +def test_read_multiple_csv_s3_storage_opts( + s3_resource, s3_storage_options, storage_options_extra +): + s3_path = "s3://modin-test/modin-bugs/multiple_csv/" def _pandas_read_csv_glob(path, storage_options): pandas_df = pandas.concat( [ pandas.read_csv( - f"{path}test_data{i}.csv", + f"{s3_path}test_data{i}.csv", storage_options=storage_options, ) for i in range(2) @@ -228,10 +234,10 @@ def _pandas_read_csv_glob(path, storage_options): eval_general( pd, pandas, - lambda module, **kwargs: pd.read_csv_glob(path, **kwargs) + lambda module, **kwargs: pd.read_csv_glob(s3_path, **kwargs) if hasattr(module, "read_csv_glob") - else _pandas_read_csv_glob(path, **kwargs), - storage_options=storage_options, + else _pandas_read_csv_glob(s3_path, **kwargs), + storage_options=s3_storage_options | storage_options_extra, ) diff --git a/modin/pandas/test/data/issue5159.parquet/part-0000.snappy.parquet/par=a/44c5b23d806c4dc8a97d70c4fb2219f5-0.parquet b/modin/pandas/test/data/issue5159.parquet/part-0000.snappy.parquet/par=a/44c5b23d806c4dc8a97d70c4fb2219f5-0.parquet new file mode 100644 index 0000000000000000000000000000000000000000..f02d8ff68062df3935ba9868ab7818e22de3edea GIT binary patch literal 1893 zcmcIlTW{i45Oyp?(^Vg;)f$0>w2`GoeIO|&I1MYUR>ve5Q^+o4<6BiB-vEPcTwfqg zl%LUuR_g!Q$3FJ&^dEH2!O23Jmwo6+_L(zt&dfJA`vV$rOo929dDUlVrtp%Y_N#yV zj_IFw48^=;N;NEJicGOu-ha@1=1SD}&qyhX`uc!|H@&wrG>v;BfSGIvQPO=cYW7EL3 zmi7ig>WA5*VrkADE5XQoCoyazG2r+e>jZGz4W{_+^~cW98GFA4W)}r()$#ou6uImP ziix*0lG7&1!w!lq#TE>lr2876$wOm9Bl@qWT=>M*mjz5bkz_}Xkt`#U1J5-hBg$|J z!pw-GM~KnTEa6O6ZX+%bR5vG-K@8#EfdSKe%TwfyO;A26Mv zHbQ6!52l|GY9e8ZItWcsdrD-2u%lmN;l-o=VvX;^9*9^WdI_O5f_tzMBMZF19$wdQ zUieY+y;K+FrrNk%nO!+CgMs_r6-+1VMTWvJr1^3x1-+T++n(gEr;^XlOywG%Np0mO z?zfR^os4EyFa#M|=lhk_mQJN}DVEy3TU(UZ*v>%ZY^>KBt=Lw$RBdd0>*}M=R8u$) zpEUzHHrv>r9_q@?LTx`H#i zHGFw)w~0Q{)zJ)W)U_+X+l^ga;pb*!6q>$8?8)*-?0=lPlPB1%49<5F$ln7li0c-) zI6mF?JX0J^Ouv%=j#J3Ne6aDi)*z>vdO5MX-a6O$rr4U<=UP3_FK4O(za5kA->fus z*tmQSv&eI%c+={)aVBZ6OyoMQ``q$nkhd~iPw1!EP>O8*#;K?Xl z<5mX_)+wH+E}jRV;bsVk_MDs)9_9(lFu}+mVU8^UiTQn43Bl~VeM^;J!CYlXeSjbAzwxX{(EtDd literal 0 HcmV?d00001 diff --git a/modin/pandas/test/data/issue5159.parquet/part-0000.snappy.parquet/par=b/44c5b23d806c4dc8a97d70c4fb2219f5-0.parquet b/modin/pandas/test/data/issue5159.parquet/part-0000.snappy.parquet/par=b/44c5b23d806c4dc8a97d70c4fb2219f5-0.parquet new file mode 100644 index 0000000000000000000000000000000000000000..320a70fa8ae7cfca560c95973d3509d35d30251c GIT binary patch literal 1893 zcmcIlTW{i45H=Q~>8cOaYK=fbYGkQVA4rM`PQywo?J)_)6xa>f__nH$Z-9wyyuLu3 zC_keQtYRg<| z;VnZkFPU;3%b5~Ws#W$LG+($f_0uy_ilV;Vr{PT(3N&@_Poc)VsPaXsRISifgQ3fG z`E#NCcj4glVDD?pR_g$z$j}r(!wW?*bhXY?l~QcY9pAv37eCV2300=6wQBAE^o?Vn z5B4CAR}@vPS4yD~*hXCWy?DpEf$iK(tfiOwLCn71|9HnZ$gYax!GMSQ`hnA$*ybzYm#IkcE_fHYc1^! zg47SQN5#@yI97s@`A%ZkMqTZ%0hI7#<)K$C~YhDP*XPr2}ktFH@~cp}M;93xpqBnO^rMn;t36oi=( zMawmIzH*}2T?SZsdek^-uz(PN;U<`D>9`~Qh-2@m%6Diyey6@7rSsm#_I+J3l)4#Pvd5!H1RnEqGt=W!kg-g}u#<#9M`b;&2^YB?S zkYlrh{pq2u++3^8XZ(s?z}~%8kH@?vVob=j6+Q&}*ZL*#4H&t#BexrlDiFJp-*ydO zUf3O?Pjq!W2OACT67cq7S6BFj*&K(aZxMU4JQfEZX72O}b}NJP-30RYfD7WfMJ|p{ z_dU-P$5YeqCV=AvaI|QDL!WC|H z;9#BMdFtVL0J_)=0nwh3lfuJ1VHqYE86+&QB_J`s4=W*#t7_&NPmqNw-qgZ&qn@I0;n literal 0 HcmV?d00001 diff --git a/modin/pandas/test/data/multiple_csv/test_data0.csv b/modin/pandas/test/data/multiple_csv/test_data0.csv new file mode 100644 index 00000000000..aee7387c3dc --- /dev/null +++ b/modin/pandas/test/data/multiple_csv/test_data0.csv @@ -0,0 +1,5 @@ +a,b,c +0,True,x +1,False,y +2,True,z +3,False,w diff --git a/modin/pandas/test/data/multiple_csv/test_data1.csv b/modin/pandas/test/data/multiple_csv/test_data1.csv new file mode 100644 index 00000000000..e2b4cc0f07c --- /dev/null +++ b/modin/pandas/test/data/multiple_csv/test_data1.csv @@ -0,0 +1,5 @@ +a,b,c +4,True,m +5,False,n +6,True,t +7,True,l diff --git a/modin/pandas/test/data/test_data.feather b/modin/pandas/test/data/test_data.feather new file mode 100644 index 0000000000000000000000000000000000000000..16599d04d8ab43db92f935cbdb4ccb3663d8108b GIT binary patch literal 2466 zcmeHJL2DCH5T3M~WGRa*ZNVT#!_z|#F($P3AU(9`!Ant!pdb>TW@{HVo3Po{hEN)M z>BWm6g5bepZ(e)wNA&2;pHjbh`*vM7Q518`z;xftH{Z_edowTH-rarhxJh&YIh`l6 ziDiKMC|xR&3^Ee$ks0f~KE`}cErVsB{w z=Nz8c3C=LrlC6(@|7s_78DgjMeCwExOl;!jvP@dMd6|kPjktwU$;Dp(mNm-^naGg(wG&EToCjnIa)x@donAmQb zP8Em;;9){SLP$vb4@3MB6Hkao9+1YLfOy&i51e~#=c8$b5ZkQA_ug~PJ?Hnk=iFm; z4>gi>h+e1PtilkwO;N8sctE+&rZ>_QeVdMK(5qmvOfRoR!@fl{?At`ByO&78!S(eZ zyjKH(rD*)Oz#1KjWkS?)EE>)`L%~)D1|5v7Mt%-NehtLS@$i3f9^1f>=^zCF@j&5W zkAAmG-ylrxh79U@fO4*{gsASV@B`iBbL5S%EkfU!<>h#qXxO*GycVTkcJr>wYia4L zNHG5R+LYO~Jx8+?LhJ89l?st(bW|JF5wr9eVC!5%a+trbb$^-rM?xM-T`;%7LKR#&)Hl0u&BstY%NxO z`B&+sm-MSBtN1HOZRGcr$SUL`^5e?u6cyWuE}McO3s&^AULZ*;r=`L03RvIGP44%NB++g^_S3I}# zUXD~nlMm-*F04a)Y~pZ?W2n+Vb`dzbIi4s6VqcMLj7(Q-K^ANQ%pWm|0p_aF#d{C- zi<|eD^BFMnC>X!FV=r@9<}?=&sfN9iCMPn7XCj_~gAeO`15kMs8PanXqfNvwFqD5Y z7o*K;USL4~?k@(M)%?$Zq^^dFU=M~Q-I^*6h2a=y+Z+qS;b4TrpD05MzS&rs-b!pG z8At--MV`?mx3;&^%sCZZMUBS${C&6WMgO?@Hhxm5fDo#~4?piAltye96%p#9T$jjL zc1}OSLfGT!^WD6=hBxs-^c+GX1p8nlPGBUP;N6BFJ8JWp<92Hoi(x}5|OU)7Xv)f9_Sl>=%EtyX`t&|(P)0)I|+I&*OwxZFr#2k*- zF+2R}aVvF6+)@#+7tdPTjf_;nn2;*-858^;cOG&$HegJSlx8-ev{>So)3d6eH~Vsq z=5Hj`De4mO*K9UQ)%~t|bOF0G2K%B7=jY=>YNOm7w_C6` zl~ys&j;?89sc|IgMH}$!0G@)}s`TWNb|R`IzL$^MkzCeBo{cFQ^;FHpcL{#Yk@M|@ zn~S6-mx_?jMsIRv<%ETK%*3oip3c1U&;`C}b&A=^IddAD6w5U?w+0J&hFm6R&ucbb zCMmYq%5gc*n_}Ql5#qkYA+k^To7OZdy|6 zfcTXz$@5%XqiIWJ?W!}J&v6MjluonN9IpLo?&_(cC2m(tT#HxaF5l))PbA1GpyG4q zruj^DT9q?_&pPBj_Y2PMIGx~(1nvX4Um#S){emTeJ-jAOg&p8lg%zkrgc5!ed$PoR z1}vv%N2L57Y$ELl+8I2CzpitnQ)Ez{x#4Wr<}i@2v|t+&FQw zgojM=1fc`?v2Ly74b=<#0W_ciylv=$h%`Xh?1S+69b-=y`OVyGtj2g@e;4~9{D}~U r_Z}+xhA9j`aTI$~GR;l$-B|ZN4C~~J5v#6I)Z~wf-K3~J_#gBiPiB9Q literal 0 HcmV?d00001 diff --git a/modin/pandas/test/data/test_data_dir.parquet/part_0.parquet b/modin/pandas/test/data/test_data_dir.parquet/part_0.parquet new file mode 100644 index 0000000000000000000000000000000000000000..51ce2896a72ed0fe3dd9add95689f798cb488a49 GIT binary patch literal 98851 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

ycNoZx3Zq&$k4 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 literal 0 HcmV?d00001 diff --git a/modin/pandas/test/data/test_data_dir.parquet/part_10.parquet b/modin/pandas/test/data/test_data_dir.parquet/part_10.parquet new file mode 100644 index 0000000000000000000000000000000000000000..91b3db27826e07432d3e4f64380e768e04288d72 GIT binary patch literal 98802 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 CXj^&! literal 0 HcmV?d00001 diff --git a/modin/pandas/test/data/test_data_dir.parquet/part_13.parquet b/modin/pandas/test/data/test_data_dir.parquet/part_13.parquet new file mode 100644 index 0000000000000000000000000000000000000000..3e3bd9d69d9cf4d602c951891aedccf623a2a9ca GIT binary patch literal 98922 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 HXC?kGvx{y0 literal 0 HcmV?d00001 diff --git a/modin/pandas/test/data/test_data_dir.parquet/part_14.parquet b/modin/pandas/test/data/test_data_dir.parquet/part_14.parquet new file mode 100644 index 0000000000000000000000000000000000000000..2eec850865a5937cc191f4b5e1668f053490ebdd GIT binary patch literal 98839 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 literal 0 HcmV?d00001 diff --git a/modin/pandas/test/data/test_data_dir.parquet/part_15.parquet b/modin/pandas/test/data/test_data_dir.parquet/part_15.parquet new file mode 100644 index 0000000000000000000000000000000000000000..e23611dd34306db6d80dfc0f2b33baf0f799d950 GIT binary patch literal 98983 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 literal 0 HcmV?d00001 diff --git a/modin/pandas/test/data/test_data_dir.parquet/part_3.parquet b/modin/pandas/test/data/test_data_dir.parquet/part_3.parquet new file mode 100644 index 0000000000000000000000000000000000000000..2bcec8b9c70c1b9c9523cdee801d216a1bf54b23 GIT binary patch literal 98998 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 Q1hQ&>m6bIkF*ExA0MEv1t^fc4 literal 0 HcmV?d00001 diff --git a/modin/pandas/test/data/test_data_dir.parquet/part_4.parquet b/modin/pandas/test/data/test_data_dir.parquet/part_4.parquet new file mode 100644 index 0000000000000000000000000000000000000000..06d8349207a794cc6b2e62a9eb8d43592a74f83a GIT binary patch literal 98963 zcmeHwdwf;Zoo`M~fcQW~O$=g{hit(I%I*SX*CdhE0JUTdF|j>*3N zT-u*Gd!4oR@2v0s{vK=Xwb!1WoRKKVE7%n;I2M~$P*sp~dQQ&4SYCe439+1sCk)BU zsT-P~b1Gy4qziHZq!V%y=>1^J~g-3J`C`g@z-n)k>pzjoe?pHq-Gy!^DB!NZI4Qp=YV7vvUBDqKIP z@U^(}Xs(n0*C-o)F+j^H$j!ljPQ#y^931bB7u=aUt>B9S#1CS5%>=QNAU2`9Qy|5V zk&yEsmq12C09f7^So2)gB&39U8stJqE93;oB*^Iyf_FJ&GK4V4A?HH?ea!@g(8=nr z31+AIrYJwIJ`qf_6kqzlyt{hbcV6CjTk)$+Pd#51fBq+joWuEkFcSjI#|9O?6L)qF zan=t4n&x>!v{})-KVEPMlq?Zw?vCYE5u;Ox5h>RQA(@B~(IugXZVEz#+ac8uQneIv z8ibgh3>gl&6aoU~jDeg3?9NnpoUQ(v*rn7rMfn*2WH(c z@nz@zp?>U21a^n=3U|hxZ6`ZVp9t*C^M+`%Vz((?@FB1(6WHyI<&7qGrNr(^^mhh? zrb@hMqT~j1e$Qd z;@^qtugS*w>YJi`Iv@Tcn)Ah{oSbwi3c@UT+mWt+_|yB&x*|WCWgbg@tsDH_wg}{wlD-4o?Qg<$f#CgMf*cEL3o{s@XB}sCdznn62B+g=Az__ zY;h(_7RA-&+m+MUO5*in&%}C&C(nziU;Eb_aQ#l2p%4Z;suWa z(aQy*pT_d$l4U0n6R=9Mu$o7-U`7q*K*SXhS9nOGREV0wLk?w4vXcy~hrla{baN*D zmOzN-SrEiR0W7Qz@pjxu7mLg*3UO(n)j zB1R0)gutiDYjml`5vbRks_^Rw#c`hc#-9n9rKo1JdEhwcsfWLRPw6k5za;!PR%@Hz z8+U$M>};6;9L@8Rp=PPw<}t7#j_$zbWa2oU{bjT!hK$(sqO}krLr%hD3M`o|F}l#Q z$zgJhK7f9*6GE#em+5zTUC2^qpx|`Pc?zXVLvc*!c!N|O_RK7`;5dHAoSswHjl1BE zx8~jT%mL@_$~2CHFtkayV;cbZX1VjjQUGb5I7D3-A$=j}S^{|?fvnIp60pMINvd7M z@0dE$@FzeBEXhiuB1j1e(y-Ecu9%3MWrqJ0?6M#{UY7og0gARSsd94aCnkOcVW~rTz$Sl`+$B07-W7`Qs@^eIl z7NF7JF=jBwB*Vx)!L{=lB)Q{>uQSdEyXt zR!+jWKl2*5fbe|s4#p^5n+Wf8i)2|HgrL&-&4G}W3^p`tnlnwEa5C8>WQ38XO%o?m z=>m8`CIiY76qF~azb2HO>YJkc!RizHW|rbhi<_)FwXyo7@BVz=9Wd|RjR7d-hzC;_ zZ^oVV4bIV8Kxv*hM4c7NATPy;v7JydFC+joNX8k>x0(3d2%+f`U@|rdIT6AUqz0Ej z12B1LZAsUf$xH(AOw^^ z)8NU}Y6ve113V3%fHLS5DR3IqUz3&TDB$gxS!x0~i1TTGd~wRVVm|GFb98zDNIC8S zD-m>FXmvI>14#44WTjbZ_m-Oi^BD680;nbQ-xiy;Uk z!uV;}*FeZyzz2^y6&{sl&+#S!JVODO=mUVNiWoPhd!$S8*EH+;E8!tu-~7X}&T}(-0O}L_0o0{-0E0}tfB;@b02w~WG^RLY6gn@!WO`Eo!C*y9 zI+B@8kq~wO*Lc81E)q}%wG!eqGmYr;2QeUqN+VSmh0 zR5Rd%A)mVOtoQ17%)ITopE`HW55OmfGnkP4F(xqPIj>#=_{3TnM=aKqOrG{FV`z{td<|{2w8s*^`}h5Sl*&3>nM(l8k0hNkZTj#IT#D zO;!TlnqmdoZ1vZKHBFPas^h1LF2$EVSWlGq8w4xQ>VAIppPd~G0E_Wr;h!K?3yP zLg&~GfYLlMp)^bFo)QU=@8-?CEc#e7bRvYDCqp^mft<4D0tKTJ3THam$yIgOKeN;h=gnm1CBGD@`3K7b zaC-0FY+vTwy998WCk|0(W#+bc!6)ANi;D=SNX-YM!}OI*cL-DrLbeiOuu$AMB>z$n zg2tH5u?!hp1R<#NAY?Atd<}#qPOn*`fVnml#FYA`C_kn?u~%j(zVw;5=H#nQP0yb7 zyCF}sIG?NxfGEc|7%c9NJ3sDmw%h~|%@c>Hvx0aqw_p!IyjV=HZHwhK5yUbw&OwL4 zL1CN>8&_%b6*qZk=nN`M#Tj4-Gov+ABc|=lGU<2W9BRfW7{aG1=Bpb1n}%j7s(B$x zOi2v614F`7p3i+e?-$LBwv2b)ULAl;j$^jx6>g3@kF9cc+zQCd6B9DC)NaN>-Zc`k zT0%x&RtF*1=n+qYjD!$I8Y0ti!b&(Vf{ca`Fq%E1G9VTqh<=gbhhCKY1E4iuMBn*O zM{P5#q}`BruE#9JNjxCt0mR>Ky8TT~QoOgheE&WF{tM^O{|JC6$1jq1TfgLNyv^qe zv#7K4E?%(SJ5hcjL2Mz2G&%wRfMm`mqBKJS%@ceQ=?~4A@G^7|YJ>|B$q890gm5yP zke7f@#`}4i*K`oLNHv!UNON+uw=+RaPrB$jq>5SU2lcL`Hs+}n7!*Fhsq%Yoj|EVb zgBeVge-L-}t&2IE*T#UWdF~L6R$PNwkjTbQX1_^HIy*)Z60!_39&!?-5khClgp#Pz zl6C4x0y6T@+L`l^+_Z2y*nG&DkUWjB7&swgs?j&aS`@-2vh&q9|DELW<&8IavY=gG z2_h>;IWi5p`>vSt<{d<~&l;`BA`w~(#=6tItz@jY^}x_TG>8!6Zw2HEAR?Ari1|w( zA`F?4`78(n5rYZ?69W;Q12IoP=uyW&h(Cgouz{L$6j*vr+JsYoz= zSW4Q_*qH#)s0kyf$3RmHVcaIXjN|m9^sO*+3@C?TKuNd3%LKRKj=xJ3rY4qY6I4~i z_}eVCV#zfu<4^nUu|wq4wNtsF=-_IXwF@b5(tPZXh|ZIws5Gx@H}A1D{wl5R{;(~x>U^HP0G>t z_2z~mnjZ3a`)8Kg5gl=A<3b0BZmn!}Hhw*bs2uY=Ce{VL`?Z+!!b3#VJU7{CmfG1F zM6{iV77;rlz~Dlbf`2kZiQ5tkJ&g8bX9_Y0LL;WrB+7KWM4qUVr3`XJoMR+7P~jkE z;4irE-q{IXosmxaauuZPpIK@k8k;O;(UYf%MQfNvf8o&pqNQ@^BN4sp8!_k5Mj~pS zJ49m`M>&!1u3W1lqURA&1|SBaCJ2Tp!j&@AgR=rRVnzo^i>GDJhcF;949GC?Z9xC11RDu41>?NZSj%xHQ(NI9ZeV3-5PVYY$2lNxrwM*YDaXNZA32CThV@r1N%s` zK7sy|=@p|C6W8ew`U(0C;9GOP!te4>9L+Hhdu5gyIF7MHV+~+*} zOb|!!(CFazW6qxM5l8dfAsVf|61mZ*@Re?=c?zu?Yj^W=L`vCp!jOU(GR+f(Q4^|G z2&qLlY22hDVZ0PVS4kI1ni77xRXPd6NU9QYQWK8CT{@)ih?_=k22gg)EH$949yk8` zm=bOrc>x0Xy?+|)Y~2}z(!IH}C+-}3F6JEAK`700hiJ4y8FZ9liEmtm9fXKELr;() zT--Zr0S-kFCPSnvogi`26r~vxZ-#o}OZk7CZzAAEic*TxIp1|5sV9MP^=-btim6EP&e=q#Bu)dpdn2``~#e7YKfxKxwQWF&U|4Bedg;p(EVJuszZ>Xzr7EiVP3 zlcStB@(Q1hJ4biLoZ~Nie(2y4j{rJ%RpsS`u104)gz6edGlZr&Ov5zDg{ct5M3)St z17v6?>SSC41dJ0&5t*2Rzzfu9=QB2xf$3CERq5jm#z_e-OdUf84lVfAQ!Dm4JO3#N zqa5I@8&vp8+*$WZ%=zRegt5;W`((xE5=L<^klqf1I)M$-8+tRc4p0iuM{maCQ!?FDv?oM|PZj z+xK5{cD)wFP!4Hix^wr>W6s*25kvFbAsVe1BHi&V3%ZIJUQH^}T8Sa8mmFhoCT8@8 zOcTdI;*d_rcnD3G{390`nCS`W5l?_H0?~sGhH%hBoN*0m8Wct&Lh(%dd7TjX{k$%< z;CVi7HXfo^Ja*`Q3}_DiB7kR^JD|b(i!FOGfw9ML+WOof8m)LDvT(_{`v8QPX)7k4 zj1a^nAHt|x4k31zK$w=3hfElOv-FDN*op|!Axwi1VGg%>MJ7PVa>N;T8tAG}6t(Ra z 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 oeDT7w@#>9cR-yAw)qyPW_ literal 0 HcmV?d00001 diff --git a/modin/pandas/test/data/test_data_dir.parquet/part_6.parquet b/modin/pandas/test/data/test_data_dir.parquet/part_6.parquet new file mode 100644 index 0000000000000000000000000000000000000000..bc8c42dc05d0852c81b16c4764fac02633cd994e GIT binary patch literal 98893 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 I9-k5Wzwi8bk^lez literal 0 HcmV?d00001 diff --git a/modin/pandas/test/data/test_data_dir.parquet/part_7.parquet b/modin/pandas/test/data/test_data_dir.parquet/part_7.parquet new file mode 100644 index 0000000000000000000000000000000000000000..30bf86797bda4f7db9dc3e52bd48f0224ff4a0d5 GIT binary patch literal 98945 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 e)Y-TX*T=@^~ncc`@v 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 y&2o$qK=cD^7$5&{GWS|N!TF??yj2>yD0lvYd8 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 xe)Zy27j&;&d4YK0*% Date: Fri, 19 Jan 2024 14:49:41 +0100 Subject: [PATCH 141/201] FIX-#6830: Pass AWS related env vars to mpiexec (#6867) Signed-off-by: Anatoly Myachev --- .github/workflows/ci.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 83c4de34a3a..37e6f5372ba 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -387,7 +387,9 @@ jobs: command: | conda run --no-capture-output -n modin_on_unidist mpiexec -n 1 -genv AWS_ACCESS_KEY_ID foobar_key \ -genv AWS_SECRET_ACCESS_KEY foobar_secret python -m pytest modin/pandas/test/test_io.py --verbose - - run: mpiexec -n 1 python -m pytest modin/experimental/pandas/test/test_io_exp.py + - run: | + mpiexec -n 1 -genv AWS_ACCESS_KEY_ID foobar_key -genv AWS_SECRET_ACCESS_KEY foobar_secret \ + python -m pytest modin/experimental/pandas/test/test_io_exp.py - run: mpiexec -n 1 python -m pytest modin/experimental/sql/test/test_sql.py - run: mpiexec -n 1 python -m pytest modin/test/interchange/dataframe_protocol/test_general.py - run: mpiexec -n 1 python -m pytest modin/test/interchange/dataframe_protocol/pandas/test_protocol.py From 5d66fe8e0c8cae0ab11d0ddca45bdd581778a8b8 Mon Sep 17 00:00:00 2001 From: Andrey Pavlenko Date: Tue, 23 Jan 2024 12:15:00 +0100 Subject: [PATCH 142/201] FEAT-#6382: Execute bitwise NOT (~) operations on HDK (#6383) Signed-off-by: Andrey Pavlenko --- .../native/implementations/hdk_on_native/expr.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/modin/experimental/core/execution/native/implementations/hdk_on_native/expr.py b/modin/experimental/core/execution/native/implementations/hdk_on_native/expr.py index 8a4a1543e6c..0fe29e9c29f 100644 --- a/modin/experimental/core/execution/native/implementations/hdk_on_native/expr.py +++ b/modin/experimental/core/execution/native/implementations/hdk_on_native/expr.py @@ -485,7 +485,7 @@ def invert(self) -> "OpExpr": OpExpr The resulting bitwise inverse expression. """ - return OpExpr("~", [self], self._dtype) + return OpExpr("BIT_NOT", [self], self._dtype) def _cmp_op(self, other, op_name): """ @@ -983,7 +983,7 @@ class OpExpr(BaseExpr): "POWER": lambda self: self._fold_arithm("__pow__"), "/": lambda self: self._fold_arithm("__truediv__"), "//": lambda self: self._fold_arithm("__floordiv__"), - "~": lambda self: self._fold_invert(), + "BIT_NOT": lambda self: self._fold_invert(), "CAST": lambda self: self._fold_literal("cast", self._dtype), "IS NULL": lambda self: self._fold_literal("is_null"), "IS NOT NULL": lambda self: self._fold_literal("is_not_null"), @@ -996,7 +996,7 @@ class OpExpr(BaseExpr): "POWER": lambda self, table: self._pc("power", table), "/": lambda self, table: self._pc("divide", table), "//": lambda self, table: self._pc("divide", table), - "~": lambda self, table: self._invert(table), + "BIT_NOT": lambda self, table: self._invert(table), "CAST": lambda self, table: self._col(table).cast(to_arrow_type(self._dtype)), "IS NULL": lambda self, table: self._col(table).is_null(nan_is_null=True), "IS NOT NULL": lambda self, table: pc.invert( @@ -1004,7 +1004,7 @@ class OpExpr(BaseExpr): ), } - _UNSUPPORTED_HDK_OPS = {"~"} + _UNSUPPORTED_HDK_OPS = {} def __init__(self, op, operands, dtype): self.op = op From 1ce226fb3548920e77571f9671c2f2ed10690706 Mon Sep 17 00:00:00 2001 From: Anatoly Myachev Date: Tue, 23 Jan 2024 16:19:02 +0100 Subject: [PATCH 143/201] FIX-#6855: Make sure read_parquet works with integer columns for pyarrow engine (#6874) Signed-off-by: Anatoly Myachev --- modin/core/io/column_stores/parquet_dispatcher.py | 8 ++++++++ modin/pandas/test/test_io.py | 11 +++++++++++ 2 files changed, 19 insertions(+) diff --git a/modin/core/io/column_stores/parquet_dispatcher.py b/modin/core/io/column_stores/parquet_dispatcher.py index f894f247e6a..63d9adb2247 100644 --- a/modin/core/io/column_stores/parquet_dispatcher.py +++ b/modin/core/io/column_stores/parquet_dispatcher.py @@ -686,6 +686,14 @@ def build_query_compiler(cls, dataset, columns, index_columns, **kwargs): row_lengths = [part.length() for part in remote_parts.T[0]] else: row_lengths = None + + if ( + dataset.pandas_metadata + and "column_indexes" in dataset.pandas_metadata + and dataset.pandas_metadata["column_indexes"][0]["numpy_type"] == "int64" + ): + columns = pandas.Index(columns).astype("int64").to_list() + frame = cls.frame_cls( remote_parts, index, diff --git a/modin/pandas/test/test_io.py b/modin/pandas/test/test_io.py index 20755df89ae..0d5c4da47a1 100644 --- a/modin/pandas/test/test_io.py +++ b/modin/pandas/test/test_io.py @@ -2034,6 +2034,17 @@ def test_read_parquet_5767(self, tmp_path, engine): # both Modin and pandas read column "b" as a category df_equals(test_df, read_df.astype("int64")) + def test_read_parquet_6855(self, tmp_path, engine): + if engine == "fastparquet": + pytest.skip("integer columns aren't supported") + test_df = pandas.DataFrame(np.random.rand(10**2, 10)) + path = tmp_path / "data" + path.mkdir() + file_name = "issue6855.parquet" + test_df.to_parquet(path / file_name, engine=engine) + read_df = pd.read_parquet(path / file_name, engine=engine) + df_equals(test_df, read_df) + def test_read_parquet_s3_with_column_partitioning( self, s3_resource, engine, s3_storage_options ): From 23ee584198dd961e0e0cddf83df7adfb7ddca17f Mon Sep 17 00:00:00 2001 From: Anatoly Myachev Date: Tue, 23 Jan 2024 16:31:33 +0100 Subject: [PATCH 144/201] FEAT-#3450: Implement read_json_glob and to_json_glob (#6873) Signed-off-by: Anatoly Myachev --- docs/flow/modin/experimental/pandas.rst | 2 + docs/supported_apis/dataframe_supported.rst | 2 + docs/supported_apis/io_supported.rst | 1 + docs/usage_guide/advanced_usage/index.rst | 2 + .../implementations/pandas_on_dask/io/io.py | 8 ++ .../dispatching/factories/dispatcher.py | 15 +++ .../dispatching/factories/factories.py | 47 +++++++ .../implementations/pandas_on_ray/io/io.py | 8 ++ .../pandas_on_unidist/io/io.py | 8 ++ modin/core/io/io.py | 14 ++ .../core/io/glob/glob_dispatcher.py | 14 +- .../core/storage_formats/pandas/parsers.py | 18 +++ modin/experimental/pandas/__init__.py | 1 + modin/experimental/pandas/io.py | 121 +++++++++++++++++- modin/experimental/pandas/test/test_io_exp.py | 24 ++++ modin/pandas/accessor.py | 48 +++++++ modin/pandas/base.py | 8 +- 17 files changed, 335 insertions(+), 6 deletions(-) diff --git a/docs/flow/modin/experimental/pandas.rst b/docs/flow/modin/experimental/pandas.rst index d429003c735..68acb705980 100644 --- a/docs/flow/modin/experimental/pandas.rst +++ b/docs/flow/modin/experimental/pandas.rst @@ -14,5 +14,7 @@ Experimental API Reference .. autofunction:: read_custom_text .. autofunction:: read_pickle_distributed .. autofunction:: read_parquet_glob +.. autofunction:: read_json_glob .. automethod:: modin.pandas.DataFrame.modin::to_pickle_distributed .. automethod:: modin.pandas.DataFrame.modin::to_parquet_glob +.. automethod:: modin.pandas.DataFrame.modin::to_json_glob diff --git a/docs/supported_apis/dataframe_supported.rst b/docs/supported_apis/dataframe_supported.rst index 7d29af21265..a631c335669 100644 --- a/docs/supported_apis/dataframe_supported.rst +++ b/docs/supported_apis/dataframe_supported.rst @@ -411,6 +411,8 @@ default to pandas. | ``to_html`` | `to_html`_ | D | | +----------------------------+---------------------------+------------------------+----------------------------------------------------+ | ``to_json`` | `to_json`_ | D | | +| | | | Experimental implementation: | +| | | | DataFrame.modin.to_json_glob | +----------------------------+---------------------------+------------------------+----------------------------------------------------+ | ``to_latex`` | `to_latex`_ | D | | +----------------------------+---------------------------+------------------------+----------------------------------------------------+ diff --git a/docs/supported_apis/io_supported.rst b/docs/supported_apis/io_supported.rst index 11f2a99f5e7..932562bc647 100644 --- a/docs/supported_apis/io_supported.rst +++ b/docs/supported_apis/io_supported.rst @@ -49,6 +49,7 @@ default to pandas. | | | Experimental implementation: read_parquet_glob | +-------------------+---------------------------------+--------------------------------------------------------+ | `read_json`_ | P | Implemented for ``lines=True`` | +| | | Experimental implementation: read_json_glob | +-------------------+---------------------------------+--------------------------------------------------------+ | `read_html`_ | D | | +-------------------+---------------------------------+--------------------------------------------------------+ diff --git a/docs/usage_guide/advanced_usage/index.rst b/docs/usage_guide/advanced_usage/index.rst index 3151c28cff7..7263036d5d0 100644 --- a/docs/usage_guide/advanced_usage/index.rst +++ b/docs/usage_guide/advanced_usage/index.rst @@ -32,8 +32,10 @@ Modin also supports these experimental APIs on top of pandas that are under acti - :py:func:`~modin.experimental.pandas.read_custom_text` -- read custom text data from file - :py:func:`~modin.experimental.pandas.read_pickle_distributed` -- read multiple pickle files in a directory - :py:func:`~modin.experimental.pandas.read_parquet_glob` -- read multiple parquet files in a directory +- :py:func:`~modin.experimental.pandas.read_json_glob` -- read multiple json files in a directory - :py:meth:`~modin.pandas.DataFrame.modin.to_pickle_distributed` -- write to multiple pickle files in a directory - :py:meth:`~modin.pandas.DataFrame.modin.to_parquet_glob` -- write to multiple parquet files in a directory +- :py:meth:`~modin.pandas.DataFrame.modin.to_json_glob` -- write to multiple json files in a directory DataFrame partitioning API -------------------------- diff --git a/modin/core/execution/dask/implementations/pandas_on_dask/io/io.py b/modin/core/execution/dask/implementations/pandas_on_dask/io/io.py index 4c51ebe395d..5a9051fe923 100644 --- a/modin/core/execution/dask/implementations/pandas_on_dask/io/io.py +++ b/modin/core/execution/dask/implementations/pandas_on_dask/io/io.py @@ -49,6 +49,7 @@ from modin.experimental.core.storage_formats.pandas.parsers import ( ExperimentalCustomTextParser, ExperimentalPandasCSVGlobParser, + ExperimentalPandasJsonParser, ExperimentalPandasParquetParser, ExperimentalPandasPickleParser, ) @@ -97,6 +98,13 @@ def __make_write(*classes, build_args=build_args): ExperimentalGlobDispatcher, build_args={**build_args, "base_write": BaseIO.to_parquet}, ) + read_json_glob = __make_read( + ExperimentalPandasJsonParser, ExperimentalGlobDispatcher + ) + to_json_glob = __make_write( + ExperimentalGlobDispatcher, + build_args={**build_args, "base_write": BaseIO.to_json}, + ) read_pickle_distributed = __make_read( ExperimentalPandasPickleParser, ExperimentalGlobDispatcher ) diff --git a/modin/core/execution/dispatching/factories/dispatcher.py b/modin/core/execution/dispatching/factories/dispatcher.py index 8c4ecfa7ace..7ab32b55b0d 100644 --- a/modin/core/execution/dispatching/factories/dispatcher.py +++ b/modin/core/execution/dispatching/factories/dispatcher.py @@ -306,6 +306,16 @@ def read_parquet_glob(cls, *args, **kwargs): def to_parquet_glob(cls, *args, **kwargs): return cls.get_factory()._to_parquet_glob(*args, **kwargs) + @classmethod + @_inherit_docstrings(factories.PandasOnRayFactory._read_json_glob) + def read_json_glob(cls, *args, **kwargs): + return cls.get_factory()._read_json_glob(*args, **kwargs) + + @classmethod + @_inherit_docstrings(factories.PandasOnRayFactory._to_json_glob) + def to_json_glob(cls, *args, **kwargs): + return cls.get_factory()._to_json_glob(*args, **kwargs) + @classmethod @_inherit_docstrings(factories.PandasOnRayFactory._read_custom_text) def read_custom_text(cls, **kwargs): @@ -316,6 +326,11 @@ def read_custom_text(cls, **kwargs): def to_csv(cls, *args, **kwargs): return cls.get_factory()._to_csv(*args, **kwargs) + @classmethod + @_inherit_docstrings(factories.BaseFactory._to_json) + def to_json(cls, *args, **kwargs): + return cls.get_factory()._to_json(*args, **kwargs) + @classmethod @_inherit_docstrings(factories.BaseFactory._to_parquet) def to_parquet(cls, *args, **kwargs): diff --git a/modin/core/execution/dispatching/factories/factories.py b/modin/core/execution/dispatching/factories/factories.py index 91d6273421a..736965d177f 100644 --- a/modin/core/execution/dispatching/factories/factories.py +++ b/modin/core/execution/dispatching/factories/factories.py @@ -413,6 +413,20 @@ def _to_csv(cls, *args, **kwargs): """ return cls.io_cls.to_csv(*args, **kwargs) + @classmethod + def _to_json(cls, *args, **kwargs): + """ + Write query compiler content to a JSON file. + + Parameters + ---------- + *args : args + Arguments to pass to the writer method. + **kwargs : kwargs + Arguments to pass to the writer method. + """ + return cls.io_cls.to_json(*args, **kwargs) + @classmethod def _to_parquet(cls, *args, **kwargs): """ @@ -549,6 +563,39 @@ def _to_parquet_glob(cls, *args, **kwargs): ) return cls.io_cls.to_parquet_glob(*args, **kwargs) + @classmethod + @doc( + _doc_io_method_raw_template, + source="Json files", + params=_doc_io_method_kwargs_params, + ) + def _read_json_glob(cls, **kwargs): + current_execution = get_current_execution() + if current_execution not in supported_executions: + raise NotImplementedError( + f"`_read_json_glob()` is not implemented for {current_execution} execution." + ) + return cls.io_cls.read_json_glob(**kwargs) + + @classmethod + def _to_json_glob(cls, *args, **kwargs): + """ + Write query compiler content to several json files. + + Parameters + ---------- + *args : args + Arguments to pass to the writer method. + **kwargs : kwargs + Arguments to pass to the writer method. + """ + current_execution = get_current_execution() + if current_execution not in supported_executions: + raise NotImplementedError( + f"`_to_json_glob()` is not implemented for {current_execution} execution." + ) + return cls.io_cls.to_json_glob(*args, **kwargs) + @doc(_doc_factory_class, execution_name="PandasOnRay") class PandasOnRayFactory(BaseFactory): diff --git a/modin/core/execution/ray/implementations/pandas_on_ray/io/io.py b/modin/core/execution/ray/implementations/pandas_on_ray/io/io.py index 4a90cc54025..29ab26b95f7 100644 --- a/modin/core/execution/ray/implementations/pandas_on_ray/io/io.py +++ b/modin/core/execution/ray/implementations/pandas_on_ray/io/io.py @@ -48,6 +48,7 @@ from modin.experimental.core.storage_formats.pandas.parsers import ( ExperimentalCustomTextParser, ExperimentalPandasCSVGlobParser, + ExperimentalPandasJsonParser, ExperimentalPandasParquetParser, ExperimentalPandasPickleParser, ) @@ -99,6 +100,13 @@ def __make_write(*classes, build_args=build_args): ExperimentalGlobDispatcher, build_args={**build_args, "base_write": RayIO.to_parquet}, ) + read_json_glob = __make_read( + ExperimentalPandasJsonParser, ExperimentalGlobDispatcher + ) + to_json_glob = __make_write( + ExperimentalGlobDispatcher, + build_args={**build_args, "base_write": RayIO.to_json}, + ) read_pickle_distributed = __make_read( ExperimentalPandasPickleParser, ExperimentalGlobDispatcher ) diff --git a/modin/core/execution/unidist/implementations/pandas_on_unidist/io/io.py b/modin/core/execution/unidist/implementations/pandas_on_unidist/io/io.py index 4311acecbfa..c2480bc0543 100644 --- a/modin/core/execution/unidist/implementations/pandas_on_unidist/io/io.py +++ b/modin/core/execution/unidist/implementations/pandas_on_unidist/io/io.py @@ -48,6 +48,7 @@ from modin.experimental.core.storage_formats.pandas.parsers import ( ExperimentalCustomTextParser, ExperimentalPandasCSVGlobParser, + ExperimentalPandasJsonParser, ExperimentalPandasParquetParser, ExperimentalPandasPickleParser, ) @@ -99,6 +100,13 @@ def __make_write(*classes, build_args=build_args): ExperimentalGlobDispatcher, build_args={**build_args, "base_write": UnidistIO.to_parquet}, ) + read_json_glob = __make_read( + ExperimentalPandasJsonParser, ExperimentalGlobDispatcher + ) + to_json_glob = __make_write( + ExperimentalGlobDispatcher, + build_args={**build_args, "base_write": UnidistIO.to_json}, + ) read_pickle_distributed = __make_read( ExperimentalPandasPickleParser, ExperimentalGlobDispatcher ) diff --git a/modin/core/io/io.py b/modin/core/io/io.py index 92836a0bf18..d3c2b3f8b3f 100644 --- a/modin/core/io/io.py +++ b/modin/core/io/io.py @@ -647,6 +647,20 @@ def to_csv(cls, obj, **kwargs): # noqa: PR01 return obj.to_csv(**kwargs) + @classmethod + @_inherit_docstrings(pandas.DataFrame.to_json, apilink="pandas.DataFrame.to_json") + def to_json(cls, obj, path, **kwargs): # noqa: PR01 + """ + Convert the object to a JSON string. + + For parameters description please refer to pandas API. + """ + ErrorMessage.default_to_pandas("`to_json`") + if isinstance(obj, BaseQueryCompiler): + obj = obj.to_pandas() + + return obj.to_json(path, **kwargs) + @classmethod @_inherit_docstrings( pandas.DataFrame.to_parquet, apilink="pandas.DataFrame.to_parquet" diff --git a/modin/experimental/core/io/glob/glob_dispatcher.py b/modin/experimental/core/io/glob/glob_dispatcher.py index 29cb4896290..6a1d6415aff 100644 --- a/modin/experimental/core/io/glob/glob_dispatcher.py +++ b/modin/experimental/core/io/glob/glob_dispatcher.py @@ -48,7 +48,12 @@ def _read(cls, **kwargs): ----- The number of partitions is equal to the number of input files. """ - path_key = "filepath_or_buffer" if "filepath_or_buffer" in kwargs else "path" + if "filepath_or_buffer" in kwargs: + path_key = "filepath_or_buffer" + elif "path" in kwargs: + path_key = "path" + elif "path_or_buf" in kwargs: + path_key = "path_or_buf" filepath_or_buffer = kwargs.pop(path_key) filepath_or_buffer = stringify_path(filepath_or_buffer) if not (isinstance(filepath_or_buffer, str) and "*" in filepath_or_buffer): @@ -112,7 +117,12 @@ def write(cls, qc, **kwargs): **kwargs : dict Parameters for ``pandas.to_(**kwargs)``. """ - path_key = "filepath_or_buffer" if "filepath_or_buffer" in kwargs else "path" + if "filepath_or_buffer" in kwargs: + path_key = "filepath_or_buffer" + elif "path" in kwargs: + path_key = "path" + elif "path_or_buf" in kwargs: + path_key = "path_or_buf" filepath_or_buffer = kwargs.pop(path_key) filepath_or_buffer = stringify_path(filepath_or_buffer) if not ( diff --git a/modin/experimental/core/storage_formats/pandas/parsers.py b/modin/experimental/core/storage_formats/pandas/parsers.py index be2ec01489a..66e4ae01e91 100644 --- a/modin/experimental/core/storage_formats/pandas/parsers.py +++ b/modin/experimental/core/storage_formats/pandas/parsers.py @@ -132,6 +132,24 @@ def parse(fname, **kwargs): return _split_result_for_readers(1, num_splits, df) + [length, width] +@doc(_doc_pandas_parser_class, data_type="json files") +class ExperimentalPandasJsonParser(PandasParser): + @staticmethod + @doc(_doc_parse_func, parameters=_doc_parse_parameters_common) + def parse(fname, **kwargs): + warnings.filterwarnings("ignore") + num_splits = 1 + single_worker_read = kwargs.pop("single_worker_read", None) + df = pandas.read_json(fname, **kwargs) + if single_worker_read: + return df + + length = len(df) + width = len(df.columns) + + return _split_result_for_readers(1, num_splits, df) + [length, width] + + @doc(_doc_pandas_parser_class, data_type="custom text") class ExperimentalCustomTextParser(PandasParser): @staticmethod diff --git a/modin/experimental/pandas/__init__.py b/modin/experimental/pandas/__init__.py index 4c484fddb89..e8278a7e2ae 100644 --- a/modin/experimental/pandas/__init__.py +++ b/modin/experimental/pandas/__init__.py @@ -40,6 +40,7 @@ from .io import ( # noqa F401 read_csv_glob, read_custom_text, + read_json_glob, read_parquet_glob, read_pickle_distributed, read_sql, diff --git a/modin/experimental/pandas/io.py b/modin/experimental/pandas/io.py index 92349b5f7b1..a305d993bcd 100644 --- a/modin/experimental/pandas/io.py +++ b/modin/experimental/pandas/io.py @@ -18,11 +18,11 @@ import inspect import pathlib import pickle -from typing import IO, AnyStr, Callable, Iterator, Optional, Union +from typing import IO, AnyStr, Callable, Iterator, Literal, Optional, Union import pandas import pandas._libs.lib as lib -from pandas._typing import CompressionOptions, StorageOptions +from pandas._typing import CompressionOptions, DtypeArg, DtypeBackend, StorageOptions from modin.core.storage_formats import BaseQueryCompiler from modin.utils import expanduser_path_arg @@ -483,3 +483,120 @@ def to_parquet_glob( storage_options=storage_options, **kwargs, ) + + +@expanduser_path_arg("path_or_buf") +def read_json_glob( + path_or_buf, + *, + orient: str | None = None, + typ: Literal["frame", "series"] = "frame", + dtype: DtypeArg | None = None, + convert_axes=None, + convert_dates: bool | list[str] = True, + keep_default_dates: bool = True, + precise_float: bool = False, + date_unit: str | None = None, + encoding: str | None = None, + encoding_errors: str | None = "strict", + lines: bool = False, + chunksize: int | None = None, + compression: CompressionOptions = "infer", + nrows: int | None = None, + storage_options: StorageOptions = None, + dtype_backend: Union[DtypeBackend, lib.NoDefault] = lib.no_default, + engine="ujson", +) -> DataFrame: # noqa: PR01 + """ + Convert a JSON string to pandas object. + + This experimental feature provides parallel reading from multiple json files which are + defined by glob pattern. The files must contain parts of one dataframe, which can be + obtained, for example, by `DataFrame.modin.to_json_glob` function. + + Returns + ------- + DataFrame + + Notes + ----- + * Only string type supported for `path_or_buf` argument. + * The rest of the arguments are the same as for `pandas.read_json`. + """ + from modin.core.execution.dispatching.factories.dispatcher import FactoryDispatcher + + if nrows is not None: + raise NotImplementedError( + "`read_json_glob` only support nrows is None, otherwise use `to_json`." + ) + + return DataFrame( + query_compiler=FactoryDispatcher.read_json_glob( + path_or_buf=path_or_buf, + orient=orient, + typ=typ, + dtype=dtype, + convert_axes=convert_axes, + convert_dates=convert_dates, + keep_default_dates=keep_default_dates, + precise_float=precise_float, + date_unit=date_unit, + encoding=encoding, + encoding_errors=encoding_errors, + lines=lines, + chunksize=chunksize, + compression=compression, + nrows=nrows, + storage_options=storage_options, + dtype_backend=dtype_backend, + engine=engine, + ) + ) + + +@expanduser_path_arg("path_or_buf") +def to_json_glob( + self, + path_or_buf=None, + orient=None, + date_format=None, + double_precision=10, + force_ascii=True, + date_unit="ms", + default_handler=None, + lines=False, + compression="infer", + index=None, + indent=None, + storage_options: StorageOptions = None, + mode="w", +) -> None: # noqa: PR01 + """ + Convert the object to a JSON string. + + Notes + ----- + * Only string type supported for `path_or_buf` argument. + * The rest of the arguments are the same as for `pandas.to_json`. + """ + obj = self + from modin.core.execution.dispatching.factories.dispatcher import FactoryDispatcher + + if isinstance(self, DataFrame): + obj = self._query_compiler + FactoryDispatcher.to_json_glob( + obj, + path_or_buf=path_or_buf, + orient=orient, + date_format=date_format, + double_precision=double_precision, + force_ascii=force_ascii, + date_unit=date_unit, + default_handler=default_handler, + lines=lines, + compression=compression, + index=index, + indent=indent, + storage_options=storage_options, + mode=mode, + ) diff --git a/modin/experimental/pandas/test/test_io_exp.py b/modin/experimental/pandas/test/test_io_exp.py index cb361194f22..a9a3959ed3e 100644 --- a/modin/experimental/pandas/test/test_io_exp.py +++ b/modin/experimental/pandas/test/test_io_exp.py @@ -298,6 +298,30 @@ def test_parquet_glob(tmp_path, filename): df_equals(read_df, df) +@pytest.mark.skipif( + Engine.get() not in ("Ray", "Unidist", "Dask"), + reason=f"{Engine.get()} does not have experimental API", +) +@pytest.mark.parametrize( + "filename", + ["test_json_glob.json", "test_json_glob*.json"], +) +def test_json_glob(tmp_path, filename): + data = test_data["int_data"] + df = pd.DataFrame(data) + + filename_param = filename + + with ( + warns_that_defaulting_to_pandas() + if filename_param == "test_json_glob.json" + else contextlib.nullcontext() + ): + df.modin.to_json_glob(str(tmp_path / filename)) + read_df = pd.read_json_glob(str(tmp_path / filename)) + df_equals(read_df, df) + + @pytest.mark.skipif( Engine.get() not in ("Ray", "Unidist", "Dask"), reason=f"{Engine.get()} does not have experimental read_custom_text API", diff --git a/modin/pandas/accessor.py b/modin/pandas/accessor.py index 83e43cff8f7..3ee22a08561 100644 --- a/modin/pandas/accessor.py +++ b/modin/pandas/accessor.py @@ -296,3 +296,51 @@ def to_parquet_glob( storage_options=storage_options, **kwargs, ) + + def to_json_glob( + self, + path_or_buf=None, + orient=None, + date_format=None, + double_precision=10, + force_ascii=True, + date_unit="ms", + default_handler=None, + lines=False, + compression="infer", + index=None, + indent=None, + storage_options: StorageOptions = None, + mode="w", + ) -> None: # noqa: PR01 + """ + Convert the object to a JSON string. + + Notes + ----- + * Only string type supported for `path_or_buf` argument. + * The rest of the arguments are the same as for `pandas.to_json`. + """ + from modin.experimental.pandas.io import to_json_glob + + if path_or_buf is None: + raise NotImplementedError( + "`to_json_glob` doesn't support path_or_buf=None, use `to_json` in that case." + ) + + to_json_glob( + self._data, + path_or_buf=path_or_buf, + orient=orient, + date_format=date_format, + double_precision=double_precision, + force_ascii=force_ascii, + date_unit=date_unit, + default_handler=default_handler, + lines=lines, + compression=compression, + index=index, + indent=indent, + storage_options=storage_options, + mode=mode, + ) diff --git a/modin/pandas/base.py b/modin/pandas/base.py index 7671612e004..595cd063730 100644 --- a/modin/pandas/base.py +++ b/modin/pandas/base.py @@ -3241,8 +3241,12 @@ def to_json( """ Convert the object to a JSON string. """ - return self._default_to_pandas( - "to_json", + from modin.core.execution.dispatching.factories.dispatcher import ( + FactoryDispatcher, + ) + + return FactoryDispatcher.to_json( + self._query_compiler, path_or_buf, orient=orient, date_format=date_format, From 72de8c022943908bb2097ee7dac934b19e5dabc2 Mon Sep 17 00:00:00 2001 From: Dmitry Chigarev Date: Wed, 24 Jan 2024 18:02:35 +0100 Subject: [PATCH 145/201] PERF-#6876: Skip the masking stage on 'iloc' where beneficial (#6878) Signed-off-by: Dmitry Chigarev Co-authored-by: Anatoly Myachev --- .../dataframe/pandas/dataframe/dataframe.py | 57 +++++++++++++++---- 1 file changed, 46 insertions(+), 11 deletions(-) diff --git a/modin/core/dataframe/pandas/dataframe/dataframe.py b/modin/core/dataframe/pandas/dataframe/dataframe.py index 7da33f52284..484a9260f6f 100644 --- a/modin/core/dataframe/pandas/dataframe/dataframe.py +++ b/modin/core/dataframe/pandas/dataframe/dataframe.py @@ -1161,12 +1161,22 @@ def _take_2d_positional( extra_log="Mask takes only list-like numeric indexers, " + f"received: {type(indexer)}", ) + if isinstance(indexer, list): + indexer = np.array(indexer, dtype=np.int64) indexers.append(indexer) row_positions, col_positions = indexers if col_positions is None and row_positions is None: return self.copy() + # quite fast check that allows skip sorting + must_sort_row_pos = row_positions is not None and not np.all( + row_positions[1:] >= row_positions[:-1] + ) + must_sort_col_pos = col_positions is not None and not np.all( + col_positions[1:] >= col_positions[:-1] + ) + if col_positions is None and row_positions is not None: # Check if the optimization that first takes part of the data using the mask # operation so that later less data is concatenated into a whole column is useful. @@ -1175,18 +1185,40 @@ def _take_2d_positional( all_rows = None if self.has_materialized_index: all_rows = len(self.index) - elif self._row_lengths_cache: - all_rows = sum(self._row_lengths_cache) - if all_rows: - if len(row_positions) > 0.9 * all_rows: - return self._reorder_labels( - row_positions=row_positions, col_positions=col_positions - ) - + elif self._row_lengths_cache or must_sort_row_pos: + all_rows = sum(self.row_lengths) + + # 'base_num_cols' specifies the number of columns that the dataframe should have + # in order to jump to 'reordered_labels' in case of len(row_positions) / len(self) >= base_ratio; + # these variables may be a subject to change in order to tune performance more accurately + base_num_cols = 10 + base_ratio = 0.2 + # Example: + # len(self.columns): 10 == base_num_cols -> min ratio to jump to reorder_labels: 0.2 == base_ratio + # len(self.columns): 15 -> min ratio to jump to reorder_labels: 0.3 + # len(self.columns): 20 -> min ratio to jump to reorder_labels: 0.4 + # ... + # len(self.columns): 49 -> min ratio to jump to reorder_labels: 0.98 + # len(self.columns): 50 -> min ratio to jump to reorder_labels: 1.0 + # len(self.columns): 55 -> min ratio to jump to reorder_labels: 1.0 + # ... + if (all_rows and len(row_positions) > 0.9 * all_rows) or ( + must_sort_row_pos + and len(row_positions) * base_num_cols + >= min( + all_rows * len(self.columns) * base_ratio, + len(row_positions) * base_num_cols, + ) + ): + return self._reorder_labels( + row_positions=row_positions, col_positions=col_positions + ) sorted_row_positions = sorted_col_positions = None - if row_positions is not None: - sorted_row_positions = self._get_sorted_positions(row_positions) + if must_sort_row_pos: + sorted_row_positions = self._get_sorted_positions(row_positions) + else: + sorted_row_positions = row_positions # Get dict of row_parts as {row_index: row_internal_indices} row_partitions_dict = self._get_dict_of_block_index( 0, sorted_row_positions, are_indices_sorted=True @@ -1201,7 +1233,10 @@ def _take_2d_positional( new_index = self.copy_index_cache(copy_lengths=True) if col_positions is not None: - sorted_col_positions = self._get_sorted_positions(col_positions) + if must_sort_col_pos: + sorted_col_positions = self._get_sorted_positions(col_positions) + else: + sorted_col_positions = col_positions # Get dict of col_parts as {col_index: col_internal_indices} col_partitions_dict = self._get_dict_of_block_index( 1, sorted_col_positions, are_indices_sorted=True From 13762a18c1e35d1254ebe1db02c170c68aa05bb5 Mon Sep 17 00:00:00 2001 From: Andrey Pavlenko Date: Wed, 24 Jan 2024 18:27:21 +0100 Subject: [PATCH 146/201] FEAT-#6835: Do not put binary functions to the Ray storage multiple times. (#6836) Signed-off-by: Andrey Pavlenko --- modin/core/dataframe/algebra/binary.py | 4 ++- .../dataframe/pandas/dataframe/dataframe.py | 10 ++++++- .../pandas/partitioning/partition_manager.py | 30 ++++++++++++++++--- .../execution/ray/common/engine_wrapper.py | 19 ++++++++++++ 4 files changed, 57 insertions(+), 6 deletions(-) diff --git a/modin/core/dataframe/algebra/binary.py b/modin/core/dataframe/algebra/binary.py index f19040cc104..af0c6ee7e8e 100644 --- a/modin/core/dataframe/algebra/binary.py +++ b/modin/core/dataframe/algebra/binary.py @@ -415,7 +415,9 @@ def caller( ): shape_hint = "column" new_modin_frame = query_compiler._modin_frame.map( - lambda df: func(df, other, *args, **kwargs), + func, + func_args=(other, *args), + func_kwargs=kwargs, dtypes=dtypes, ) return query_compiler.__constructor__( diff --git a/modin/core/dataframe/pandas/dataframe/dataframe.py b/modin/core/dataframe/pandas/dataframe/dataframe.py index 484a9260f6f..00c6f1f17fa 100644 --- a/modin/core/dataframe/pandas/dataframe/dataframe.py +++ b/modin/core/dataframe/pandas/dataframe/dataframe.py @@ -2128,6 +2128,8 @@ def map( func: Callable, dtypes: Optional[str] = None, new_columns: Optional[pandas.Index] = None, + func_args=None, + func_kwargs=None, ) -> "PandasDataframe": """ Perform a function that maps across the entire dataset. @@ -2143,13 +2145,19 @@ def map( new_columns : pandas.Index, optional New column labels of the result, its length has to be identical to the older columns. If not specified, old column labels are preserved. + func_args : iterable, optional + Positional arguments for the 'func' callable. + func_kwargs : dict, optional + Keyword arguments for the 'func' callable. Returns ------- PandasDataframe A new dataframe. """ - new_partitions = self._partition_mgr_cls.map_partitions(self._partitions, func) + new_partitions = self._partition_mgr_cls.map_partitions( + self._partitions, func, func_args, func_kwargs + ) if new_columns is not None and self.has_materialized_columns: assert len(new_columns) == len( self.columns diff --git a/modin/core/dataframe/pandas/partitioning/partition_manager.py b/modin/core/dataframe/pandas/partitioning/partition_manager.py index 3a1dd63e555..0e9d35cf545 100644 --- a/modin/core/dataframe/pandas/partitioning/partition_manager.py +++ b/modin/core/dataframe/pandas/partitioning/partition_manager.py @@ -566,7 +566,13 @@ def broadcast_axis_partitions( @classmethod @wait_computations_if_benchmark_mode - def map_partitions(cls, partitions, map_func): + def map_partitions( + cls, + partitions, + map_func, + func_args=None, + func_kwargs=None, + ): """ Apply `map_func` to every partition in `partitions`. @@ -576,6 +582,10 @@ def map_partitions(cls, partitions, map_func): Partitions housing the data of Modin Frame. map_func : callable Function to apply. + func_args : iterable, optional + Positional arguments for the 'map_func'. + func_kwargs : dict, optional + Keyword arguments for the 'map_func'. Returns ------- @@ -585,14 +595,23 @@ def map_partitions(cls, partitions, map_func): preprocessed_map_func = cls.preprocess_func(map_func) return np.array( [ - [part.apply(preprocessed_map_func) for part in row_of_parts] + [ + part.apply( + preprocessed_map_func, + *func_args if func_args is not None else (), + **func_kwargs if func_kwargs is not None else {}, + ) + for part in row_of_parts + ] for row_of_parts in partitions ] ) @classmethod @wait_computations_if_benchmark_mode - def lazy_map_partitions(cls, partitions, map_func, func_args=None): + def lazy_map_partitions( + cls, partitions, map_func, func_args=None, func_kwargs=None + ): """ Apply `map_func` to every partition in `partitions` *lazily*. @@ -604,6 +623,8 @@ def lazy_map_partitions(cls, partitions, map_func, func_args=None): Function to apply. func_args : iterable, optional Positional arguments for the 'map_func'. + func_kwargs : dict, optional + Keyword arguments for the 'map_func'. Returns ------- @@ -616,7 +637,8 @@ def lazy_map_partitions(cls, partitions, map_func, func_args=None): [ part.add_to_apply_calls( preprocessed_map_func, - *(tuple() if func_args is None else func_args), + *func_args if func_args is not None else (), + **func_kwargs if func_kwargs is not None else {}, ) for part in row ] diff --git a/modin/core/execution/ray/common/engine_wrapper.py b/modin/core/execution/ray/common/engine_wrapper.py index 8e20033d20d..e274d28c764 100644 --- a/modin/core/execution/ray/common/engine_wrapper.py +++ b/modin/core/execution/ray/common/engine_wrapper.py @@ -18,10 +18,14 @@ """ import asyncio +import os +from types import FunctionType import ray from ray.util.client.common import ClientObjectRef +from modin.error_message import ErrorMessage + @ray.remote def _deploy_ray_func(func, *args, **kwargs): # pragma: no cover @@ -48,6 +52,8 @@ def _deploy_ray_func(func, *args, **kwargs): # pragma: no cover class RayWrapper: """Mixin that provides means of running functions remotely and getting local results.""" + _func_cache = {} + @classmethod def deploy(cls, func, f_args=None, f_kwargs=None, num_returns=1): """ @@ -127,6 +133,19 @@ def put(cls, data, **kwargs): ray.ObjectID Ray object identifier to get the value by. """ + if isinstance(data, FunctionType): + qname = data.__qualname__ + if "" not in qname and "" not in qname: + ref = cls._func_cache.get(data, None) + if ref is None: + if len(cls._func_cache) < 1024: + ref = ray.put(data) + cls._func_cache[data] = ref + else: + msg = "To many functions in the RayWrapper cache!" + assert "MODIN_GITHUB_CI" not in os.environ, msg + ErrorMessage.warn(msg) + return ref return ray.put(data, **kwargs) @classmethod From 74bb6fda985d13b21939135df3db44850a5ade49 Mon Sep 17 00:00:00 2001 From: Anatoly Myachev Date: Fri, 26 Jan 2024 10:40:40 +0100 Subject: [PATCH 147/201] FIX-#6881: Make sure 'astype' works correctly with 'int32' and 'float32' dtypes (#6884) Signed-off-by: Anatoly Myachev --- modin/core/dataframe/pandas/dataframe/dataframe.py | 6 +----- modin/pandas/test/test_series.py | 6 ++++++ 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/modin/core/dataframe/pandas/dataframe/dataframe.py b/modin/core/dataframe/pandas/dataframe/dataframe.py index 00c6f1f17fa..0d5da6c06d5 100644 --- a/modin/core/dataframe/pandas/dataframe/dataframe.py +++ b/modin/core/dataframe/pandas/dataframe/dataframe.py @@ -1638,12 +1638,8 @@ def astype(self, col_dtypes, errors: str = "raise"): except TypeError: new_dtype = dtype - if dtype != np.int32 and new_dtype == np.int32: - new_dtypes[column] = np.dtype("int64") - elif dtype != np.float32 and new_dtype == np.float32: - new_dtypes[column] = np.dtype("float64") # We cannot infer without computing the dtype if - elif isinstance(new_dtype, str) and new_dtype == "category": + if isinstance(new_dtype, str) and new_dtype == "category": new_dtypes[column] = LazyProxyCategoricalDtype._build_proxy( # Actual parent will substitute `None` at `.set_dtypes_cache` parent=None, diff --git a/modin/pandas/test/test_series.py b/modin/pandas/test/test_series.py index f8ee06503b8..83bc6a6075d 100644 --- a/modin/pandas/test/test_series.py +++ b/modin/pandas/test/test_series.py @@ -1113,6 +1113,12 @@ def test_astype(data): # dict to astype() for a series with no name. +@pytest.mark.parametrize("dtype", ["int32", "float32"]) +def test_astype_32_types(dtype): + # https://github.com/modin-project/modin/issues/6881 + assert pd.Series([1, 2, 6]).astype(dtype).dtype == dtype + + @pytest.mark.parametrize( "data", [["A", "A", "B", "B", "A"], [1, 1, 2, 1, 2, 2, 3, 1, 2, 1, 2]] ) From 808b0bfffc55f93b124dea40a768b54a4a525bde Mon Sep 17 00:00:00 2001 From: Anatoly Myachev Date: Fri, 26 Jan 2024 13:07:52 +0100 Subject: [PATCH 148/201] TEST-#6868: Remove tests for 'gs' remote protocol since we rely on 'fsspec' (#6882) Signed-off-by: Anatoly Myachev --- modin/experimental/pandas/test/test_io_exp.py | 7 +------ modin/pandas/test/test_io.py | 7 ------- 2 files changed, 1 insertion(+), 13 deletions(-) diff --git a/modin/experimental/pandas/test/test_io_exp.py b/modin/experimental/pandas/test/test_io_exp.py index a9a3959ed3e..47a68e7289e 100644 --- a/modin/experimental/pandas/test/test_io_exp.py +++ b/modin/experimental/pandas/test/test_io_exp.py @@ -179,14 +179,9 @@ def test_read_single_csv_with_parse_dates(self, parse_dates): "path", [ "s3://modin-test/modin-bugs/multiple_csv/test_data*.csv", - "gs://modin-testing/testing/multiple_csv/test_data*.csv", ], ) def test_read_multiple_csv_cloud_store(path, s3_resource, s3_storage_options): - storage_options_new = {"anon": True} - if path.startswith("s3"): - storage_options_new = s3_storage_options - def _pandas_read_csv_glob(path, storage_options): pandas_dfs = [ pandas.read_csv( @@ -202,7 +197,7 @@ def _pandas_read_csv_glob(path, storage_options): lambda module, **kwargs: pd.read_csv_glob(path, **kwargs).reset_index(drop=True) if hasattr(module, "read_csv_glob") else _pandas_read_csv_glob(path, **kwargs), - storage_options=storage_options_new, + storage_options=s3_storage_options, ) diff --git a/modin/pandas/test/test_io.py b/modin/pandas/test/test_io.py index 0d5c4da47a1..b74504deef9 100644 --- a/modin/pandas/test/test_io.py +++ b/modin/pandas/test/test_io.py @@ -882,13 +882,6 @@ def test_read_csv_categories(self): dtype={"one": "int64", "two": "category"}, ) - def test_read_csv_google_cloud_storage(self): - eval_io( - fn_name="read_csv", - # read_csv kwargs - filepath_or_buffer="gs://modin-testing/testing/multiple_csv/test_data0.csv", - ) - @pytest.mark.parametrize("encoding", [None, "utf-8"]) @pytest.mark.parametrize("encoding_errors", ["strict", "ignore"]) @pytest.mark.parametrize( From 128b286e05b2def6f42a73e2d8ac87ae31531502 Mon Sep 17 00:00:00 2001 From: Anatoly Myachev Date: Fri, 26 Jan 2024 16:59:40 +0100 Subject: [PATCH 149/201] TEST-#6885: Switch to black>=24.1.0 (#6887) Signed-off-by: Anatoly Myachev --- .github/workflows/ci.yml | 2 +- environment-dev.yml | 2 +- modin/config/pubsub.py | 16 ++-- modin/config/test/test_envvars.py | 6 +- .../dataframe/pandas/dataframe/dataframe.py | 36 +++++--- .../core/dataframe/pandas/dataframe/utils.py | 10 ++- .../core/dataframe/pandas/metadata/dtypes.py | 12 +-- .../pandas/partitioning/partition_manager.py | 88 +++++++++++-------- .../partitioning/virtual_partition.py | 8 +- .../partitioning/virtual_partition.py | 8 +- .../partitioning/virtual_partition.py | 8 +- .../column_stores/column_store_dispatcher.py | 8 +- .../io/column_stores/parquet_dispatcher.py | 6 +- modin/core/io/text/text_file_dispatcher.py | 14 +-- .../storage_formats/base/query_compiler.py | 16 ++-- .../storage_formats/pandas/query_compiler.py | 46 ++++++---- modin/core/storage_formats/pandas/utils.py | 18 ++-- .../hdk_on_native/calcite_builder.py | 8 +- .../hdk_on_native/dataframe/dataframe.py | 16 ++-- .../implementations/hdk_on_native/io/io.py | 24 +++-- modin/experimental/pandas/test/test_io_exp.py | 16 ++-- modin/numpy/indexing.py | 8 +- modin/pandas/general.py | 14 +-- modin/pandas/indexing.py | 12 +-- modin/pandas/io.py | 25 +++--- modin/pandas/series.py | 6 +- modin/pandas/test/dataframe/test_binary.py | 6 +- modin/pandas/test/dataframe/test_indexing.py | 54 +++++++----- .../test/dataframe/test_map_metadata.py | 8 +- modin/pandas/test/test_general.py | 12 ++- modin/pandas/test/test_groupby.py | 18 ++-- modin/pandas/test/test_io.py | 10 ++- modin/pandas/test/utils.py | 16 ++-- .../dataframe_protocol/hdk/utils.py | 7 +- modin/test/test_partition_api.py | 8 +- requirements-dev.txt | 2 +- requirements/env_hdk.yml | 2 +- requirements/env_unidist_linux.yml | 2 +- requirements/env_unidist_win.yml | 2 +- requirements/requirements-no-engine.yml | 2 +- scripts/doc_checker.py | 2 +- 41 files changed, 350 insertions(+), 234 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 37e6f5372ba..841b7d8a4d9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,7 +32,7 @@ jobs: steps: - uses: actions/checkout@v3 - uses: ./.github/actions/python-only - - run: pip install black isort>=5.12 + - run: pip install black>=24.1.0 isort>=5.12 # NOTE: keep the black command here in sync with the pre-commit hook in # /contributing/pre-commit - run: black --check --diff modin/ asv_bench/benchmarks scripts/doc_checker.py diff --git a/environment-dev.yml b/environment-dev.yml index 004ed213b76..20ef24aa1de 100644 --- a/environment-dev.yml +++ b/environment-dev.yml @@ -54,7 +54,7 @@ dependencies: - pytest-xdist>=3.2.0 # code linters - - black>=23.1.0 + - black>=24.1.0 - flake8>=6.0.0 - flake8-no-implicit-concat>=0.3.4 - flake8-print>=5.0.0 diff --git a/modin/config/pubsub.py b/modin/config/pubsub.py index 7c7f2b7fce2..2feca34d5c1 100644 --- a/modin/config/pubsub.py +++ b/modin/config/pubsub.py @@ -153,13 +153,15 @@ class ExactStr(str): for key_value in value.split(",") for key, val in [[v.strip() for v in key_value.split("=", maxsplit=1)]] }, - normalize=lambda value: value - if isinstance(value, dict) - else { - key: int(val) if val.isdigit() else val - for key_value in str(value).split(",") - for key, val in [[v.strip() for v in key_value.split("=", maxsplit=1)]] - }, + normalize=lambda value: ( + value + if isinstance(value, dict) + else { + key: int(val) if val.isdigit() else val + for key_value in str(value).split(",") + for key, val in [[v.strip() for v in key_value.split("=", maxsplit=1)]] + } + ), verify=lambda value: isinstance(value, dict) or ( isinstance(value, str) diff --git a/modin/config/test/test_envvars.py b/modin/config/test/test_envvars.py index b470a4ed5c4..2387388e520 100644 --- a/modin/config/test/test_envvars.py +++ b/modin/config/test/test_envvars.py @@ -118,9 +118,9 @@ def test_hdk_envvar(): # This test is intended to check pyhdk internals. If pyhdk is not available, skip the version check test. pass - os.environ[ - cfg.HdkLaunchParameters.varname - ] = "enable_union=4,enable_thrift_logs=5,enable_lazy_dict_materialization=6" + os.environ[cfg.HdkLaunchParameters.varname] = ( + "enable_union=4,enable_thrift_logs=5,enable_lazy_dict_materialization=6" + ) del cfg.HdkLaunchParameters._value params = cfg.HdkLaunchParameters.get() assert params["enable_union"] == 4 diff --git a/modin/core/dataframe/pandas/dataframe/dataframe.py b/modin/core/dataframe/pandas/dataframe/dataframe.py index 0d5da6c06d5..20003c6f20d 100644 --- a/modin/core/dataframe/pandas/dataframe/dataframe.py +++ b/modin/core/dataframe/pandas/dataframe/dataframe.py @@ -1382,18 +1382,22 @@ def from_labels(self) -> "PandasDataframe": new_row_labels = pandas.RangeIndex(len(self.index)) if self.index.nlevels > 1: level_names = [ - self.index.names[i] - if self.index.names[i] is not None - else "level_{}".format(i) + ( + self.index.names[i] + if self.index.names[i] is not None + else "level_{}".format(i) + ) for i in range(self.index.nlevels) ] else: level_names = [ - self.index.names[0] - if self.index.names[0] is not None - else "index" - if "index" not in self.columns - else "level_{}".format(0) + ( + self.index.names[0] + if self.index.names[0] is not None + else ( + "index" if "index" not in self.columns else "level_{}".format(0) + ) + ) ] names = tuple(level_names) if len(level_names) > 1 else level_names[0] new_dtypes = self.index.to_frame(name=names).dtypes @@ -2924,9 +2928,11 @@ def apply_select_indices( # `axis` given may have changed, we currently just recompute it. # TODO Determine lengths from current lengths if `keep_remaining=False` lengths_objs = { - axis: [len(apply_indices)] - if not keep_remaining - else [self.row_lengths, self.column_widths][axis], + axis: ( + [len(apply_indices)] + if not keep_remaining + else [self.row_lengths, self.column_widths][axis] + ), axis ^ 1: [self.row_lengths, self.column_widths][axis ^ 1], } return self.__constructor__( @@ -3891,9 +3897,11 @@ def join_cols(df, *cols): # Getting futures for columns of non-empty partitions cols = [ part.apply( - lambda df: None - if df.attrs.get(skip_on_aligning_flag, False) - else df.columns + lambda df: ( + None + if df.attrs.get(skip_on_aligning_flag, False) + else df.columns + ) )._data for part in result._partitions.flatten() ] diff --git a/modin/core/dataframe/pandas/dataframe/utils.py b/modin/core/dataframe/pandas/dataframe/utils.py index 56e37c1943d..6bb84df49bb 100644 --- a/modin/core/dataframe/pandas/dataframe/utils.py +++ b/modin/core/dataframe/pandas/dataframe/utils.py @@ -406,11 +406,13 @@ def get_group(grp, key, df): if len(non_na_rows) == 1: groups = [ # taking an empty slice for an index's metadata - pandas.DataFrame(index=df.index[:0], columns=df.columns).astype( - df.dtypes + ( + pandas.DataFrame(index=df.index[:0], columns=df.columns).astype( + df.dtypes + ) + if key != groupby_codes[0] + else non_na_rows ) - if key != groupby_codes[0] - else non_na_rows for key in group_keys ] else: diff --git a/modin/core/dataframe/pandas/metadata/dtypes.py b/modin/core/dataframe/pandas/metadata/dtypes.py index a23cdeb4786..0d228c569db 100644 --- a/modin/core/dataframe/pandas/metadata/dtypes.py +++ b/modin/core/dataframe/pandas/metadata/dtypes.py @@ -498,9 +498,11 @@ def _merge_dtypes( # otherwise, it may indicate missing columns that this 'val' has no info about, # meaning that we shouldn't try computing a new dtype for this column, # so marking it as 'unknown' - i: np.dtype(float) - if val._know_all_names and val._remaining_dtype is None - else "unknown" + i: ( + np.dtype(float) + if val._know_all_names and val._remaining_dtype is None + else "unknown" + ) }, inplace=True, ) @@ -732,8 +734,8 @@ def lazy_get(self, ids: list, numeric_index: bool = False) -> "ModinDtypes": elif callable(self._value): new_self = self.copy() old_value = new_self._value - new_self._value = ( - lambda: old_value().iloc[ids] if numeric_index else old_value()[ids] + new_self._value = lambda: ( + old_value().iloc[ids] if numeric_index else old_value()[ids] ) return new_self ErrorMessage.catch_bugs_and_request_email( diff --git a/modin/core/dataframe/pandas/partitioning/partition_manager.py b/modin/core/dataframe/pandas/partitioning/partition_manager.py index 0e9d35cf545..421b58f29a6 100644 --- a/modin/core/dataframe/pandas/partitioning/partition_manager.py +++ b/modin/core/dataframe/pandas/partitioning/partition_manager.py @@ -383,13 +383,15 @@ def get_partitions(index): new_partitions = np.array( [ - partitions_for_apply[i] - if i not in left_indices - else cls._apply_func_to_list_of_partitions_broadcast( - apply_func, - partitions_for_apply[i], - internal_indices=left_indices[i], - **get_partitions(i), + ( + partitions_for_apply[i] + if i not in left_indices + else cls._apply_func_to_list_of_partitions_broadcast( + apply_func, + partitions_for_apply[i], + internal_indices=left_indices[i], + **get_partitions(i), + ) ) for i in range(len(partitions_for_apply)) if i in left_indices or keep_remaining @@ -946,15 +948,19 @@ def update_bar(f): return parts else: row_lengths = [ - row_chunksize - if i + row_chunksize < len(df) - else len(df) % row_chunksize or row_chunksize + ( + row_chunksize + if i + row_chunksize < len(df) + else len(df) % row_chunksize or row_chunksize + ) for i in range(0, len(df), row_chunksize) ] col_widths = [ - col_chunksize - if i + col_chunksize < len(df.columns) - else len(df.columns) % col_chunksize or col_chunksize + ( + col_chunksize + if i + col_chunksize < len(df.columns) + else len(df.columns) % col_chunksize or col_chunksize + ) for i in range(0, len(df.columns), col_chunksize) ] return parts, row_lengths, col_widths @@ -1206,14 +1212,18 @@ def apply_func_to_select_indices( else: result = np.array( [ - partitions_for_apply[i] - if i not in indices - else cls._apply_func_to_list_of_partitions( - func, - partitions_for_apply[i], - func_dict={ - idx: dict_func[idx] for idx in indices[i] if idx >= 0 - }, + ( + partitions_for_apply[i] + if i not in indices + else cls._apply_func_to_list_of_partitions( + func, + partitions_for_apply[i], + func_dict={ + idx: dict_func[idx] + for idx in indices[i] + if idx >= 0 + }, + ) ) for i in range(len(partitions_for_apply)) ] @@ -1239,10 +1249,14 @@ def apply_func_to_select_indices( # remaining (non-updated) blocks in their original position. result = np.array( [ - partitions_for_apply[i] - if i not in indices - else cls._apply_func_to_list_of_partitions( - func, partitions_for_apply[i], internal_indices=indices[i] + ( + partitions_for_apply[i] + if i not in indices + else cls._apply_func_to_list_of_partitions( + func, + partitions_for_apply[i], + internal_indices=indices[i], + ) ) for i in range(len(partitions_for_apply)) ] @@ -1331,12 +1345,14 @@ def apply_func_to_select_indices_along_full_axis( else: result = np.array( [ - partitions_for_remaining[i] - if i not in indices - else cls._apply_func_to_list_of_partitions( - preprocessed_func, - partitions_for_apply[i], - func_dict={idx: dict_func[idx] for idx in indices[i]}, + ( + partitions_for_remaining[i] + if i not in indices + else cls._apply_func_to_list_of_partitions( + preprocessed_func, + partitions_for_apply[i], + func_dict={idx: dict_func[idx] for idx in indices[i]}, + ) ) for i in range(len(partitions_for_apply)) ] @@ -1354,10 +1370,12 @@ def apply_func_to_select_indices_along_full_axis( # See notes in `apply_func_to_select_indices` result = np.array( [ - partitions_for_remaining[i] - if i not in indices - else partitions_for_apply[i].apply( - preprocessed_func, internal_indices=indices[i] + ( + partitions_for_remaining[i] + if i not in indices + else partitions_for_apply[i].apply( + preprocessed_func, internal_indices=indices[i] + ) ) for i in range(len(partitions_for_remaining)) ] diff --git a/modin/core/execution/dask/implementations/pandas_on_dask/partitioning/virtual_partition.py b/modin/core/execution/dask/implementations/pandas_on_dask/partitioning/virtual_partition.py index 5681eb23b01..a78870205f5 100644 --- a/modin/core/execution/dask/implementations/pandas_on_dask/partitioning/virtual_partition.py +++ b/modin/core/execution/dask/implementations/pandas_on_dask/partitioning/virtual_partition.py @@ -94,9 +94,11 @@ def deploy_splitting_func( *partitions, ), f_kwargs={"extract_metadata": extract_metadata}, - num_returns=num_splits * (1 + cls._PARTITIONS_METADATA_LEN) - if extract_metadata - else num_splits, + num_returns=( + num_splits * (1 + cls._PARTITIONS_METADATA_LEN) + if extract_metadata + else num_splits + ), pure=False, ) diff --git a/modin/core/execution/ray/implementations/pandas_on_ray/partitioning/virtual_partition.py b/modin/core/execution/ray/implementations/pandas_on_ray/partitioning/virtual_partition.py index 07144413f57..2f67bf94d73 100644 --- a/modin/core/execution/ray/implementations/pandas_on_ray/partitioning/virtual_partition.py +++ b/modin/core/execution/ray/implementations/pandas_on_ray/partitioning/virtual_partition.py @@ -110,9 +110,11 @@ def deploy_splitting_func( extract_metadata=False, ): return _deploy_ray_func.options( - num_returns=num_splits * (1 + cls._PARTITIONS_METADATA_LEN) - if extract_metadata - else num_splits, + num_returns=( + num_splits * (1 + cls._PARTITIONS_METADATA_LEN) + if extract_metadata + else num_splits + ), ).remote( cls._get_deploy_split_func(), *f_args, diff --git a/modin/core/execution/unidist/implementations/pandas_on_unidist/partitioning/virtual_partition.py b/modin/core/execution/unidist/implementations/pandas_on_unidist/partitioning/virtual_partition.py index 012456998a7..002193f1881 100644 --- a/modin/core/execution/unidist/implementations/pandas_on_unidist/partitioning/virtual_partition.py +++ b/modin/core/execution/unidist/implementations/pandas_on_unidist/partitioning/virtual_partition.py @@ -112,9 +112,11 @@ def deploy_splitting_func( extract_metadata=False, ): return _deploy_unidist_func.options( - num_returns=num_splits * (1 + cls._PARTITIONS_METADATA_LEN) - if extract_metadata - else num_splits, + num_returns=( + num_splits * (1 + cls._PARTITIONS_METADATA_LEN) + if extract_metadata + else num_splits + ), ).remote( cls._get_deploy_split_func(), axis, diff --git a/modin/core/io/column_stores/column_store_dispatcher.py b/modin/core/io/column_stores/column_store_dispatcher.py index 602aad4c121..a35e1469002 100644 --- a/modin/core/io/column_stores/column_store_dispatcher.py +++ b/modin/core/io/column_stores/column_store_dispatcher.py @@ -135,9 +135,11 @@ def build_index(cls, partition_ids): row_lengths = [index_len] + [0 for _ in range(num_partitions - 1)] else: row_lengths = [ - index_chunksize - if (i + 1) * index_chunksize < index_len - else max(0, index_len - (index_chunksize * i)) + ( + index_chunksize + if (i + 1) * index_chunksize < index_len + else max(0, index_len - (index_chunksize * i)) + ) for i in range(num_partitions) ] return index, row_lengths diff --git a/modin/core/io/column_stores/parquet_dispatcher.py b/modin/core/io/column_stores/parquet_dispatcher.py index 63d9adb2247..0b40ea3f16f 100644 --- a/modin/core/io/column_stores/parquet_dispatcher.py +++ b/modin/core/io/column_stores/parquet_dispatcher.py @@ -848,9 +848,9 @@ def func(df, **kw): # pragma: no cover """ compression = kwargs["compression"] partition_idx = kw["partition_idx"] - kwargs[ - "path" - ] = f"{output_path}/part-{partition_idx:04d}.{compression}.parquet" + kwargs["path"] = ( + f"{output_path}/part-{partition_idx:04d}.{compression}.parquet" + ) df.to_parquet(**kwargs) return pandas.DataFrame() diff --git a/modin/core/io/text/text_file_dispatcher.py b/modin/core/io/text/text_file_dispatcher.py index 4516b1a0803..d80e5ecf6dc 100644 --- a/modin/core/io/text/text_file_dispatcher.py +++ b/modin/core/io/text/text_file_dispatcher.py @@ -582,11 +582,15 @@ def _define_metadata( # if num_splits == 4, len(column_names) == 80 and column_chunksize == 32, # column_widths will be [32, 32, 16, 0] column_widths = [ - column_chunksize - if len(column_names) > (column_chunksize * (i + 1)) - else 0 - if len(column_names) < (column_chunksize * i) - else len(column_names) - (column_chunksize * i) + ( + column_chunksize + if len(column_names) > (column_chunksize * (i + 1)) + else ( + 0 + if len(column_names) < (column_chunksize * i) + else len(column_names) - (column_chunksize * i) + ) + ) for i in range(num_splits) ] diff --git a/modin/core/storage_formats/base/query_compiler.py b/modin/core/storage_formats/base/query_compiler.py index 1e4233e1248..b4f6a1e1a1f 100644 --- a/modin/core/storage_formats/base/query_compiler.py +++ b/modin/core/storage_formats/base/query_compiler.py @@ -6301,9 +6301,11 @@ def expanding_corr( other_for_default = ( other if other is None - else other.to_pandas().squeeze(axis=1) - if squeeze_other - else other.to_pandas() + else ( + other.to_pandas().squeeze(axis=1) + if squeeze_other + else other.to_pandas() + ) ) return ExpandingDefault.register( pandas.core.window.expanding.Expanding.corr, @@ -6347,9 +6349,11 @@ def expanding_cov( other_for_default = ( other if other is None - else other.to_pandas().squeeze(axis=1) - if squeeze_other - else other.to_pandas() + else ( + other.to_pandas().squeeze(axis=1) + if squeeze_other + else other.to_pandas() + ) ) return ExpandingDefault.register( pandas.core.window.expanding.Expanding.cov, diff --git a/modin/core/storage_formats/pandas/query_compiler.py b/modin/core/storage_formats/pandas/query_compiler.py index 4566037205f..292e696e6da 100644 --- a/modin/core/storage_formats/pandas/query_compiler.py +++ b/modin/core/storage_formats/pandas/query_compiler.py @@ -846,9 +846,11 @@ def _reset(df, *axis_lengths, partition_idx): # pragma: no cover # into the data. We will replace the old levels later. new_copy.index = self.index.droplevel(keep_levels) new_copy.index.names = [ - "level_{}".format(level_value) - if new_copy.index.names[level_index] is None - else new_copy.index.names[level_index] + ( + "level_{}".format(level_value) + if new_copy.index.names[level_index] is None + else new_copy.index.names[level_index] + ) for level_index, level_value in enumerate(uniq_sorted_level) ] new_modin_frame = new_copy._modin_frame.from_labels() @@ -1418,9 +1420,11 @@ def expanding_cov( other_for_pandas = ( other if other is None - else other.to_pandas().squeeze(axis=1) - if squeeze_other - else other.to_pandas() + else ( + other.to_pandas().squeeze(axis=1) + if squeeze_other + else other.to_pandas() + ) ) if len(self.columns) > 1: # computing covariance for each column requires having the other columns, @@ -1466,9 +1470,11 @@ def expanding_corr( other_for_pandas = ( other if other is None - else other.to_pandas().squeeze(axis=1) - if squeeze_other - else other.to_pandas() + else ( + other.to_pandas().squeeze(axis=1) + if squeeze_other + else other.to_pandas() + ) ) if len(self.columns) > 1: # computing correlation for each column requires having the other columns, @@ -1774,9 +1780,7 @@ def get_unique_level_values(index): new_index = ( get_unique_level_values(index) if consider_index - else index - if isinstance(index, list) - else [index] + else index if isinstance(index, list) else [index] ) new_columns = ( @@ -2644,9 +2648,11 @@ def rank(self, **kwargs): axis, lambda df: df.rank(**kwargs), new_index=self._modin_frame.copy_index_cache(copy_lengths=True), - new_columns=self._modin_frame.copy_columns_cache(copy_lengths=True) - if not numeric_only - else None, + new_columns=( + self._modin_frame.copy_columns_cache(copy_lengths=True) + if not numeric_only + else None + ), dtypes=np.float64, sync_labels=False, ) @@ -4136,10 +4142,12 @@ def compute_groupby(df, drop=False, partition_idx=0): ) else: new_index_names = tuple( - None - if isinstance(name, str) - and name.startswith(MODIN_UNNAMED_SERIES_LABEL) - else name + ( + None + if isinstance(name, str) + and name.startswith(MODIN_UNNAMED_SERIES_LABEL) + else name + ) for name in result.index.names ) result.index.names = new_index_names diff --git a/modin/core/storage_formats/pandas/utils.py b/modin/core/storage_formats/pandas/utils.py index b8f267b3150..3fdb32fb032 100644 --- a/modin/core/storage_formats/pandas/utils.py +++ b/modin/core/storage_formats/pandas/utils.py @@ -98,9 +98,13 @@ def split_result_of_axis_func_pandas(axis, num_splits, result, length_list=None) return [ # Sliced MultiIndex still stores all encoded values of the original index, explicitly # asking it to drop unused values in order to save memory. - chunk.set_axis(chunk.axes[axis].remove_unused_levels(), axis=axis, copy=False) - if isinstance(chunk.axes[axis], pandas.MultiIndex) - else chunk + ( + chunk.set_axis( + chunk.axes[axis].remove_unused_levels(), axis=axis, copy=False + ) + if isinstance(chunk.axes[axis], pandas.MultiIndex) + else chunk + ) for chunk in chunked ] @@ -123,9 +127,11 @@ def get_length_list(axis_len: int, num_splits: int) -> list: """ chunksize = compute_chunksize(axis_len, num_splits) return [ - chunksize - if (i + 1) * chunksize <= axis_len - else max(0, axis_len - i * chunksize) + ( + chunksize + if (i + 1) * chunksize <= axis_len + else max(0, axis_len - i * chunksize) + ) for i in range(num_splits) ] diff --git a/modin/experimental/core/execution/native/implementations/hdk_on_native/calcite_builder.py b/modin/experimental/core/execution/native/implementations/hdk_on_native/calcite_builder.py index 9b7cfb239c6..08ed41e6104 100644 --- a/modin/experimental/core/execution/native/implementations/hdk_on_native/calcite_builder.py +++ b/modin/experimental/core/execution/native/implementations/hdk_on_native/calcite_builder.py @@ -1024,9 +1024,11 @@ def _process_groupby(self, op): ): trans = self._input_ctx()._maybe_copy_and_translate_expr proj_exprs = [ - trans(frame.ref(c).cast(bool_cols[c])) - if c in bool_cols - else self._ref(frame, c) + ( + trans(frame.ref(c).cast(bool_cols[c])) + if c in bool_cols + else self._ref(frame, c) + ) for c in proj_cols ] else: diff --git a/modin/experimental/core/execution/native/implementations/hdk_on_native/dataframe/dataframe.py b/modin/experimental/core/execution/native/implementations/hdk_on_native/dataframe/dataframe.py index b5932a632ed..b567a89e32a 100644 --- a/modin/experimental/core/execution/native/implementations/hdk_on_native/dataframe/dataframe.py +++ b/modin/experimental/core/execution/native/implementations/hdk_on_native/dataframe/dataframe.py @@ -1462,9 +1462,11 @@ def _join_arrow_columns(self, other_modin_frames): for f in frames ): tables = [ - t - if isinstance(t := f._partitions[0][0].get(), pyarrow.Table) - else t.to_arrow() + ( + t + if isinstance(t := f._partitions[0][0].get(), pyarrow.Table) + else t.to_arrow() + ) for f in frames ] column_names = [c for t in tables for c in t.column_names] @@ -2323,9 +2325,11 @@ def reset_index(self, drop): Index, data=exprs.keys(), dtype="O", - name=self.columns.names - if isinstance(self.columns, MultiIndex) - else self.columns.name, + name=( + self.columns.names + if isinstance(self.columns, MultiIndex) + else self.columns.name + ), ) return self.__constructor__( columns=new_columns, diff --git a/modin/experimental/core/execution/native/implementations/hdk_on_native/io/io.py b/modin/experimental/core/execution/native/implementations/hdk_on_native/io/io.py index 332f53f12af..654082b859b 100644 --- a/modin/experimental/core/execution/native/implementations/hdk_on_native/io/io.py +++ b/modin/experimental/core/execution/native/implementations/hdk_on_native/io/io.py @@ -199,17 +199,23 @@ def get_col_names(): column_types=column_types, null_values=None, # we need to add default true/false_values like Pandas does - true_values=true_values + ["TRUE", "True", "true"] - if true_values is not None - else true_values, - false_values=false_values + ["False", "FALSE", "false"] - if false_values is not None - else false_values, + true_values=( + true_values + ["TRUE", "True", "true"] + if true_values is not None + else true_values + ), + false_values=( + false_values + ["False", "FALSE", "false"] + if false_values is not None + else false_values + ), # timestamp fields should be handled as strings if parse_dates # didn't passed explicitly as an array or a dict - timestamp_parsers=[""] - if parse_dates is None or isinstance(parse_dates, bool) - else None, + timestamp_parsers=( + [""] + if parse_dates is None or isinstance(parse_dates, bool) + else None + ), strings_can_be_null=None, include_columns=usecols_md, include_missing_columns=None, diff --git a/modin/experimental/pandas/test/test_io_exp.py b/modin/experimental/pandas/test/test_io_exp.py index 47a68e7289e..d2a25cc3586 100644 --- a/modin/experimental/pandas/test/test_io_exp.py +++ b/modin/experimental/pandas/test/test_io_exp.py @@ -194,9 +194,11 @@ def _pandas_read_csv_glob(path, storage_options): eval_general( pd, pandas, - lambda module, **kwargs: pd.read_csv_glob(path, **kwargs).reset_index(drop=True) - if hasattr(module, "read_csv_glob") - else _pandas_read_csv_glob(path, **kwargs), + lambda module, **kwargs: ( + pd.read_csv_glob(path, **kwargs).reset_index(drop=True) + if hasattr(module, "read_csv_glob") + else _pandas_read_csv_glob(path, **kwargs) + ), storage_options=s3_storage_options, ) @@ -229,9 +231,11 @@ def _pandas_read_csv_glob(path, storage_options): eval_general( pd, pandas, - lambda module, **kwargs: pd.read_csv_glob(s3_path, **kwargs) - if hasattr(module, "read_csv_glob") - else _pandas_read_csv_glob(s3_path, **kwargs), + lambda module, **kwargs: ( + pd.read_csv_glob(s3_path, **kwargs) + if hasattr(module, "read_csv_glob") + else _pandas_read_csv_glob(s3_path, **kwargs) + ), storage_options=s3_storage_options | storage_options_extra, ) diff --git a/modin/numpy/indexing.py b/modin/numpy/indexing.py index c0de8a4fe44..b598577a34d 100644 --- a/modin/numpy/indexing.py +++ b/modin/numpy/indexing.py @@ -451,9 +451,11 @@ def get_axis(axis): ) row_lookup_len, col_lookup_len = [ - len(lookup) - if not isinstance(lookup, slice) - else compute_sliced_len(lookup, len(get_axis(i))) + ( + len(lookup) + if not isinstance(lookup, slice) + else compute_sliced_len(lookup, len(get_axis(i))) + ) for i, lookup in enumerate([row_lookup, col_lookup]) ] diff --git a/modin/pandas/general.py b/modin/pandas/general.py index 47ec30ddaa3..d4d0ba6f538 100644 --- a/modin/pandas/general.py +++ b/modin/pandas/general.py @@ -496,11 +496,15 @@ def concat( # dataframe to a series on axis=0, pandas ignores the name of the series, # and this check aims to mirror that (possibly buggy) functionality list_of_objs = [ - obj._query_compiler - if isinstance(obj, DataFrame) - else DataFrame(obj.rename())._query_compiler - if isinstance(obj, (pandas.Series, Series)) and axis == 0 - else DataFrame(obj)._query_compiler + ( + obj._query_compiler + if isinstance(obj, DataFrame) + else ( + DataFrame(obj.rename())._query_compiler + if isinstance(obj, (pandas.Series, Series)) and axis == 0 + else DataFrame(obj)._query_compiler + ) + ) for obj in list_of_objs ] if keys is None and isinstance(objs, dict): diff --git a/modin/pandas/indexing.py b/modin/pandas/indexing.py index f6fbcb7833c..08bfb995efe 100644 --- a/modin/pandas/indexing.py +++ b/modin/pandas/indexing.py @@ -390,9 +390,7 @@ def _get_pandas_object_from_qc_view( None if (col_scalar and row_scalar) or (row_multiindex_full_lookup and col_multiindex_full_lookup) - else 1 - if col_scalar or col_multiindex_full_lookup - else 0 + else 1 if col_scalar or col_multiindex_full_lookup else 0 ) res_df = self.df.__constructor__(query_compiler=qc_view) @@ -477,9 +475,11 @@ def get_axis(axis): return self.qc.index if axis == 0 else self.qc.columns row_lookup_len, col_lookup_len = [ - len(lookup) - if not isinstance(lookup, slice) - else compute_sliced_len(lookup, len(get_axis(i))) + ( + len(lookup) + if not isinstance(lookup, slice) + else compute_sliced_len(lookup, len(get_axis(i))) + ) for i, lookup in enumerate([row_lookup, col_lookup]) ] diff --git a/modin/pandas/io.py b/modin/pandas/io.py index 8045d8203af..0088eb9df5d 100644 --- a/modin/pandas/io.py +++ b/modin/pandas/io.py @@ -457,12 +457,9 @@ def read_excel( header: int | Sequence[int] | None = 0, names: list[str] | None = None, index_col: int | Sequence[int] | None = None, - usecols: int - | str - | Sequence[int] - | Sequence[str] - | Callable[[str], bool] - | None = None, + usecols: ( + int | str | Sequence[int] | Sequence[str] | Callable[[str], bool] | None + ) = None, dtype: DtypeArg | None = None, engine: Literal[("xlrd", "openpyxl", "odf", "pyxlsb")] | None = None, converters: dict[str, Callable] | dict[int, Callable] | None = None, @@ -861,9 +858,11 @@ def return_handler(*args, **kwargs): if item[0] != "_": ErrorMessage.default_to_pandas("`{}`".format(item)) args = [ - to_pandas(arg) - if isinstance(arg, ModinObjects.DataFrame) - else arg + ( + to_pandas(arg) + if isinstance(arg, ModinObjects.DataFrame) + else arg + ) for arg in args ] kwargs = { @@ -925,9 +924,11 @@ def return_handler(*args, **kwargs): if item[0] != "_": ErrorMessage.default_to_pandas("`{}`".format(item)) args = [ - to_pandas(arg) - if isinstance(arg, ModinObjects.DataFrame) - else arg + ( + to_pandas(arg) + if isinstance(arg, ModinObjects.DataFrame) + else arg + ) for arg in args ] kwargs = { diff --git a/modin/pandas/series.py b/modin/pandas/series.py index 2466be99deb..f38ea375292 100644 --- a/modin/pandas/series.py +++ b/modin/pandas/series.py @@ -1221,9 +1221,9 @@ def arg(s): return self.__constructor__( query_compiler=self._query_compiler.map( - lambda s: arg(s) - if pandas.isnull(s) is not True or na_action is None - else s + lambda s: ( + arg(s) if pandas.isnull(s) is not True or na_action is None else s + ) ) ) diff --git a/modin/pandas/test/dataframe/test_binary.py b/modin/pandas/test/dataframe/test_binary.py index 327991891a5..69ca719e2d4 100644 --- a/modin/pandas/test/dataframe/test_binary.py +++ b/modin/pandas/test/dataframe/test_binary.py @@ -341,9 +341,9 @@ def test_mismatched_row_partitions(is_idx_aligned, op_type, is_more_other_partit eval_general( modin_df2, pandas_df2, - lambda df: df / modin_df1.a - if isinstance(df, pd.DataFrame) - else df / pandas_df1.a, + lambda df: ( + df / modin_df1.a if isinstance(df, pd.DataFrame) else df / pandas_df1.a + ), ) return diff --git a/modin/pandas/test/dataframe/test_indexing.py b/modin/pandas/test/dataframe/test_indexing.py index 3e608a218f3..bf58b572168 100644 --- a/modin/pandas/test/dataframe/test_indexing.py +++ b/modin/pandas/test/dataframe/test_indexing.py @@ -301,9 +301,11 @@ def test_indexing_duplicate_axis(data): lambda df: df.columns[0], lambda df: df.index, lambda df: [df.index, df.columns[0]], - lambda df: pandas.Series(list(range(len(df.index)))) - if isinstance(df, pandas.DataFrame) - else pd.Series(list(range(len(df)))), + lambda df: ( + pandas.Series(list(range(len(df.index)))) + if isinstance(df, pandas.DataFrame) + else pd.Series(list(range(len(df)))) + ), ], ids=[ "non_existing_column", @@ -1567,18 +1569,24 @@ def test_reset_index_with_multi_index_no_drop( [f"level_{i}" for i in range(index.nlevels)] if multiindex_levels_names_max_levels == 0 else [ - tuple( - [ - f"level_{i}_name_{j}" - for j in range( - 0, - max(multiindex_levels_names_max_levels + 1 - index.nlevels, 0) - + i, - ) - ] + ( + tuple( + [ + f"level_{i}_name_{j}" + for j in range( + 0, + max( + multiindex_levels_names_max_levels + 1 - index.nlevels, + 0, + ) + + i, + ) + ] + ) + if max(multiindex_levels_names_max_levels + 1 - index.nlevels, 0) + i + > 0 + else f"level_{i}" ) - if max(multiindex_levels_names_max_levels + 1 - index.nlevels, 0) + i > 0 - else f"level_{i}" for i in range(index.nlevels) ] ) @@ -1596,9 +1604,11 @@ def test_reset_index_with_multi_index_no_drop( if isinstance(level, list): level = [ - index.names[int(x[len("level_name_") :])] - if isinstance(x, str) and x.startswith("level_name_") - else x + ( + index.names[int(x[len("level_name_") :])] + if isinstance(x, str) and x.startswith("level_name_") + else x + ) for x in level ] @@ -2356,8 +2366,8 @@ def _make_copy(df1, df2): def test_setitem_2d_insertion(): def build_value_picker(modin_value, pandas_value): """Build a function that returns either Modin or pandas DataFrame depending on the passed frame.""" - return ( - lambda source_df, *args, **kwargs: modin_value + return lambda source_df, *args, **kwargs: ( + modin_value if isinstance(source_df, (pd.DataFrame, pd.Series)) else pandas_value ) @@ -2573,9 +2583,9 @@ def test__getitem_bool_with_empty_partition(): eval_general( modin_tmp_result, pandas_tmp_result, - lambda df: df[modin_series] - if isinstance(df, pd.DataFrame) - else df[pandas_series], + lambda df: ( + df[modin_series] if isinstance(df, pd.DataFrame) else df[pandas_series] + ), ) diff --git a/modin/pandas/test/dataframe/test_map_metadata.py b/modin/pandas/test/dataframe/test_map_metadata.py index 200b148590c..b32e732c25f 100644 --- a/modin/pandas/test/dataframe/test_map_metadata.py +++ b/modin/pandas/test/dataframe/test_map_metadata.py @@ -1605,9 +1605,11 @@ def test_update(data, other_data, raise_errors): eval_general( modin_df, pandas_df, - lambda df: df.update(other_modin_df) - if isinstance(df, pd.DataFrame) - else df.update(other_pandas_df), + lambda df: ( + df.update(other_modin_df) + if isinstance(df, pd.DataFrame) + else df.update(other_pandas_df) + ), __inplace__=True, **kwargs, ) diff --git a/modin/pandas/test/test_general.py b/modin/pandas/test/test_general.py index 28c96a760a2..d3452545642 100644 --- a/modin/pandas/test/test_general.py +++ b/modin/pandas/test/test_general.py @@ -81,7 +81,11 @@ def test_merge(): join_types = ["outer", "inner"] for how in join_types: - with warns_that_defaulting_to_pandas() if how == "outer" else contextlib.nullcontext(): + with ( + warns_that_defaulting_to_pandas() + if how == "outer" + else contextlib.nullcontext() + ): modin_result = pd.merge(modin_df, modin_df2, how=how) pandas_result = pandas.merge(pandas_df, pandas_df2, how=how) df_equals(modin_result, pandas_result) @@ -920,7 +924,11 @@ def test_default_to_pandas_warning_message(func, regex): def test_empty_dataframe(): df = pd.DataFrame(columns=["a", "b"]) - with warns_that_defaulting_to_pandas() if StorageFormat.get() != "Hdk" else contextlib.nullcontext(): + with ( + warns_that_defaulting_to_pandas() + if StorageFormat.get() != "Hdk" + else contextlib.nullcontext() + ): df[(df.a == 1) & (df.b == 2)] diff --git a/modin/pandas/test/test_groupby.py b/modin/pandas/test/test_groupby.py index 81241a4d2d9..34493e1ac34 100644 --- a/modin/pandas/test/test_groupby.py +++ b/modin/pandas/test/test_groupby.py @@ -626,10 +626,12 @@ def maybe_get_columns(df, by): if not isinstance(by, list): by = [by] by_from_workaround = [ - modin_df[getattr(col, "name", col)].copy() - if (hashable(col) and col in modin_groupby._internal_by) - or isinstance(col, GetColumn) - else col + ( + modin_df[getattr(col, "name", col)].copy() + if (hashable(col) and col in modin_groupby._internal_by) + or isinstance(col, GetColumn) + else col + ) for col in by ] # GroupBy result with 'as_index=False' depends on the 'by' origin, since we forcibly changed @@ -1430,9 +1432,11 @@ def test(grp): md_grp, pd_grp, # Defaulting to pandas only for Modin groupby objects - lambda grp: grp[item].sum() - if not isinstance(grp, pd.groupby.DataFrameGroupBy) - else grp[item]._default_to_pandas(lambda df: df.sum()), + lambda grp: ( + grp[item].sum() + if not isinstance(grp, pd.groupby.DataFrameGroupBy) + else grp[item]._default_to_pandas(lambda df: df.sum()) + ), comparator=build_types_asserter(df_equals), ) diff --git a/modin/pandas/test/test_io.py b/modin/pandas/test/test_io.py index b74504deef9..c985a5d2c2b 100644 --- a/modin/pandas/test/test_io.py +++ b/modin/pandas/test/test_io.py @@ -1249,9 +1249,7 @@ def test_to_csv_with_index(self, tmp_path): @pytest.mark.parametrize("set_async_read_mode", [False, True], indirect=True) def test_read_csv_issue_5150(self, set_async_read_mode): with ensure_clean(".csv") as unique_filename: - pandas_df = pandas.DataFrame( - np.random.randint(0, 100, size=(2**6, 2**6)) - ) + pandas_df = pandas.DataFrame(np.random.randint(0, 100, size=(2**6, 2**6))) pandas_df.to_csv(unique_filename, index=False) expected_pandas_df = pandas.read_csv(unique_filename, index_col=False) modin_df = pd.read_csv(unique_filename, index_col=False) @@ -1281,7 +1279,11 @@ def _check_relative_io(fn_name, unique_filename, path_arg, storage_default=()): pinned_home = {envvar: dirname for envvar in ("HOME", "USERPROFILE", "HOMEPATH")} should_default = Engine.get() == "Python" or StorageFormat.get() in storage_default with mock.patch.dict(os.environ, pinned_home): - with warns_that_defaulting_to_pandas() if should_default else contextlib.nullcontext(): + with ( + warns_that_defaulting_to_pandas() + if should_default + else contextlib.nullcontext() + ): eval_io( fn_name=fn_name, **{path_arg: f"~/{basename}"}, diff --git a/modin/pandas/test/utils.py b/modin/pandas/test/utils.py index 1b448ee491c..d8496c8717c 100644 --- a/modin/pandas/test/utils.py +++ b/modin/pandas/test/utils.py @@ -76,9 +76,11 @@ }, "float_nan_data": { "col{}".format(int((i - NCOLS / 2) % NCOLS + 1)): [ - x - if (j % 4 == 0 and i > NCOLS // 2) or (j != i and i <= NCOLS // 2) - else np.NaN + ( + x + if (j % 4 == 0 and i > NCOLS // 2) or (j != i and i <= NCOLS // 2) + else np.NaN + ) for j, x in enumerate( random_state.uniform(RAND_LOW, RAND_HIGH, size=(NROWS)) ) @@ -1179,9 +1181,11 @@ def get_unique_filename( char_counter += 1 parameters_values = "_".join( [ - str(value) - if not isinstance(value, (list, tuple)) - else "_".join([str(x) for x in value]) + ( + str(value) + if not isinstance(value, (list, tuple)) + else "_".join([str(x) for x in value]) + ) for value in kwargs_name.values() ] ) diff --git a/modin/test/interchange/dataframe_protocol/hdk/utils.py b/modin/test/interchange/dataframe_protocol/hdk/utils.py index 401b6e2f13b..0369a08f82f 100644 --- a/modin/test/interchange/dataframe_protocol/hdk/utils.py +++ b/modin/test/interchange/dataframe_protocol/hdk/utils.py @@ -202,7 +202,12 @@ def get_data_of_all_types( ) if has_nulls: string_data["string_null"] = np.array( - ["English: test string", None, "Chinese: 测试字符串", "Russian: тестовая строка"] + [ + "English: test string", + None, + "Chinese: 测试字符串", + "Russian: тестовая строка", + ] * 10 ) diff --git a/modin/test/test_partition_api.py b/modin/test/test_partition_api.py index 3118e0a4b83..9e128c0342d 100644 --- a/modin/test/test_partition_api.py +++ b/modin/test/test_partition_api.py @@ -154,16 +154,12 @@ def test_from_partitions(axis, index, columns, row_lengths, column_widths): row_lengths = ( None if row_lengths is None - else [num_rows, num_rows] - if axis == 0 - else [num_rows] + else [num_rows, num_rows] if axis == 0 else [num_rows] ) column_widths = ( None if column_widths is None - else [num_cols] - if axis == 0 - else [num_cols, num_cols] + else [num_cols] if axis == 0 else [num_cols, num_cols] ) futures = [] if axis is None: diff --git a/requirements-dev.txt b/requirements-dev.txt index 0f855c5e8df..cdd68b5ab85 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -56,7 +56,7 @@ pytest-cov>=4.0.0 pytest-xdist>=3.2.0 ## code linters -black>=23.1.0 +black>=24.1.0 flake8>=6.0.0 flake8-no-implicit-concat>=0.3.4 flake8-print>=5.0.0 diff --git a/requirements/env_hdk.yml b/requirements/env_hdk.yml index 551ec920bcf..3b582d03ed5 100644 --- a/requirements/env_hdk.yml +++ b/requirements/env_hdk.yml @@ -36,7 +36,7 @@ dependencies: - pytest-xdist>=3.2.0 # code linters - - black>=23.1.0 + - black>=24.1.0 - flake8>=6.0.0 - flake8-no-implicit-concat>=0.3.4 - flake8-print>=5.0.0 diff --git a/requirements/env_unidist_linux.yml b/requirements/env_unidist_linux.yml index 7589560c2b9..33247eb56cf 100644 --- a/requirements/env_unidist_linux.yml +++ b/requirements/env_unidist_linux.yml @@ -46,7 +46,7 @@ dependencies: - pytest-xdist>=3.2.0 # code linters - - black>=23.1.0 + - black>=24.1.0 - flake8>=6.0.0 - flake8-no-implicit-concat>=0.3.4 - flake8-print>=5.0.0 diff --git a/requirements/env_unidist_win.yml b/requirements/env_unidist_win.yml index f3b3459dab6..6c93f0c4af5 100644 --- a/requirements/env_unidist_win.yml +++ b/requirements/env_unidist_win.yml @@ -46,7 +46,7 @@ dependencies: - pytest-xdist>=3.2.0 # code linters - - black>=23.1.0 + - black>=24.1.0 - flake8>=6.0.0 - flake8-no-implicit-concat>=0.3.4 - flake8-print>=5.0.0 diff --git a/requirements/requirements-no-engine.yml b/requirements/requirements-no-engine.yml index cabd72510f3..1c004a001c0 100644 --- a/requirements/requirements-no-engine.yml +++ b/requirements/requirements-no-engine.yml @@ -39,7 +39,7 @@ dependencies: - pytest-xdist>=3.2.0 # code linters - - black>=23.1.0 + - black>=24.1.0 - flake8>=6.0.0 - flake8-no-implicit-concat>=0.3.4 - flake8-print>=5.0.0 diff --git a/scripts/doc_checker.py b/scripts/doc_checker.py index 70db787b0b3..c85f11a9ed3 100644 --- a/scripts/doc_checker.py +++ b/scripts/doc_checker.py @@ -538,7 +538,7 @@ def load_obj(name, old_load_obj=Docstring._load_obj): xgboost_mock = Mock() class Booster: - ... + pass xgboost_mock.Booster = Booster sys.modules["xgboost"] = xgboost_mock From fe19363d58dfd50834971ee56e5038a822374594 Mon Sep 17 00:00:00 2001 From: Andrey Pavlenko Date: Fri, 26 Jan 2024 20:06:23 +0100 Subject: [PATCH 150/201] FEAT-#6398: Improved performance of list-like objects insertion into HDK DataFrames (#6412) Signed-off-by: Andrey Pavlenko Co-authored-by: Dmitry Chigarev Co-authored-by: Anatoly Myachev --- .../hdk_on_native/dataframe/dataframe.py | 212 ++++++++++++++++-- .../hdk_on_native/dataframe/utils.py | 46 ++++ .../hdk_on_native/df_algebra.py | 46 +++- .../hdk_on_native/partitioning/partition.py | 69 +++++- .../partitioning/partition_manager.py | 84 ++----- .../hdk_on_native/test/test_dataframe.py | 83 ++++++- .../storage_formats/hdk/query_compiler.py | 6 +- modin/pandas/test/test_general.py | 4 + 8 files changed, 458 insertions(+), 92 deletions(-) diff --git a/modin/experimental/core/execution/native/implementations/hdk_on_native/dataframe/dataframe.py b/modin/experimental/core/execution/native/implementations/hdk_on_native/dataframe/dataframe.py index b567a89e32a..0167a66501b 100644 --- a/modin/experimental/core/execution/native/implementations/hdk_on_native/dataframe/dataframe.py +++ b/modin/experimental/core/execution/native/implementations/hdk_on_native/dataframe/dataframe.py @@ -78,6 +78,7 @@ build_categorical_from_at, check_cols_to_join, check_join_supported, + ensure_supported_dtype, get_data_for_join_by_index, maybe_range, ) @@ -199,6 +200,9 @@ def __init__( self.id = str(type(self)._next_id[0]) type(self)._next_id[0] += 1 + if op is None and partitions is not None: + op = FrameNode(self) + self._op = op self._index_cols = index_cols self._partitions = partitions @@ -252,6 +256,7 @@ def copy( op=no_default, index_cols=no_default, uses_rowid=no_default, + has_unsupported_data=no_default, ): """ Copy this DataFrame. @@ -276,6 +281,8 @@ def copy( uses_rowid : bool, optional True for frames which require access to the virtual 'rowid' column for its execution. + has_unsupported_data : bool, optional + True for frames holding data not supported by Arrow or HDK storage format. Returns ------- @@ -296,6 +303,8 @@ def copy( index_cols = self._index_cols if uses_rowid is no_default: uses_rowid = self._uses_rowid + if has_unsupported_data is no_default: + has_unsupported_data = self._has_unsupported_data return self.__constructor__( partitions=partitions, index=index, @@ -307,7 +316,7 @@ def copy( index_cols=index_cols, uses_rowid=uses_rowid, force_execution_mode=self._force_execution_mode, - has_unsupported_data=self._has_unsupported_data, + has_unsupported_data=has_unsupported_data, ) def id_str(self): @@ -482,9 +491,7 @@ def _has_arrow_table(self): ------- bool """ - return self._partitions is not None and isinstance( - self._partitions[0][0].get(), pyarrow.Table - ) + return self._partitions is not None and self._partitions[0][0].raw def _dtypes_for_exprs(self, exprs): """ @@ -1433,7 +1440,7 @@ def _join_by_index(self, other_modin_frames, how, sort, ignore_index): lhs = lhs._reset_index_names() if ignore_index: - new_columns = Index.__new__(RangeIndex, data=range(len(lhs.columns))) + new_columns = RangeIndex(range(len(lhs.columns))) lhs = lhs._set_columns(new_columns) return lhs @@ -1461,14 +1468,7 @@ def _join_arrow_columns(self, other_modin_frames): and isinstance(f._execute(), (DbTable, pyarrow.Table)) for f in frames ): - tables = [ - ( - t - if isinstance(t := f._partitions[0][0].get(), pyarrow.Table) - else t.to_arrow() - ) - for f in frames - ] + tables = [f._partitions[0][0].get(to_arrow=True) for f in frames] column_names = [c for t in tables for c in t.column_names] if len(column_names) != len(set(column_names)): raise NotImplementedError("Duplicate column names") @@ -1676,6 +1676,13 @@ def insert(self, loc, column, value): assert column not in self._table_cols assert 0 <= loc <= len(self.columns) + if is_list_like(value): + if isinstance(value, pd.Series) and not self.index.equals(value.index): + # Align by index + value = value.reindex(self.index) + value.reset_index(drop=True, inplace=True) + return self._insert_list(loc, column, value) + exprs = self._index_exprs() for i in range(0, loc): col = self.columns[i] @@ -1696,6 +1703,171 @@ def insert(self, loc, column, value): force_execution_mode=self._force_execution_mode, ) + def _insert_list(self, loc, name, value): + """ + Insert a list-like value. + + Parameters + ---------- + loc : int + name : str + value : list + + Returns + ------- + HdkOnNativeDataframe + """ + ncols = len(self.columns) + + if loc == -1: + loc = ncols + + if ncols == 0: + assert loc == 0 + return self._list_to_df(name, value, True) + + if self._partitions and self._partitions[0][0].raw: + return self._insert_list_col(loc, name, value) + + if loc == 0 or loc == ncols: + in_idx = 0 if loc == 0 else 1 + if ( + isinstance(self._op, JoinNode) + and self._op.by_rowid + and self._op.input[in_idx]._partitions + and self._op.input[in_idx]._partitions[0][0].raw + ): + lhs = self._op.input[0] + rhs = self._op.input[1] + if loc == 0: + lhs = lhs._insert_list(0, name, value) + dtype = lhs.dtypes[0] + else: + rhs = rhs._insert_list(-1, name, value) + dtype = rhs.dtypes[-1] + elif loc == 0: + lhs = self._list_to_df(name, value, False) + rhs = self + dtype = lhs.dtypes[0] + else: + lhs = self + rhs = self._list_to_df(name, value, False) + dtype = rhs.dtypes[0] + elif isinstance(self._op, JoinNode) and self._op.by_rowid: + left_len = len(self._op.input[0].columns) + if loc < left_len: + lhs = self._op.input[0]._insert_list(loc, name, value) + rhs = self._op.input[1] + dtype = lhs.dtypes[loc] + else: + lhs = self._op.input[0] + rhs = self._op.input[1]._insert_list(loc - left_len, name, value) + dtype = rhs.dtypes[loc] + else: + lexprs = self._index_exprs() + rexprs = {} + for i, col in enumerate(self.columns): + (lexprs if i < loc else rexprs)[col] = self.ref(col) + lhs = self.__constructor__( + columns=self.columns[0:loc], + dtypes=self._dtypes_for_exprs(lexprs), + op=TransformNode(self, lexprs), + index=self._index_cache, + index_cols=self._index_cols, + force_execution_mode=self._force_execution_mode, + )._insert_list(loc, name, value) + rhs = self.__constructor__( + columns=self.columns[loc:], + dtypes=self._dtypes_for_exprs(rexprs), + op=TransformNode(self, rexprs), + force_execution_mode=self._force_execution_mode, + ) + dtype = lhs.dtypes[loc] + + op = self._join_by_rowid_op(lhs, rhs) + return self._insert_list_col(loc, name, value, dtype, op) + + def _insert_list_col(self, idx, name, value, dtype=None, op=None): + """ + Insert a list-like column. + + Parameters + ---------- + idx : int + name : str + value : list + dtype : dtype, default: None + op : DFAlgNode, default: None + + Returns + ------- + HdkOnNativeDataframe + """ + cols = self.columns.tolist() + cols.insert(idx, name) + has_unsupported_data = self._has_unsupported_data + if self._index_cols: + idx += len(self._index_cols) + if dtype is None: + part, dtype = self._partitions[0][0].insert(idx, name, value) + part = np.array([[part]]) + if not has_unsupported_data: + try: + ensure_supported_dtype(dtype) + except NotImplementedError: + has_unsupported_data = True + else: + part = None + dtypes = self._dtypes.tolist() + dtypes.insert(idx, dtype) + return self.copy( + partitions=part, + columns=cols, + dtypes=dtypes, + op=op, + has_unsupported_data=has_unsupported_data, + ) + + def _list_to_df(self, name, value, add_index): + """ + Create a single-column frame from the list-like value. + + Parameters + ---------- + name : str + value : list + add_index : bool + + Returns + ------- + HdkOnNativeDataframe + """ + df = pd.DataFrame({name: value}, index=self.index if add_index else None) + ensure_supported_dtype(df.dtypes[0]) + return self.from_pandas(df) + + @staticmethod + def _join_by_rowid_op(lhs, rhs): + """ + Create a JoinNode for join by rowid. + + Parameters + ---------- + lhs : HdkOnNativeDataframe + rhs : HdkOnNativeDataframe + + Returns + ------- + JoinNode + """ + exprs = lhs._index_exprs() if lhs._index_cols else rhs._index_exprs() + exprs.update((c, lhs.ref(c)) for c in lhs.columns) + exprs.update((c, rhs.ref(c)) for c in rhs.columns) + condition = lhs._build_equi_join_condition( + rhs, [ROWID_COL_NAME], [ROWID_COL_NAME] + ) + return JoinNode(lhs, rhs, exprs=exprs, condition=condition) + def cat_codes(self): """ Extract codes for a category column. @@ -2193,7 +2365,7 @@ def _compute_axis_labels_and_lengths(self, axis: int, partitions=None): return (cols, [len(cols)]) if self._index_cols is None: - index = Index.__new__(RangeIndex, data=range(len(obj))) + index = RangeIndex(range(len(obj))) return (index, [len(index)]) if isinstance(obj, DbTable): # TODO: Get the index columns only @@ -2217,8 +2389,12 @@ def _compute_axis_labels_and_lengths(self, axis: int, partitions=None): def _build_index_cache(self): """Materialize index and store it in the cache.""" - index, _ = self._compute_axis_labels_and_lengths(axis=0) - self.set_index_cache(index) + if self._partitions and not self._index_cols: + nrows = self._partitions[0][0]._length_cache + self.set_index_cache(RangeIndex(range(nrows))) + else: + index, _ = self._compute_axis_labels_and_lengths(axis=0) + self.set_index_cache(index) def _get_index(self): """ @@ -2666,8 +2842,8 @@ def to_pandas(self): assert len(df.columns) == len(self.columns) else: assert self._index_cols is None - assert df.index.name is None or isinstance( - self._partitions[0][0].get(), pd.DataFrame + assert ( + df.index.name is None or self._has_unsupported_data ), f"index name '{df.index.name}' is not None" if self.has_materialized_index: df.index = self._index_cache.get().copy() diff --git a/modin/experimental/core/execution/native/implementations/hdk_on_native/dataframe/utils.py b/modin/experimental/core/execution/native/implementations/hdk_on_native/dataframe/utils.py index 2063cdae0d8..e4a4ab12436 100644 --- a/modin/experimental/core/execution/native/implementations/hdk_on_native/dataframe/utils.py +++ b/modin/experimental/core/execution/native/implementations/hdk_on_native/dataframe/utils.py @@ -531,6 +531,52 @@ def get_common_arrow_type(t1: pa.lib.DataType, t2: pa.lib.DataType) -> pa.lib.Da return pa.from_numpy_dtype(np.promote_types(t1, t2)) +def is_supported_arrow_type(dtype: pa.lib.DataType) -> bool: + """ + Return True if the specified arrow type is supported by HDK. + + Parameters + ---------- + dtype : pa.lib.DataType + + Returns + ------- + bool + """ + if ( + pa.types.is_string(dtype) + or pa.types.is_time(dtype) + or pa.types.is_dictionary(dtype) + or pa.types.is_null(dtype) + ): + return True + if isinstance(dtype, pa.ExtensionType) or pa.types.is_duration(dtype): + return False + try: + pandas_dtype = dtype.to_pandas_dtype() + return pandas_dtype != np.dtype("O") + except NotImplementedError: + return False + + +def ensure_supported_dtype(dtype): + """ + Check if the specified `dtype` is supported by HDK. + + If `dtype` is not supported, `NotImplementedError` is raised. + + Parameters + ---------- + dtype : dtype + """ + try: + dtype = pa.from_numpy_dtype(dtype) + except pa.ArrowNotImplementedError as err: + raise NotImplementedError(f"Type {dtype}") from err + if not is_supported_arrow_type(dtype): + raise NotImplementedError(f"Type {dtype}") + + def arrow_to_pandas(at: pa.Table) -> pandas.DataFrame: """ Convert the specified arrow table to pandas. diff --git a/modin/experimental/core/execution/native/implementations/hdk_on_native/df_algebra.py b/modin/experimental/core/execution/native/implementations/hdk_on_native/df_algebra.py index 8914a8c796b..e6a9fa8b894 100644 --- a/modin/experimental/core/execution/native/implementations/hdk_on_native/df_algebra.py +++ b/modin/experimental/core/execution/native/implementations/hdk_on_native/df_algebra.py @@ -430,7 +430,9 @@ def execute_arrow(self, ignore=None) -> Union[DbTable, pa.Table, pandas.DataFram """ frame = self.modin_frame if frame._partitions is not None: - return frame._partitions[0][0].get() + part = frame._partitions[0][0] + to_arrow = part.raw and not frame._has_unsupported_data + return part.get(to_arrow) if frame._has_unsupported_data: return pandas.DataFrame( index=frame._index_cache, columns=frame._columns_cache @@ -832,6 +834,48 @@ def __init__( self.exprs = exprs self.condition = condition + @property + def by_rowid(self): + """ + Return True if this is a join by the rowid column. + + Returns + ------- + bool + """ + return ( + isinstance(self.condition, OpExpr) + and self.condition.op == "=" + and all( + isinstance(o, InputRefExpr) and o.column == ColNameCodec.ROWID_COL_NAME + for o in self.condition.operands + ) + ) + + @_inherit_docstrings(DFAlgNode.require_executed_base) + def require_executed_base(self) -> bool: + return self.by_rowid and any( + not isinstance(i._op, FrameNode) for i in self.input + ) + + @_inherit_docstrings(DFAlgNode.can_execute_arrow) + def can_execute_arrow(self) -> bool: + return self.by_rowid and all( + isinstance(e, InputRefExpr) for e in self.exprs.values() + ) + + @_inherit_docstrings(DFAlgNode.execute_arrow) + def execute_arrow(self, tables: List[pa.Table]) -> pa.Table: + t1 = tables[0] + t2 = tables[1] + cols1 = t1.column_names + cols = [ + (t1 if (col := ColNameCodec.encode(e.column)) in cols1 else t2).column(col) + for e in self.exprs.values() + ] + names = [ColNameCodec.encode(c) for c in self.exprs] + return pa.table(cols, names) + def copy(self): """ Make a shallow copy of the node. diff --git a/modin/experimental/core/execution/native/implementations/hdk_on_native/partitioning/partition.py b/modin/experimental/core/execution/native/implementations/hdk_on_native/partitioning/partition.py index 60003c9d960..11b9482aa0d 100644 --- a/modin/experimental/core/execution/native/implementations/hdk_on_native/partitioning/partition.py +++ b/modin/experimental/core/execution/native/implementations/hdk_on_native/partitioning/partition.py @@ -16,10 +16,11 @@ import pandas import pyarrow as pa +from pandas._typing import AnyArrayLike from modin.core.dataframe.pandas.partitioning.partition import PandasDataframePartition -from ..dataframe.utils import arrow_to_pandas +from ..dataframe.utils import ColNameCodec, arrow_to_pandas from ..db_worker import DbTable @@ -82,14 +83,24 @@ def to_numpy(self, **kwargs): """ return self.to_pandas().to_numpy(**kwargs) - def get(self): + def get(self, to_arrow: bool = False) -> Union[DbTable, pandas.DataFrame, pa.Table]: """ Get partition data. + Parameters + ---------- + to_arrow : bool, default: False + Convert the data to ``pyarrow.Table``. + Returns ------- - DbTable or pandas.DataFrame or pyarrow.Table + ``DbTable`` or ``pandas.DataFrame`` or ``pyarrow.Table`` """ + if to_arrow: + if isinstance(self._data, pandas.DataFrame): + self._data = pa.Table.from_pandas(self._data, preserve_index=False) + elif isinstance(self._data, DbTable): + return self._data.to_arrow() return self._data @classmethod @@ -109,6 +120,58 @@ def put(cls, obj): """ return cls(obj) + def insert(self, idx: int, name: str, value: AnyArrayLike): + """ + Insert column into this raw partition. + + Parameters + ---------- + idx : int + name : str + value : AnyArrayLike + + Returns + ------- + tuple of HdkOnNativeDataframePartition, dtype + """ + data = self._data + name = ColNameCodec.encode(name) + + if isinstance(data, pandas.DataFrame): + data = data.copy(False) + data.insert(idx, name, value) + dtype = data.dtypes[idx] + elif isinstance(data, pa.Table): + try: + new_data = data.add_column(idx, name, [value]) + dtype = new_data.field(idx).type.to_pandas_dtype() + data = new_data + except Exception: + try: + df = pandas.DataFrame({name: value}) + at = pa.Table.from_pandas(df, preserve_index=False) + data = data.add_column(idx, at.field(0), at.column(0)) + dtype = df.dtypes[0] + except Exception as err: + raise NotImplementedError(repr(err)) + else: + raise NotImplementedError(f"Insertion into {type(data)}") + + return HdkOnNativeDataframePartition(data), dtype + + @property + def raw(self): + """ + True if the partition contains a raw data. + + The raw data is either ``pandas.DataFrame`` or ``pyarrow.Table``. + + Returns + ------- + bool + """ + return isinstance(self._data, (pandas.DataFrame, pa.Table)) + @property def _length_cache(self): """ diff --git a/modin/experimental/core/execution/native/implementations/hdk_on_native/partitioning/partition_manager.py b/modin/experimental/core/execution/native/implementations/hdk_on_native/partitioning/partition_manager.py index a9ce5775ea6..443c53a0388 100644 --- a/modin/experimental/core/execution/native/implementations/hdk_on_native/partitioning/partition_manager.py +++ b/modin/experimental/core/execution/native/implementations/hdk_on_native/partitioning/partition_manager.py @@ -28,7 +28,7 @@ from ..calcite_builder import CalciteBuilder from ..calcite_serializer import CalciteSerializer -from ..dataframe.utils import ColNameCodec +from ..dataframe.utils import ColNameCodec, is_supported_arrow_type from ..db_worker import DbTable, DbWorker from ..partitioning.partition import HdkOnNativeDataframePartition @@ -66,23 +66,12 @@ def from_pandas(cls, df, return_dims=False, encode_col_names=True): Tuple holding array of partitions, list of columns with unsupported data and optionally partitions' dimensions. """ - at, unsupported_cols = cls._get_unsupported_cols(df) - - if len(unsupported_cols) > 0: - # Putting pandas frame into partitions instead of arrow table, because we know - # that all of operations with this frame will be default to pandas and don't want - # unnecessaries conversion pandas->arrow->pandas - parts = [[cls._partition_class(df)]] - if not return_dims: - return np.array(parts), unsupported_cols - else: - row_lengths = [len(df)] - col_widths = [len(df.columns)] - return np.array(parts), row_lengths, col_widths, unsupported_cols + unsupported_cols = cls._get_unsupported_cols(df) + parts = np.array([[cls._partition_class(df)]]) + if not return_dims: + return parts, unsupported_cols else: - # Since we already have arrow table, putting it into partitions instead - # of pandas frame, to skip that phase when we will be putting our frame to HDK - return cls.from_arrow(at, return_dims, unsupported_cols, encode_col_names) + return parts, [len(df)], [len(df.columns)], unsupported_cols @classmethod def from_arrow( @@ -117,16 +106,14 @@ def from_arrow( else: encoded_at = at - parts = [[cls._partition_class(encoded_at)]] + parts = np.array([[cls._partition_class(encoded_at)]]) if unsupported_cols is None: - _, unsupported_cols = cls._get_unsupported_cols(at) + unsupported_cols = cls._get_unsupported_cols(at) if not return_dims: - return np.array(parts), unsupported_cols + return parts, unsupported_cols else: - row_lengths = [at.num_rows] - col_widths = [at.num_columns] - return np.array(parts), row_lengths, col_widths, unsupported_cols + return parts, [at.num_rows], [at.num_columns], unsupported_cols @classmethod def _get_unsupported_cols(cls, obj): @@ -140,9 +127,8 @@ def _get_unsupported_cols(cls, obj): Returns ------- - tuple - Arrow representation of `obj` (for future using) and a list of - unsupported columns. + list + List of unsupported columns. """ if isinstance(obj, (pandas.Series, pandas.DataFrame)): # picking first rows from cols with `dtype="object"` to check its actual type, @@ -163,10 +149,10 @@ def _get_unsupported_cols(cls, obj): ] if len(unsupported_cols) > 0: - return None, unsupported_cols + return unsupported_cols try: - at = pyarrow.Table.from_pandas(obj, preserve_index=False) + schema = pyarrow.Schema.from_pandas(obj, preserve_index=False) except ( pyarrow.lib.ArrowTypeError, pyarrow.lib.ArrowInvalid, @@ -198,34 +184,14 @@ def _get_unsupported_cols(cls, obj): unsupported_cols.extend(match) if len(unsupported_cols) == 0: - unsupported_cols = obj.columns - return None, unsupported_cols - else: - obj = at - - def is_supported_dtype(dtype): - """Check whether the passed pyarrow `dtype` is supported by HDK.""" - if ( - pyarrow.types.is_string(dtype) - or pyarrow.types.is_time(dtype) - or pyarrow.types.is_dictionary(dtype) - or pyarrow.types.is_null(dtype) - ): - return True - if isinstance(dtype, pyarrow.ExtensionType) or pyarrow.types.is_duration( - dtype - ): - return False - try: - pandas_dtype = dtype.to_pandas_dtype() - return pandas_dtype != np.dtype("O") - except NotImplementedError: - return False + unsupported_cols = obj.columns.tolist() + return unsupported_cols + else: + schema = obj.schema - return ( - obj, - [field.name for field in obj.schema if not is_supported_dtype(field.type)], - ) + return [ + field.name for field in schema if not is_supported_arrow_type(field.type) + ] @classmethod def run_exec_plan(cls, plan): @@ -283,11 +249,9 @@ def import_table(cls, frame, worker=DbWorker()) -> DbTable: ------- DbTable """ - table = frame._partitions[0][0].get() - if isinstance(table, pandas.DataFrame): - table = worker.import_pandas_dataframe(table) - frame._partitions[0][0] = cls._partition_class(table) - elif isinstance(table, pyarrow.Table): + part = frame._partitions[0][0] + table = part.get(part.raw) + if isinstance(table, pyarrow.Table): if table.num_columns == 0: # Tables without columns are not supported. # Creating an empty table with index columns only. diff --git a/modin/experimental/core/execution/native/implementations/hdk_on_native/test/test_dataframe.py b/modin/experimental/core/execution/native/implementations/hdk_on_native/test/test_dataframe.py index 9b9b6166822..c2e963bf7cb 100644 --- a/modin/experimental/core/execution/native/implementations/hdk_on_native/test/test_dataframe.py +++ b/modin/experimental/core/execution/native/implementations/hdk_on_native/test/test_dataframe.py @@ -24,6 +24,7 @@ from modin.config import StorageFormat from modin.pandas.test.utils import ( + create_test_dfs, default_to_pandas_ignore_string, io_ops_bad_exc, random_state, @@ -698,6 +699,52 @@ def applier(df, lib, **kwargs): run_and_compare(applier, data=self.data, force_lazy=False) + @pytest.mark.parametrize( + "data", [None, {"A": range(10)}, pandas.DataFrame({"A": range(10)})] + ) + @pytest.mark.parametrize( + "index", + [None, pandas.RangeIndex(10), pandas.RangeIndex(start=10, stop=0, step=-1)], + ) + @pytest.mark.parametrize("value", [list(range(10)), pandas.Series(range(10))]) + @pytest.mark.parametrize("part_type", [None, "arrow", "hdk"]) + @pytest.mark.parametrize("insert_scalar", [True, False]) + def test_insert_list(self, data, index, value, part_type, insert_scalar): + def create(): + mdf, pdf = create_test_dfs(data, index=index) + if part_type == "arrow": # Make sure the partition contains an arrow table + mdf._query_compiler._modin_frame._partitions[0][0].get(True) + elif part_type == "hdk": + mdf._query_compiler._modin_frame.force_import() + return mdf, pdf + + def insert(loc, name, value): + nonlocal mdf, pdf + mdf.insert(loc, name, value) + pdf.insert(loc, name, value) + if insert_scalar: + mdf[f"S{loc}"] = 1 + pdf[f"S{loc}"] = 1 + + niter = 3 + + mdf, pdf = create() + for i in range(niter): + insert(len(pdf.columns), f"B{i}", value) + df_equals(mdf, pdf) + + mdf, pdf = create() + for i in range(niter): + insert(0, f"C{i}", value) + df_equals(mdf, pdf) + + mdf, pdf = create() + for i in range(niter): + insert(len(pdf.columns), f"B{i}", value) + insert(0, f"C{i}", value) + insert(len(pdf.columns) // 2, f"D{i}", value) + df_equals(mdf, pdf) + def test_concat_many(self): def concat(df1, df2, lib, **kwargs): df3 = df1.copy() @@ -2496,16 +2543,42 @@ class TestUnsupportedColumns: ) def test_unsupported_columns(self, data, is_good): pandas_df = pandas.DataFrame({"col": data}) - obj, bad_cols = HdkOnNativeDataframePartitionManager._get_unsupported_cols( - pandas_df - ) + bad_cols = HdkOnNativeDataframePartitionManager._get_unsupported_cols(pandas_df) if is_good: - assert obj and not bad_cols + assert not bad_cols else: - assert not obj and bad_cols == ["col"] + assert bad_cols == ["col"] class TestConstructor: + @pytest.mark.parametrize( + "data", + [ + None, + {"A": range(10)}, + pandas.Series(range(10)), + pandas.DataFrame({"A": range(10)}), + ], + ) + @pytest.mark.parametrize( + "index", + [None, pandas.RangeIndex(10), pandas.RangeIndex(start=10, stop=0, step=-1)], + ) + @pytest.mark.parametrize("columns", [None, ["A"], ["A", "B", "C"]]) + @pytest.mark.parametrize("dtype", [None, float]) + def test_raw_data(self, data, index, columns, dtype): + if ( + isinstance(data, pandas.Series) + and data.name is None + and columns is not None + and len(columns) > 1 + ): + data = data.copy() + # Pandas constructor fails if an unnamed Series is passed along with columns argument + data.name = "D" + mdf, pdf = create_test_dfs(data, index=index, columns=columns, dtype=dtype) + df_equals(mdf, pdf) + @pytest.mark.parametrize( "index", [ diff --git a/modin/experimental/core/storage_formats/hdk/query_compiler.py b/modin/experimental/core/storage_formats/hdk/query_compiler.py index 31bf0c7a460..7428a8571c2 100644 --- a/modin/experimental/core/storage_formats/hdk/query_compiler.py +++ b/modin/experimental/core/storage_formats/hdk/query_compiler.py @@ -23,7 +23,7 @@ import pandas from pandas._libs.lib import no_default from pandas.core.common import is_bool_indexer -from pandas.core.dtypes.common import is_bool_dtype, is_integer_dtype, is_list_like +from pandas.core.dtypes.common import is_bool_dtype, is_integer_dtype from modin.core.storage_formats.base.query_compiler import BaseQueryCompiler from modin.core.storage_formats.base.query_compiler import ( @@ -830,10 +830,6 @@ def insert(self, loc, column, value): if isinstance(value, type(self)): value.columns = [column] return self.insert_item(axis=1, loc=loc, value=value) - - if is_list_like(value): - raise NotImplementedError("HDK's insert does not support list-like values.") - return self.__constructor__(self._modin_frame.insert(loc, column, value)) def sort_rows_by_column_values(self, columns, ascending=True, **kwargs): diff --git a/modin/pandas/test/test_general.py b/modin/pandas/test/test_general.py index d3452545642..5c893512a10 100644 --- a/modin/pandas/test/test_general.py +++ b/modin/pandas/test/test_general.py @@ -39,6 +39,10 @@ if StorageFormat.get() == "Hdk": pytestmark = pytest.mark.filterwarnings(default_to_pandas_ignore_string) +else: + pytestmark = pytest.mark.filterwarnings( + "default:`DataFrame.insert` for empty DataFrame is not currently supported.*:UserWarning" + ) @pytest.mark.parametrize("data", test_data_values, ids=test_data_keys) From fb4a19faa23155f46d104073e2bb9ade5e54bb4d Mon Sep 17 00:00:00 2001 From: Dmitry Chigarev Date: Mon, 29 Jan 2024 18:37:44 +0100 Subject: [PATCH 151/201] TEST-#6893: Added support for `pytest 8.0.0` (#6894) Signed-off-by: Dmitry Chigarev --- modin/experimental/pandas/test/test_io_exp.py | 4 ++-- modin/pandas/test/test_general.py | 10 +++++----- modin/pandas/test/test_series.py | 15 +++++++++------ 3 files changed, 16 insertions(+), 13 deletions(-) diff --git a/modin/experimental/pandas/test/test_io_exp.py b/modin/experimental/pandas/test/test_io_exp.py index d2a25cc3586..4954ab2a037 100644 --- a/modin/experimental/pandas/test/test_io_exp.py +++ b/modin/experimental/pandas/test/test_io_exp.py @@ -126,8 +126,8 @@ def test_read_csv_empty_frame(self): df_equals(modin_df, pandas_df) def test_read_csv_without_glob(self): - with pytest.warns(UserWarning, match=r"Shell-style wildcard"): - with pytest.raises(FileNotFoundError): + with pytest.raises(FileNotFoundError): + with warns_that_defaulting_to_pandas(): pd.read_csv_glob( "s3://dask-data/nyc-taxi/2015/yellow_tripdata_2015-", storage_options={"anon": True}, diff --git a/modin/pandas/test/test_general.py b/modin/pandas/test/test_general.py index 5c893512a10..7a445657d2d 100644 --- a/modin/pandas/test/test_general.py +++ b/modin/pandas/test/test_general.py @@ -295,7 +295,7 @@ def test_merge_asof_bad_arguments(): modin_left, modin_right = pd.DataFrame(left), pd.DataFrame(right) # Can't mix by with left_by/right_by - with pytest.raises(ValueError), warns_that_defaulting_to_pandas(): + with pytest.raises(ValueError): pandas.merge_asof( pandas_left, pandas_right, on="a", by="b", left_by="can't do with by" ) @@ -303,7 +303,7 @@ def test_merge_asof_bad_arguments(): pd.merge_asof( modin_left, modin_right, on="a", by="b", left_by="can't do with by" ) - with pytest.raises(ValueError), warns_that_defaulting_to_pandas(): + with pytest.raises(ValueError): pandas.merge_asof( pandas_left, pandas_right, by="b", on="a", right_by="can't do with by" ) @@ -313,11 +313,11 @@ def test_merge_asof_bad_arguments(): ) # Can't mix on with left_on/right_on - with pytest.raises(ValueError), warns_that_defaulting_to_pandas(): + with pytest.raises(ValueError): pandas.merge_asof(pandas_left, pandas_right, on="a", left_on="can't do with by") with pytest.raises(ValueError), warns_that_defaulting_to_pandas(): pd.merge_asof(modin_left, modin_right, on="a", left_on="can't do with by") - with pytest.raises(ValueError), warns_that_defaulting_to_pandas(): + with pytest.raises(ValueError): pandas.merge_asof( pandas_left, pandas_right, on="a", right_on="can't do with by" ) @@ -347,7 +347,7 @@ def test_merge_asof_bad_arguments(): pandas.merge_asof(pandas_left, pandas_right, right_on="a") with pytest.raises(ValueError), warns_that_defaulting_to_pandas(): pd.merge_asof(modin_left, modin_right, right_on="a") - with pytest.raises(ValueError), warns_that_defaulting_to_pandas(): + with pytest.raises(ValueError): pandas.merge_asof(pandas_left, pandas_right) with pytest.raises(ValueError), warns_that_defaulting_to_pandas(): pd.merge_asof(modin_left, modin_right) diff --git a/modin/pandas/test/test_series.py b/modin/pandas/test/test_series.py index 83bc6a6075d..019b0896169 100644 --- a/modin/pandas/test/test_series.py +++ b/modin/pandas/test/test_series.py @@ -213,15 +213,18 @@ def inter_df_math_helper_one_side( ) modin_df_multi_level = modin_series.copy() modin_df_multi_level.index = new_idx - + # When 'level' parameter is passed, modin's implementation must raise a default-to-pandas warning, + # here we first detect whether 'op' takes 'level' parameter at all and only then perform the warning check + # reasoning: https://github.com/modin-project/modin/issues/6893 try: - # Defaults to pandas - with warns_that_defaulting_to_pandas(): - # Operation against self for sanity check - getattr(modin_df_multi_level, op)(modin_df_multi_level, level=1) + getattr(modin_df_multi_level, op)(modin_df_multi_level, level=1) except TypeError: - # Some operations don't support multilevel `level` parameter + # Operation doesn't support 'level' parameter pass + else: + # Operation supports 'level' parameter, so it makes sense to check for a warning + with warns_that_defaulting_to_pandas(): + getattr(modin_df_multi_level, op)(modin_df_multi_level, level=1) def create_test_series(vals, sort=False, **kwargs): From 46dc0a5a8bb90ac73c91649a3a29702a4160e8cb Mon Sep 17 00:00:00 2001 From: Dmitry Chigarev Date: Mon, 29 Jan 2024 20:20:21 +0100 Subject: [PATCH 152/201] FEAT-#5925: Enable grouping on categoricals with range-partitioning impl (#6862) Signed-off-by: Dmitry Chigarev --- .../algebra/default2pandas/groupby.py | 6 +- .../dataframe/pandas/dataframe/dataframe.py | 88 ++++++-- .../core/dataframe/pandas/dataframe/utils.py | 212 ++++++++++++++++++ .../pandas/partitioning/partition_manager.py | 13 +- modin/core/dataframe/pandas/utils.py | 16 ++ .../storage_formats/pandas/query_compiler.py | 43 ++-- modin/pandas/test/test_groupby.py | 97 +++++++- 7 files changed, 432 insertions(+), 43 deletions(-) diff --git a/modin/core/dataframe/algebra/default2pandas/groupby.py b/modin/core/dataframe/algebra/default2pandas/groupby.py index 8e2e4de062d..4c8271df555 100644 --- a/modin/core/dataframe/algebra/default2pandas/groupby.py +++ b/modin/core/dataframe/algebra/default2pandas/groupby.py @@ -55,7 +55,11 @@ def is_transformation_kernel(agg_func: Any) -> bool: ------- bool """ - return hashable(agg_func) and agg_func in transformation_kernels + return hashable(agg_func) and agg_func in transformation_kernels.union( + # these methods are also producing transpose-like result in a sense we understand it + # (they're non-aggregative functions), however are missing in the pandas dictionary + {"nth", "head", "tail"} + ) @classmethod def _call_groupby(cls, df, *args, **kwargs): # noqa: PR01 diff --git a/modin/core/dataframe/pandas/dataframe/dataframe.py b/modin/core/dataframe/pandas/dataframe/dataframe.py index 20003c6f20d..6ed18805336 100644 --- a/modin/core/dataframe/pandas/dataframe/dataframe.py +++ b/modin/core/dataframe/pandas/dataframe/dataframe.py @@ -32,6 +32,7 @@ from modin.core.dataframe.base.dataframe.utils import Axis, JoinType from modin.core.dataframe.pandas.dataframe.utils import ( ShuffleSortFunctions, + add_missing_categories_to_groupby, lazy_metadata_decorator, ) from modin.core.dataframe.pandas.metadata import ( @@ -3758,7 +3759,8 @@ def groupby( by: Union[str, List[str]], operator: Callable, result_schema: Optional[Dict[Hashable, type]] = None, - align_result_columns=False, + align_result_columns: bool = False, + add_missing_cats: bool = False, **kwargs: dict, ) -> "PandasDataframe": """ @@ -3780,6 +3782,8 @@ def groupby( Whether to manually align columns between all the resulted row partitions. This flag is helpful when dealing with UDFs as they can change the partition's shape and labeling unpredictably, resulting in an invalid dataframe. + add_missing_cats : bool, default: False + Whether to add missing categories from `by` columns to the result. **kwargs : dict Additional arguments to pass to the ``df.groupby`` method (besides the 'by' argument). @@ -3809,6 +3813,7 @@ def groupby( if not isinstance(by, list): by = [by] + kwargs["observed"] = True skip_on_aligning_flag = "__skip_me_on_aligning__" def apply_func(df): # pragma: no cover @@ -3831,9 +3836,8 @@ def apply_func(df): # pragma: no cover key_columns=by, func=apply_func, ) - # no need aligning columns if there's only one row partition - if align_result_columns and result._partitions.shape[0] > 1: + if add_missing_cats or align_result_columns and result._partitions.shape[0] > 1: # FIXME: the current reshuffling implementation guarantees us that there's only one column # partition in the result, so we should never hit this exception for now, however # in the future, we might want to make this implementation more broader @@ -3847,37 +3851,79 @@ def apply_func(df): # pragma: no cover # it gathers all the dataframes in a single ray-kernel. # 2. The second one works slower, but only gathers light pandas.Index objects, # so there should be less stress on the network. - if not IsRayCluster.get(): + if add_missing_cats or not IsRayCluster.get(): + original_dtypes = self.dtypes if self.has_materialized_dtypes else None - def compute_aligned_columns(*dfs): + def compute_aligned_columns(*dfs, initial_columns=None): """Take row partitions, filter empty ones, and return joined columns for them.""" - valid_dfs = [ - df - for df in dfs - if not df.attrs.get(skip_on_aligning_flag, False) - ] - if len(valid_dfs) == 0 and len(dfs) != 0: - valid_dfs = dfs - - # Using '.concat()' on empty-slices instead of 'Index.join()' - # in order to get identical behavior to pandas when it joins - # results of different groups - return pandas.concat( - [df.iloc[:0] for df in valid_dfs], axis=0, join="outer" - ).columns + if align_result_columns: + valid_dfs = [ + df + for df in dfs + if not df.attrs.get(skip_on_aligning_flag, False) + ] + + if len(valid_dfs) == 0 and len(dfs) != 0: + valid_dfs = dfs + + # Using '.concat()' on empty-slices instead of 'Index.join()' + # in order to get identical behavior to pandas when it joins + # results of different groups + combined_cols = pandas.concat( + [df.iloc[:0] for df in valid_dfs], axis=0, join="outer" + ).columns + else: + combined_cols = dfs[0].columns + + masks = None + if add_missing_cats: + masks, combined_cols = add_missing_categories_to_groupby( + dfs, + by, + operator, + initial_columns, + combined_cols, + is_udf_agg=align_result_columns, + kwargs=kwargs.copy(), + initial_dtypes=original_dtypes, + ) + return ( + (combined_cols, masks) + if align_result_columns + else (None, masks) + ) # Passing all partitions to the 'compute_aligned_columns' kernel to get # aligned columns parts = result._partitions.flatten() aligned_columns = parts[0].apply( - compute_aligned_columns, *[part._data for part in parts[1:]] + compute_aligned_columns, + *[part._data for part in parts[1:]], + initial_columns=self.columns, ) + def apply_aligned(df, args, partition_idx): + combined_cols, mask = args + if mask is not None and mask.get(partition_idx) is not None: + values = mask[partition_idx] + + original_names = df.index.names + # TODO: inserting 'values' based on 'searchsorted' result might be more efficient + # in cases of small amount of 'values' + df = pandas.concat([df, values]) + if kwargs["sort"]: + df = df.sort_index(axis=0) + df.index.names = original_names + if combined_cols is not None: + df = df.reindex(columns=combined_cols) + return df + # Lazily applying aligned columns to partitions new_partitions = self._partition_mgr_cls.lazy_map_partitions( result._partitions, - lambda df, columns: df.reindex(columns=columns), + apply_aligned, func_args=(aligned_columns._data,), + enumerate_partitions=True, ) else: diff --git a/modin/core/dataframe/pandas/dataframe/utils.py b/modin/core/dataframe/pandas/dataframe/utils.py index 6bb84df49bb..08bbd6894de 100644 --- a/modin/core/dataframe/pandas/dataframe/utils.py +++ b/modin/core/dataframe/pandas/dataframe/utils.py @@ -519,3 +519,215 @@ def run_f_on_minimally_updated_metadata(self, *args, **kwargs): return run_f_on_minimally_updated_metadata return decorator + + +def add_missing_categories_to_groupby( + dfs, + by, + operator, + initial_columns, + combined_cols, + is_udf_agg, + kwargs, + initial_dtypes=None, +): + """ + Generate values for missing categorical values to be inserted into groupby result. + + This function is used to emulate behavior of ``groupby(observed=False)`` parameter, + it takes groupby result that was computed using ``groupby(observed=True)`` + and computes results for categorical values that are not presented in `dfs`. + + Parameters + ---------- + dfs : list of pandas.DataFrames + Row partitions containing groupby results. + by : list of hashable + Column labels that were used to perform groupby. + operator : callable + Aggregation function that was used during groupby. + initial_columns : pandas.Index + Column labels of the original dataframe. + combined_cols : pandas.Index + Column labels of the groupby result. + is_udf_agg : bool + Whether ``operator`` is a UDF. + kwargs : dict + Parameters that were passed to ``groupby(by, **kwargs)``. + initial_dtypes : pandas.Series, optional + Dtypes of the original dataframe. If not specified, assume it's ``int64``. + + Returns + ------- + masks : dict[int, pandas.DataFrame] + Mapping between partition idx and a dataframe with results for missing categorical values + to insert to this partition. + new_combined_cols : pandas.Index + New column labels of the groupby result. If ``is_udf_agg is True``, then ``operator`` + may change the resulted columns. + """ + kwargs["observed"] = False + new_combined_cols = combined_cols + + ### At first we need to compute missing categorical values + indices = [df.index for df in dfs] + # total_index contains all categorical values that resided in the result, + # missing values are computed differently depending on whether we're grouping + # on multiple groupers or not + total_index = indices[0].append(indices[1:]) + if isinstance(total_index, pandas.MultiIndex): + if all( + not isinstance(level, pandas.CategoricalIndex) + for level in total_index.levels + ): + return {}, new_combined_cols + missing_cats_dtype = { + name: ( + level.dtype + if isinstance(level.dtype, pandas.CategoricalDtype) + # it's a bit confusing but we have to convert the remaining 'by' columns to categoricals + # in order to compute a proper fill value later in the code + else pandas.CategoricalDtype(level) + ) + for level, name in zip(total_index.levels, total_index.names) + } + # if we're grouping on multiple groupers, then the missing categorical values is a + # carthesian product of (actual_missing_categorical_values X all_values_of_another_groupers) + complete_index = pandas.MultiIndex.from_product( + [ + value.categories.astype(total_level.dtype) + for total_level, value in zip( + total_index.levels, missing_cats_dtype.values() + ) + ], + names=by, + ) + missing_index = complete_index[~complete_index.isin(total_index)] + else: + if not isinstance(total_index, pandas.CategoricalIndex): + return {}, new_combined_cols + # if we're grouping on a single grouper then we simply compute the difference + # between categorical values in the result and the values defined in categorical dtype + missing_index = total_index.categories.difference(total_index.values) + missing_cats_dtype = {by[0]: pandas.CategoricalDtype(missing_index)} + missing_index.names = by + + if len(missing_index) == 0: + return {}, new_combined_cols + + ### At this stage we want to get a fill_value for missing categorical values + if is_udf_agg and isinstance(total_index, pandas.MultiIndex): + # if grouping on multiple columns and aggregating with an UDF, then the + # fill value is always `np.NaN` + missing_values = pandas.DataFrame({0: [np.NaN]}) + else: + # In case of a udf aggregation we're forced to run the operator against each + # missing category, as in theory it can return different results for each + # empty group. In other cases it's enough to run the operator against a single + # missing categorical and then broadcast the fill value to each missing value + if not is_udf_agg: + missing_cats_dtype = { + key: pandas.CategoricalDtype(value.categories[:1]) + for key, value in missing_cats_dtype.items() + } + + empty_df = pandas.DataFrame(columns=initial_columns) + # HACK: default 'object' dtype doesn't fit our needs, as most of the aggregations + # fail on a non-numeric columns, ideally, we need dtypes of the original dataframe, + # however, 'int64' also works fine here if the original schema is not available + empty_df = empty_df.astype( + "int64" if initial_dtypes is None else initial_dtypes + ) + empty_df = empty_df.astype(missing_cats_dtype) + missing_values = operator(empty_df.groupby(by, **kwargs)) + + if is_udf_agg and not isinstance(total_index, pandas.MultiIndex): + missing_values = missing_values.drop(columns=by, errors="ignore") + new_combined_cols = pandas.concat( + [ + pandas.DataFrame(columns=combined_cols), + missing_values.iloc[:0], + ], + axis=0, + join="outer", + ).columns + else: + # HACK: If the aggregation has failed, the result would be empty. Assuming the + # fill value to be `np.NaN` here (this may not always be correct!!!) + fill_value = np.NaN if len(missing_values) == 0 else missing_values.iloc[0, 0] + missing_values = pandas.DataFrame( + fill_value, index=missing_index, columns=combined_cols + ) + + # restoring original categorical dtypes for the indices (MultiIndex already have proper dtypes) + if not isinstance(missing_values.index, pandas.MultiIndex): + missing_values.index = missing_values.index.astype(total_index.dtype) + + ### Then we decide to which missing categorical values should go to which partition + if not kwargs["sort"]: + # If the result is allowed to be unsorted, simply insert all the missing + # categories to the last partition + mask = {len(indices) - 1: missing_values} + return mask, new_combined_cols + + # If the result has to be sorted, we have to assign missing categoricals to proper partitions. + # For that purpose we define bins with corner values of each partition and then using either + # np.digitize or np.searchsorted find correct bins for each missing categorical value. + # Example: part0-> [0, 1, 2]; part1-> [3, 4, 10, 12]; part2-> [15, 17, 20, 100] + # bins -> [2, 12] # took last values of each partition excluding the last partition + # (every value that's matching 'x > part[-2][-1]' should go to the + # last partition, meaning that including the last value of the last + # partitions doesn't make sense) + # missing_cats -> [-2, 5, 6, 14, 21, 120] + # np.digitize(missing_cats, bins) -> [ 0, 1, 1, 2, 2, 2] + # ^-- mapping between values and partition idx to insert + bins = [] + old_bins_to_new = {} + offset = 0 + # building bins by taking last values of each partition excluding the last partition + for idx in indices[:-1]: + if len(idx) == 0: + # if a partition is empty, we can't use its values to define a bin, thus we simply + # skip it and remember the number of skipped partitions as an 'offset' + offset += 1 + continue + # remember the number of skipped partitions before this bin, in order to restore original + # indexing at the end + old_bins_to_new[len(bins)] = offset + # for MultiIndices we always use the very first level for bins as using multiple levels + # doesn't affect the result + bins.append(idx[-1][0] if isinstance(idx, pandas.MultiIndex) else idx[-1]) + old_bins_to_new[len(bins)] = offset + + if len(bins) == 0: + # insert values to the first non-empty partition + return {old_bins_to_new.get(0, 0): missing_values}, new_combined_cols + + # we used the very first level of MultiIndex to build bins, meaning that we also have + # to use values of the first index's level for 'digitize' + lvl_zero = ( + missing_values.index.levels[0] + if isinstance(missing_values.index, pandas.MultiIndex) + else missing_values.index + ) + if pandas.api.types.is_any_real_numeric_dtype(lvl_zero): + part_idx = np.digitize(lvl_zero, bins, right=True) + else: + part_idx = np.searchsorted(bins, lvl_zero) + + ### In the end we build a dictionary mapping partition index to a dataframe with missing categoricals + ### to be inserted into this partition + masks = {} + if isinstance(total_index, pandas.MultiIndex): + for idx, values in pandas.RangeIndex(len(lvl_zero)).groupby(part_idx).items(): + masks[idx] = missing_values[ + pandas.Index(missing_values.index.codes[0]).isin(values) + ] + else: + frame_idx = missing_values.index.to_frame() + for idx, values in lvl_zero.groupby(part_idx).items(): + masks[idx] = missing_values[frame_idx.iloc[:, 0].isin(values)] + + # Restore the original indexing by adding the amount of skipped missing partitions + masks = {key + old_bins_to_new[key]: value for key, value in masks.items()} + return masks, new_combined_cols diff --git a/modin/core/dataframe/pandas/partitioning/partition_manager.py b/modin/core/dataframe/pandas/partitioning/partition_manager.py index 421b58f29a6..f7b3899550c 100644 --- a/modin/core/dataframe/pandas/partitioning/partition_manager.py +++ b/modin/core/dataframe/pandas/partitioning/partition_manager.py @@ -612,7 +612,12 @@ def map_partitions( @classmethod @wait_computations_if_benchmark_mode def lazy_map_partitions( - cls, partitions, map_func, func_args=None, func_kwargs=None + cls, + partitions, + map_func, + func_args=None, + func_kwargs=None, + enumerate_partitions=False, ): """ Apply `map_func` to every partition in `partitions` *lazily*. @@ -627,6 +632,7 @@ def lazy_map_partitions( Positional arguments for the 'map_func'. func_kwargs : dict, optional Keyword arguments for the 'map_func'. + enumerate_partitions : bool, default: False Returns ------- @@ -639,12 +645,13 @@ def lazy_map_partitions( [ part.add_to_apply_calls( preprocessed_map_func, - *func_args if func_args is not None else (), + *(tuple() if func_args is None else func_args), **func_kwargs if func_kwargs is not None else {}, + **({"partition_idx": i} if enumerate_partitions else {}), ) for part in row ] - for row in partitions + for i, row in enumerate(partitions) ] ) diff --git a/modin/core/dataframe/pandas/utils.py b/modin/core/dataframe/pandas/utils.py index c1703d6f2db..98304ba89c3 100644 --- a/modin/core/dataframe/pandas/utils.py +++ b/modin/core/dataframe/pandas/utils.py @@ -38,6 +38,22 @@ def concatenate(dfs): assert df.columns.equals(dfs[0].columns) for i in dfs[0].columns.get_indexer_for(dfs[0].select_dtypes("category").columns): columns = [df.iloc[:, i] for df in dfs] + all_categorical_parts_are_empty = None + has_non_categorical_parts = False + for col in columns: + if isinstance(col.dtype, pandas.CategoricalDtype): + if all_categorical_parts_are_empty is None: + all_categorical_parts_are_empty = len(col) == 0 + continue + all_categorical_parts_are_empty &= len(col) == 0 + else: + has_non_categorical_parts = True + # 'union_categoricals' raises an error if some of the passed values don't have categorical dtype, + # if it happens, we only want to continue when all parts with categorical dtypes are actually empty. + # This can happen if there were an aggregation that discards categorical dtypes and that aggregation + # doesn't properly do so for empty partitions + if has_non_categorical_parts and all_categorical_parts_are_empty: + continue union = union_categoricals(columns) for df in dfs: df.isetitem( diff --git a/modin/core/storage_formats/pandas/query_compiler.py b/modin/core/storage_formats/pandas/query_compiler.py index 292e696e6da..56bfbcdd5bd 100644 --- a/modin/core/storage_formats/pandas/query_compiler.py +++ b/modin/core/storage_formats/pandas/query_compiler.py @@ -3781,6 +3781,15 @@ def _groupby_shuffle( all(col in self.columns for col in by) if is_all_labels else False ) + is_transform = how == "transform" or GroupBy.is_transformation_kernel(agg_func) + + if is_transform: + # https://github.com/modin-project/modin/issues/5924 + ErrorMessage.missmatch_with_pandas( + operation="range-partitioning groupby", + message="the order of rows may be shuffled for the result", + ) + if not is_all_column_names: raise NotImplementedError( "Range-partitioning groupby is only supported when grouping on a column(s) of the same frame. " @@ -3788,23 +3797,26 @@ def _groupby_shuffle( ) # This check materializes dtypes for 'by' columns - if isinstance(self._modin_frame._dtypes, ModinDtypes): - by_dtypes = self._modin_frame._dtypes.lazy_get(by).get() - else: - by_dtypes = self.dtypes[by] - if any(isinstance(dtype, pandas.CategoricalDtype) for dtype in by_dtypes): - raise NotImplementedError( - "Range-partitioning groupby is not yet supported when grouping on a categorical column. " - + "https://github.com/modin-project/modin/issues/5925" + if not is_transform and groupby_kwargs.get("observed", False) in ( + False, + lib.no_default, + ): + if isinstance(self._modin_frame._dtypes, ModinDtypes): + by_dtypes = self._modin_frame._dtypes.lazy_get(by).get() + else: + by_dtypes = self.dtypes[by] + add_missing_cats = any( + isinstance(dtype, pandas.CategoricalDtype) for dtype in by_dtypes ) + else: + add_missing_cats = False - is_transform = how == "transform" or GroupBy.is_transformation_kernel(agg_func) - - if is_transform: - # https://github.com/modin-project/modin/issues/5924 - ErrorMessage.missmatch_with_pandas( - operation="range-partitioning groupby", - message="the order of rows may be shuffled for the result", + if add_missing_cats and not groupby_kwargs.get("as_index", True): + raise NotImplementedError( + "Range-partitioning groupby is not implemented for grouping on categorical columns with " + + "the following set of parameters {'as_index': False, 'observed': False}. Change either 'as_index' " + + "or 'observed' to True and try again. " + + "https://github.com/modin-project/modin/issues/5926" ) if isinstance(agg_func, dict): @@ -3842,6 +3854,7 @@ def agg_func(grp, *args, **kwargs): # that's why we have to align the partition's shapes/labeling across different # row partitions align_result_columns=how == "group_wise", + add_missing_cats=add_missing_cats, **groupby_kwargs, ) result_qc = self.__constructor__(result) diff --git a/modin/pandas/test/test_groupby.py b/modin/pandas/test/test_groupby.py index 34493e1ac34..c28ccad1832 100644 --- a/modin/pandas/test/test_groupby.py +++ b/modin/pandas/test/test_groupby.py @@ -2963,9 +2963,13 @@ def test_reshuffling_groupby_on_strings(modify_config): modin_df = modin_df.astype({"col1": "string"}) pandas_df = pandas_df.astype({"col1": "string"}) - eval_general( - modin_df.groupby("col1"), pandas_df.groupby("col1"), lambda grp: grp.mean() - ) + md_grp = modin_df.groupby("col1") + pd_grp = pandas_df.groupby("col1") + + eval_general(md_grp, pd_grp, lambda grp: grp.mean()) + eval_general(md_grp, pd_grp, lambda grp: grp.nth()) + eval_general(md_grp, pd_grp, lambda grp: grp.head(10)) + eval_general(md_grp, pd_grp, lambda grp: grp.tail(10)) @pytest.mark.parametrize( @@ -3146,3 +3150,90 @@ def test_groupby_agg_provided_callable_warning(): match="In a future version of pandas, the provided callable will be used directly", ): pandas_groupby.agg(func) + + +def _apply_transform(df): + if len(df) == 0: + df = df.copy() + df.loc[0] = 10 + return df.squeeze() + return df.sum() + + +@pytest.mark.parametrize( + "modify_config", [{RangePartitioningGroupby: True}], indirect=True +) +@pytest.mark.parametrize("observed", [False]) +@pytest.mark.parametrize("as_index", [True]) +@pytest.mark.parametrize( + "func", + [ + pytest.param(lambda grp: grp.sum(), id="sum"), + pytest.param(lambda grp: grp.size(), id="size"), + pytest.param(lambda grp: grp.apply(lambda df: df.sum()), id="apply_sum"), + pytest.param(lambda grp: grp.apply(_apply_transform), id="apply_transform"), + ], +) +@pytest.mark.parametrize( + "by_cols, cat_cols", + [ + ("a", ["a"]), + ("b", ["b"]), + ("e", ["e"]), + (["a", "e"], ["a"]), + (["a", "e"], ["e"]), + (["a", "e"], ["a", "e"]), + (["b", "e"], ["b"]), + (["b", "e"], ["e"]), + (["b", "e"], ["b", "e"]), + (["a", "b", "e"], ["a"]), + (["a", "b", "e"], ["b"]), + (["a", "b", "e"], ["e"]), + (["a", "b", "e"], ["a", "e"]), + (["a", "b", "e"], ["a", "b", "e"]), + ], +) +@pytest.mark.parametrize( + "exclude_values", + [ + pytest.param(lambda row: ~row["a"].isin(["a", "e"]), id="exclude_from_a"), + pytest.param(lambda row: ~row["b"].isin([4]), id="exclude_from_b"), + pytest.param(lambda row: ~row["e"].isin(["x"]), id="exclude_from_e"), + pytest.param( + lambda row: ~row["a"].isin(["a", "e"]) & ~row["b"].isin([4]), + id="exclude_from_a_b", + ), + pytest.param( + lambda row: ~row["b"].isin([4]) & ~row["e"].isin(["x"]), + id="exclude_from_b_e", + ), + pytest.param( + lambda row: ~row["a"].isin(["a", "e"]) + & ~row["b"].isin([4]) + & ~row["e"].isin(["x"]), + id="exclude_from_a_b_e", + ), + ], +) +def test_range_groupby_categories( + observed, func, by_cols, cat_cols, exclude_values, as_index, modify_config +): + # HACK: there's a bug in range-partitioning impl that can be triggered + # here on certain seeds, manually setting the seed so it won't show up + # https://github.com/modin-project/modin/issues/6875 + np.random.seed(0) + data = { + "a": ["a", "b", "c", "d", "e", "b", "g", "a"] * 32, + "b": [1, 2, 3, 4] * 64, + "c": range(256), + "d": range(256), + "e": ["x", "y"] * 128, + } + + md_df, pd_df = create_test_dfs(data) + md_df = md_df.astype({col: "category" for col in cat_cols})[exclude_values] + pd_df = pd_df.astype({col: "category" for col in cat_cols})[exclude_values] + + md_res = func(md_df.groupby(by_cols, observed=observed, as_index=as_index)) + pd_res = func(pd_df.groupby(by_cols, observed=observed, as_index=as_index)) + df_equals(md_res, pd_res) From e726435f96967ef8225a851035d2bdcfe08fd7cd Mon Sep 17 00:00:00 2001 From: Anatoly Myachev Date: Tue, 30 Jan 2024 10:02:46 +0100 Subject: [PATCH 153/201] REFACTOR-#6889: Define `__all__` in `modin.config.__init__.py` (#6886) Signed-off-by: Anatoly Myachev --- modin/config/__init__.py | 94 ++++++++++++++++++++++++++++- modin/config/__main__.py | 23 ++++--- modin/config/envvars.py | 2 +- modin/config/pubsub.py | 2 +- modin/config/test/test_envvars.py | 13 ++-- modin/config/test/test_parameter.py | 2 +- modin/pandas/__init__.py | 10 ++- modin/pandas/test/test_groupby.py | 8 ++- modin/pandas/test/test_io.py | 2 +- 9 files changed, 128 insertions(+), 28 deletions(-) diff --git a/modin/config/__init__.py b/modin/config/__init__.py index ea41412cdf3..fef851c0116 100644 --- a/modin/config/__init__.py +++ b/modin/config/__init__.py @@ -13,5 +13,95 @@ """Module houses config entities which can be used for Modin behavior tuning.""" -from .envvars import * # noqa: F403, F401 -from .pubsub import Parameter # noqa: F401 +from modin.config.envvars import ( + AsvDataSizeConfig, + AsvImplementation, + AsyncReadMode, + BenchmarkMode, + CIAWSAccessKeyID, + CIAWSSecretAccessKey, + CpuCount, + DoUseCalcite, + Engine, + EnvironmentVariable, + ExperimentalGroupbyImpl, + ExperimentalNumPyAPI, + GithubCI, + GpuCount, + HdkFragmentSize, + HdkLaunchParameters, + IsDebug, + IsExperimental, + IsRayCluster, + LogFileSize, + LogMemoryInterval, + LogMode, + Memory, + MinPartitionSize, + ModinNumpy, + NPartitions, + PersistentPickle, + ProgressBar, + RangePartitioningGroupby, + RayRedisAddress, + RayRedisPassword, + ReadSqlEngine, + StorageFormat, + TestDatasetSize, + TestRayClient, + TestReadFromPostgres, + TestReadFromSqlServer, + TrackFileLeaks, +) +from modin.config.pubsub import Parameter, ValueSource + +__all__ = [ + "EnvironmentVariable", + "Parameter", + "ValueSource", + # General settings + "IsDebug", + "Engine", + "StorageFormat", + "CpuCount", + "GpuCount", + "Memory", + # Ray specific + "IsRayCluster", + "RayRedisAddress", + "RayRedisPassword", + "TestRayClient", + # Partitioning + "NPartitions", + "MinPartitionSize", + # HDK specific + "HdkFragmentSize", + "DoUseCalcite", + "HdkLaunchParameters", + # ASV specific + "TestDatasetSize", + "AsvImplementation", + "AsvDataSizeConfig", + # Specific features + "ProgressBar", + "BenchmarkMode", + "PersistentPickle", + "ModinNumpy", + "ExperimentalNumPyAPI", + "RangePartitioningGroupby", + "ExperimentalGroupbyImpl", + "AsyncReadMode", + "ReadSqlEngine", + "IsExperimental", + # For tests + "TrackFileLeaks", + "TestReadFromSqlServer", + "TestReadFromPostgres", + "GithubCI", + "CIAWSSecretAccessKey", + "CIAWSAccessKeyID", + # Logging + "LogMode", + "LogMemoryInterval", + "LogFileSize", +] diff --git a/modin/config/__main__.py b/modin/config/__main__.py index d0c3f72edfc..7310a719833 100644 --- a/modin/config/__main__.py +++ b/modin/config/__main__.py @@ -24,15 +24,18 @@ import pandas -from . import * # noqa: F403, F401 -from .pubsub import Parameter +import modin.config as cfg def print_config_help() -> None: """Print configs help messages.""" - for objname in sorted(globals()): - obj = globals()[objname] - if isinstance(obj, type) and issubclass(obj, Parameter) and not obj.is_abstract: + for objname in sorted(cfg.__all__): + obj = getattr(cfg, objname) + if ( + isinstance(obj, type) + and issubclass(obj, cfg.Parameter) + and not obj.is_abstract + ): print(f"{obj.get_help()}\n\tCurrent value: {obj.get()}") # noqa: T201 @@ -51,9 +54,13 @@ def export_config_help(filename: str) -> None: CpuCount="multiprocessing.cpu_count()", NPartitions="equals to MODIN_CPUS env", ) - for objname in sorted(globals()): - obj = globals()[objname] - if isinstance(obj, type) and issubclass(obj, Parameter) and not obj.is_abstract: + for objname in sorted(cfg.__all__): + obj = getattr(cfg, objname) + if ( + isinstance(obj, type) + and issubclass(obj, cfg.Parameter) + and not obj.is_abstract + ): data = { "Config Name": obj.__name__, "Env. Variable Name": getattr( diff --git a/modin/config/envvars.py b/modin/config/envvars.py index 5ab97abda9b..18413724358 100644 --- a/modin/config/envvars.py +++ b/modin/config/envvars.py @@ -23,7 +23,7 @@ from packaging import version from pandas.util._decorators import doc # type: ignore[attr-defined] -from .pubsub import ( +from modin.config.pubsub import ( _TYPE_PARAMS, _UNSET, DeprecationDescriptor, diff --git a/modin/config/pubsub.py b/modin/config/pubsub.py index 2feca34d5c1..a49f906ca64 100644 --- a/modin/config/pubsub.py +++ b/modin/config/pubsub.py @@ -28,7 +28,7 @@ ) if TYPE_CHECKING: - from .envvars import EnvironmentVariable + from modin.config.envvars import EnvironmentVariable class DeprecationDescriptor: diff --git a/modin/config/test/test_envvars.py b/modin/config/test/test_envvars.py index 2387388e520..16f7ed74cad 100644 --- a/modin/config/test/test_envvars.py +++ b/modin/config/test/test_envvars.py @@ -19,16 +19,11 @@ from packaging import version import modin.config as cfg -from modin.config.envvars import ( - _UNSET, - EnvironmentVariable, - ExactStr, - Parameter, - _check_vars, -) +from modin.config.envvars import _check_vars +from modin.config.pubsub import _UNSET, ExactStr -def reset_vars(*vars: tuple[Parameter]): +def reset_vars(*vars: tuple[cfg.Parameter]): """ Reset value for the passed parameters. @@ -51,7 +46,7 @@ def make_unknown_env(): @pytest.fixture(params=[str, ExactStr]) def make_custom_envvar(request): - class CustomVar(EnvironmentVariable, type=request.param): + class CustomVar(cfg.EnvironmentVariable, type=request.param): """custom var""" default = 10 diff --git a/modin/config/test/test_parameter.py b/modin/config/test/test_parameter.py index f2ae02e9fae..cc9bbb5f3f2 100644 --- a/modin/config/test/test_parameter.py +++ b/modin/config/test/test_parameter.py @@ -15,7 +15,7 @@ import pytest -from modin.config.pubsub import Parameter +from modin.config import Parameter def make_prefilled(vartype, varinit): diff --git a/modin/pandas/__init__.py b/modin/pandas/__init__.py index 0116104f520..a45cb3116e0 100644 --- a/modin/pandas/__init__.py +++ b/modin/pandas/__init__.py @@ -101,9 +101,13 @@ def _update_engine(publisher: Parameter): - from modin.config import CpuCount, Engine, StorageFormat - from modin.config.envvars import IsExperimental - from modin.config.pubsub import ValueSource + from modin.config import ( + CpuCount, + Engine, + IsExperimental, + StorageFormat, + ValueSource, + ) # Set this so that Pandas doesn't try to multithread by itself os.environ["OMP_NUM_THREADS"] = "1" diff --git a/modin/pandas/test/test_groupby.py b/modin/pandas/test/test_groupby.py index c28ccad1832..aafb81b6bd1 100644 --- a/modin/pandas/test/test_groupby.py +++ b/modin/pandas/test/test_groupby.py @@ -21,8 +21,12 @@ import pytest import modin.pandas as pd -from modin.config import NPartitions, StorageFormat -from modin.config.envvars import IsRayCluster, RangePartitioningGroupby +from modin.config import ( + IsRayCluster, + NPartitions, + RangePartitioningGroupby, + StorageFormat, +) from modin.core.dataframe.algebra.default2pandas.groupby import GroupBy from modin.core.dataframe.pandas.partitioning.axis_partition import ( PandasDataframeAxisPartition, diff --git a/modin/pandas/test/test_io.py b/modin/pandas/test/test_io.py index c985a5d2c2b..e6ae1d1f797 100644 --- a/modin/pandas/test/test_io.py +++ b/modin/pandas/test/test_io.py @@ -39,13 +39,13 @@ AsyncReadMode, Engine, IsExperimental, + MinPartitionSize, ReadSqlEngine, StorageFormat, TestDatasetSize, TestReadFromPostgres, TestReadFromSqlServer, ) -from modin.config.envvars import MinPartitionSize from modin.db_conn import ModinDatabaseConnection, UnsupportedDatabaseException from modin.pandas.io import from_arrow, to_pandas from modin.test.test_utils import warns_that_defaulting_to_pandas From 93e7aff3392903282d23491723a01ff1091dbeac Mon Sep 17 00:00:00 2001 From: Anatoly Myachev Date: Tue, 30 Jan 2024 12:27:38 +0100 Subject: [PATCH 154/201] FIX-#2405: Make sure named aggregation work for Series objects (#6892) Co-authored-by: Iaroslav Igoshev Signed-off-by: Anatoly Myachev --- modin/pandas/groupby.py | 41 +++++++++++++++++++++++++++++++ modin/pandas/test/test_groupby.py | 8 ++++++ modin/pandas/test/utils.py | 5 ++++ 3 files changed, 54 insertions(+) diff --git a/modin/pandas/groupby.py b/modin/pandas/groupby.py index 7f6f9193c26..d0fcccfcd93 100644 --- a/modin/pandas/groupby.py +++ b/modin/pandas/groupby.py @@ -1924,8 +1924,47 @@ def nsmallest(self, n=5, keep="first"): ) ) + def _validate_func_kwargs(self, kwargs: dict): + """ + Validate types of user-provided "named aggregation" kwargs. + + Parameters + ---------- + kwargs : dict + + Returns + ------- + columns : List[str] + List of user-provided keys. + funcs : List[Union[str, callable[...,Any]]] + List of user-provided aggfuncs. + + Raises + ------ + `TypeError` is raised if aggfunc is not `str` or callable. + + Notes + ----- + Copied from pandas. + """ + columns = list(kwargs) + funcs = [] + for col_func in kwargs.values(): + if not (isinstance(col_func, str) or callable(col_func)): + raise TypeError( + f"func is expected but received {type(col_func).__name__} in **kwargs." + ) + funcs.append(col_func) + if not columns: + raise TypeError("Must provide 'func' or named aggregation **kwargs.") + return columns, funcs + def aggregate(self, func=None, *args, engine=None, engine_kwargs=None, **kwargs): engine_default = engine is None and engine_kwargs is None + # if func is None, will switch to user-provided "named aggregation" kwargs + if func_is_none := func is None: + columns, func = self._validate_func_kwargs(kwargs) + kwargs = {} if isinstance(func, dict) and engine_default: raise SpecificationError("nested renamer is not supported") elif is_list_like(func) and engine_default: @@ -1946,6 +1985,8 @@ def aggregate(self, func=None, *args, engine=None, engine_kwargs=None, **kwargs) # because there is no need to identify which original column's aggregation # the new column represents. alternatively we could give the query compiler # a hint that it's for a series, not a dataframe. + if func_is_none: + return result.set_axis(labels=columns, axis=1, copy=False) return result.set_axis( labels=self._try_get_str_func(func), axis=1, copy=False ) diff --git a/modin/pandas/test/test_groupby.py b/modin/pandas/test/test_groupby.py index aafb81b6bd1..6b23329d30b 100644 --- a/modin/pandas/test/test_groupby.py +++ b/modin/pandas/test/test_groupby.py @@ -44,6 +44,7 @@ from .utils import ( check_df_columns_have_nans, create_test_dfs, + create_test_series, default_to_pandas_ignore_string, df_equals, dict_equals, @@ -2993,6 +2994,13 @@ def test_groupby_apply_series_result(modify_config): ) +def test_groupby_named_aggregation(): + modin_ser, pandas_ser = create_test_series([10, 10, 10, 1, 1, 1, 2, 3], name="data") + eval_general( + modin_ser, pandas_ser, lambda ser: ser.groupby(level=0).agg(result=("max")) + ) + + ### TEST GROUPBY WARNINGS ### diff --git a/modin/pandas/test/utils.py b/modin/pandas/test/utils.py index d8496c8717c..4452c173fed 100644 --- a/modin/pandas/test/utils.py +++ b/modin/pandas/test/utils.py @@ -1033,6 +1033,11 @@ def create_test_dfs(*args, **kwargs): ) +def create_test_series(*args, **kwargs): + post_fn = kwargs.pop("post_fn", lambda df: df) + return map(post_fn, [pd.Series(*args, **kwargs), pandas.Series(*args, **kwargs)]) + + def generate_dfs(): df = pandas.DataFrame( { From c130e13f805911b6f00871c4783e0b9da72a8a41 Mon Sep 17 00:00:00 2001 From: Dmitry Chigarev Date: Tue, 30 Jan 2024 13:58:23 +0100 Subject: [PATCH 155/201] FIX-#5925: Put a sorting-hack into groupby tests to hide #6875 bug (#6896) Signed-off-by: Dmitry Chigarev --- modin/pandas/test/test_groupby.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/modin/pandas/test/test_groupby.py b/modin/pandas/test/test_groupby.py index 6b23329d30b..00866739e2c 100644 --- a/modin/pandas/test/test_groupby.py +++ b/modin/pandas/test/test_groupby.py @@ -3230,10 +3230,6 @@ def _apply_transform(df): def test_range_groupby_categories( observed, func, by_cols, cat_cols, exclude_values, as_index, modify_config ): - # HACK: there's a bug in range-partitioning impl that can be triggered - # here on certain seeds, manually setting the seed so it won't show up - # https://github.com/modin-project/modin/issues/6875 - np.random.seed(0) data = { "a": ["a", "b", "c", "d", "e", "b", "g", "a"] * 32, "b": [1, 2, 3, 4] * 64, @@ -3248,4 +3244,8 @@ def test_range_groupby_categories( md_res = func(md_df.groupby(by_cols, observed=observed, as_index=as_index)) pd_res = func(pd_df.groupby(by_cols, observed=observed, as_index=as_index)) - df_equals(md_res, pd_res) + + # HACK, FIXME: there's a bug in range-partitioning impl that apparently can + # break the order of rows in the result for multi-column groupbys. Placing the sorting-hack for now + # https://github.com/modin-project/modin/issues/6875 + df_equals(md_res.sort_index(axis=0), pd_res.sort_index(axis=0)) From bfe77ed6d73b7f24d8b3e372df7994923ffbcf34 Mon Sep 17 00:00:00 2001 From: Dmitry Chigarev Date: Wed, 31 Jan 2024 16:28:28 +0100 Subject: [PATCH 156/201] FIX-#6897: Preprocess kernel function that alignes columns in groupby (#6898) Signed-off-by: Dmitry Chigarev --- modin/core/dataframe/pandas/dataframe/dataframe.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/modin/core/dataframe/pandas/dataframe/dataframe.py b/modin/core/dataframe/pandas/dataframe/dataframe.py index 6ed18805336..a67db9a8cab 100644 --- a/modin/core/dataframe/pandas/dataframe/dataframe.py +++ b/modin/core/dataframe/pandas/dataframe/dataframe.py @@ -3897,7 +3897,9 @@ def compute_aligned_columns(*dfs, initial_columns=None): # aligned columns parts = result._partitions.flatten() aligned_columns = parts[0].apply( - compute_aligned_columns, + # TODO: unidist on MPI execution requires for this function to be preprocessed, + # otherwise, the execution fails. Look into the issue later. + self._partition_mgr_cls.preprocess_func(compute_aligned_columns), *[part._data for part in parts[1:]], initial_columns=self.columns, ) From 7c4a6650f3c6f7911b9373b84c809b4b784f847f Mon Sep 17 00:00:00 2001 From: Dmitry Chigarev Date: Wed, 31 Jan 2024 17:17:53 +0100 Subject: [PATCH 157/201] FIX-#6899: Avoid sending lazy categorical proxies to workers (#6900) Signed-off-by: Dmitry Chigarev --- .../dataframe/pandas/dataframe/dataframe.py | 17 ++++++++++++++++- modin/pandas/test/test_groupby.py | 19 ++++++++++--------- 2 files changed, 26 insertions(+), 10 deletions(-) diff --git a/modin/core/dataframe/pandas/dataframe/dataframe.py b/modin/core/dataframe/pandas/dataframe/dataframe.py index a67db9a8cab..8d70881e45a 100644 --- a/modin/core/dataframe/pandas/dataframe/dataframe.py +++ b/modin/core/dataframe/pandas/dataframe/dataframe.py @@ -3852,7 +3852,22 @@ def apply_func(df): # pragma: no cover # 2. The second one works slower, but only gathers light pandas.Index objects, # so there should be less stress on the network. if add_missing_cats or not IsRayCluster.get(): - original_dtypes = self.dtypes if self.has_materialized_dtypes else None + if self.has_materialized_dtypes: + original_dtypes = pandas.Series( + { + # lazy proxies hold a reference to another modin's DataFrame which can be + # a problem during serialization, in this scenario we don't need actual + # categorical values, so a "category" string will be enough + name: ( + "category" + if isinstance(dtype, LazyProxyCategoricalDtype) + else dtype + ) + for name, dtype in self.dtypes.items() + } + ) + else: + original_dtypes = None def compute_aligned_columns(*dfs, initial_columns=None): """Take row partitions, filter empty ones, and return joined columns for them.""" diff --git a/modin/pandas/test/test_groupby.py b/modin/pandas/test/test_groupby.py index 00866739e2c..b9079b2bec5 100644 --- a/modin/pandas/test/test_groupby.py +++ b/modin/pandas/test/test_groupby.py @@ -3164,14 +3164,6 @@ def test_groupby_agg_provided_callable_warning(): pandas_groupby.agg(func) -def _apply_transform(df): - if len(df) == 0: - df = df.copy() - df.loc[0] = 10 - return df.squeeze() - return df.sum() - - @pytest.mark.parametrize( "modify_config", [{RangePartitioningGroupby: True}], indirect=True ) @@ -3183,7 +3175,16 @@ def _apply_transform(df): pytest.param(lambda grp: grp.sum(), id="sum"), pytest.param(lambda grp: grp.size(), id="size"), pytest.param(lambda grp: grp.apply(lambda df: df.sum()), id="apply_sum"), - pytest.param(lambda grp: grp.apply(_apply_transform), id="apply_transform"), + pytest.param( + lambda grp: grp.apply( + lambda df: ( + df.sum() + if len(df) > 0 + else pandas.Series([10] * len(df.columns), index=df.columns) + ) + ), + id="apply_transform", + ), ], ) @pytest.mark.parametrize( From 4a32e71a7dd91d9ab7ab5d1763cc7fe0f52c08e4 Mon Sep 17 00:00:00 2001 From: Vedant <70121054+Vedant222@users.noreply.github.com> Date: Thu, 1 Feb 2024 15:49:05 +0530 Subject: [PATCH 158/201] REFACTOR-#6293: Corrected 'missmatch' to 'mismatch' in ErrorMessage.missmatch_with_pandas method (#6901) Signed-off-by: Vedant <2711vedant@gmail.com> --- modin/core/dataframe/algebra/groupby.py | 2 +- modin/core/storage_formats/pandas/parsers.py | 2 +- modin/core/storage_formats/pandas/query_compiler.py | 6 +++--- modin/error_message.py | 2 +- modin/pandas/groupby.py | 4 ++-- modin/pandas/test/utils.py | 2 +- modin/pandas/window.py | 2 +- modin/test/storage_formats/pandas/test_internals.py | 2 +- 8 files changed, 11 insertions(+), 11 deletions(-) diff --git a/modin/core/dataframe/algebra/groupby.py b/modin/core/dataframe/algebra/groupby.py index 0f97e2d7fcb..6967f904c10 100644 --- a/modin/core/dataframe/algebra/groupby.py +++ b/modin/core/dataframe/algebra/groupby.py @@ -379,7 +379,7 @@ def caller( if not groupby_kwargs.get("sort", True) and isinstance( by, type(query_compiler) ): - ErrorMessage.missmatch_with_pandas( + ErrorMessage.mismatch_with_pandas( operation="df.groupby(categorical_by, sort=False)", message=( "the groupby keys will be sorted anyway, although the 'sort=False' was passed. " diff --git a/modin/core/storage_formats/pandas/parsers.py b/modin/core/storage_formats/pandas/parsers.py index b677a541d2e..f76ef71d128 100644 --- a/modin/core/storage_formats/pandas/parsers.py +++ b/modin/core/storage_formats/pandas/parsers.py @@ -254,7 +254,7 @@ def get_dtypes(cls, dtypes_ids, columns): frame_dtypes.name = None if not combined_part_dtypes.eq(frame_dtypes, axis=0).all(axis=None): - ErrorMessage.missmatch_with_pandas( + ErrorMessage.mismatch_with_pandas( operation="read_*", message="Data types of partitions are different! " + "Please refer to the troubleshooting section of the Modin documentation " diff --git a/modin/core/storage_formats/pandas/query_compiler.py b/modin/core/storage_formats/pandas/query_compiler.py index 56bfbcdd5bd..70e2aeffb7b 100644 --- a/modin/core/storage_formats/pandas/query_compiler.py +++ b/modin/core/storage_formats/pandas/query_compiler.py @@ -2706,7 +2706,7 @@ def melt( col_level=None, ignore_index=True, ): - ErrorMessage.missmatch_with_pandas( + ErrorMessage.mismatch_with_pandas( operation="melt", message="Order of rows could be different from pandas" ) @@ -3785,7 +3785,7 @@ def _groupby_shuffle( if is_transform: # https://github.com/modin-project/modin/issues/5924 - ErrorMessage.missmatch_with_pandas( + ErrorMessage.mismatch_with_pandas( operation="range-partitioning groupby", message="the order of rows may be shuffled for the result", ) @@ -4344,7 +4344,7 @@ def pivot_table( observed, sort, ): - ErrorMessage.missmatch_with_pandas( + ErrorMessage.mismatch_with_pandas( operation="pivot_table", message="Order of columns could be different from pandas", ) diff --git a/modin/error_message.py b/modin/error_message.py index 212b698fc2a..54c646777af 100644 --- a/modin/error_message.py +++ b/modin/error_message.py @@ -101,7 +101,7 @@ def bad_type_for_numpy_op(cls, function_name: str, operand_type: type) -> None: ) @classmethod - def missmatch_with_pandas(cls, operation: str, message: str) -> None: + def mismatch_with_pandas(cls, operation: str, message: str) -> None: get_logger().debug( f"Modin Warning: {operation} mismatch with pandas: {message}" ) diff --git a/modin/pandas/groupby.py b/modin/pandas/groupby.py index d0fcccfcd93..4790e95a1d3 100644 --- a/modin/pandas/groupby.py +++ b/modin/pandas/groupby.py @@ -778,7 +778,7 @@ def __getitem__(self, key): if make_dataframe: internal_by = frozenset(self._internal_by) if len(internal_by.intersection(key)) != 0: - ErrorMessage.missmatch_with_pandas( + ErrorMessage.mismatch_with_pandas( operation="GroupBy.__getitem__", message=( "intersection of the selection and 'by' columns is not yet supported, " @@ -933,7 +933,7 @@ def do_relabel(obj_to_relabel): # noqa: F811 and not self._as_index and any(col in func_dict for col in self._internal_by) ): - ErrorMessage.missmatch_with_pandas( + ErrorMessage.mismatch_with_pandas( operation="GroupBy.aggregate(**dictionary_renaming_aggregation)", message=( "intersection of the columns to aggregate and 'by' is not yet supported when 'as_index=False', " diff --git a/modin/pandas/test/utils.py b/modin/pandas/test/utils.py index 4452c173fed..1303448f9d9 100644 --- a/modin/pandas/test/utils.py +++ b/modin/pandas/test/utils.py @@ -951,7 +951,7 @@ def eval_io( comparator: obj Function to perform comparison. cast_to_str: bool - There could be some missmatches in dtypes, so we're + There could be some mismatches in dtypes, so we're casting the whole frame to `str` before comparison. See issue #1931 for details. check_exception_type: bool diff --git a/modin/pandas/window.py b/modin/pandas/window.py index c0a2bf7ccc9..3321e496259 100644 --- a/modin/pandas/window.py +++ b/modin/pandas/window.py @@ -272,7 +272,7 @@ def __init__(self, groupby_obj, *args, **kwargs): super().__init__(self._groupby_obj._df, *args, **kwargs) def sem(self, *args, **kwargs): - ErrorMessage.missmatch_with_pandas( + ErrorMessage.mismatch_with_pandas( operation="RollingGroupby.sem() when 'as_index=False'", message=( "The group columns won't be involved in the aggregation.\n" diff --git a/modin/test/storage_formats/pandas/test_internals.py b/modin/test/storage_formats/pandas/test_internals.py index 71b01ea56db..b448b718e98 100644 --- a/modin/test/storage_formats/pandas/test_internals.py +++ b/modin/test/storage_formats/pandas/test_internals.py @@ -772,7 +772,7 @@ def test_groupby_with_empty_partition(): ) md_res = md_df.query("a > 1", engine="python") grp_obj = md_res.groupby("a") - # check index error due to partitioning missmatching + # check index error due to partitioning mismatching grp_obj.count() md_df = construct_modin_df_by_scheme( From 3e42b8b703eeaab4f961c15ec4c0c6644e56cdfe Mon Sep 17 00:00:00 2001 From: Dmitry Chigarev Date: Thu, 1 Feb 2024 12:52:40 +0100 Subject: [PATCH 159/201] FIX-#6897: Revert unidist specific fix for groupby (#6902) Signed-off-by: Dmitry Chigarev --- modin/core/dataframe/pandas/dataframe/dataframe.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/modin/core/dataframe/pandas/dataframe/dataframe.py b/modin/core/dataframe/pandas/dataframe/dataframe.py index 8d70881e45a..c6b45d46c3b 100644 --- a/modin/core/dataframe/pandas/dataframe/dataframe.py +++ b/modin/core/dataframe/pandas/dataframe/dataframe.py @@ -3912,9 +3912,7 @@ def compute_aligned_columns(*dfs, initial_columns=None): # aligned columns parts = result._partitions.flatten() aligned_columns = parts[0].apply( - # TODO: unidist on MPI execution requires for this function to be preprocessed, - # otherwise, the execution fails. Look into the issue later. - self._partition_mgr_cls.preprocess_func(compute_aligned_columns), + compute_aligned_columns, *[part._data for part in parts[1:]], initial_columns=self.columns, ) From abbbd03023f55d4ee9592c4c695308e10a97cb4c Mon Sep 17 00:00:00 2001 From: Dmitry Chigarev Date: Thu, 1 Feb 2024 14:36:10 +0100 Subject: [PATCH 160/201] FEAT-#6883: Support grouping on a Series with range-partitioning impl (#6888) Signed-off-by: Dmitry Chigarev --- .../dataframe/pandas/dataframe/dataframe.py | 250 +++++++++++++++--- .../storage_formats/pandas/query_compiler.py | 129 ++++++--- modin/pandas/test/test_groupby.py | 156 +++++++++-- modin/pandas/test/test_series.py | 14 +- modin/pandas/test/utils.py | 14 +- 5 files changed, 443 insertions(+), 120 deletions(-) diff --git a/modin/core/dataframe/pandas/dataframe/dataframe.py b/modin/core/dataframe/pandas/dataframe/dataframe.py index c6b45d46c3b..259d8b42414 100644 --- a/modin/core/dataframe/pandas/dataframe/dataframe.py +++ b/modin/core/dataframe/pandas/dataframe/dataframe.py @@ -18,6 +18,7 @@ for pandas storage format. """ import datetime +import re from typing import TYPE_CHECKING, Callable, Dict, Hashable, List, Optional, Union import numpy as np @@ -2423,7 +2424,14 @@ def combine_and_apply( ) def _apply_func_to_range_partitioning( - self, key_columns, func, ascending=True, preserve_columns=False, **kwargs + self, + key_columns, + func, + ascending=True, + preserve_columns=False, + data=None, + data_key_columns=None, + **kwargs, ): """ Reshuffle data so it would be range partitioned and then apply the passed function row-wise. @@ -2438,6 +2446,12 @@ def _apply_func_to_range_partitioning( Whether the range should be built in ascending or descending order. preserve_columns : bool, default: False If the columns cache should be preserved (specify this flag if `func` doesn't change column labels). + data : PandasDataframe, optional + Dataframe to range-partition along with the `self` frame. If specified, the `func` will recieve + a dataframe with an additional MultiIndex level in columns that separates `self` and `data`: + ``df["grouper"] # self`` and ``df["data"] # data``. + data_key_columns : list of hashables, optional + Additional key columns from `data`. Will be combined with `key_columns`. **kwargs : dict Additional arguments to forward to the range builder function. @@ -2446,21 +2460,60 @@ def _apply_func_to_range_partitioning( PandasDataframe A new dataframe. """ + if data is not None: + # adding an extra MultiIndex level in order to separate `self grouper` from the `data` + # after concatenation + new_grouper_cols = pandas.MultiIndex.from_tuples( + [ + ("grouper", *col) if isinstance(col, tuple) else ("grouper", col) + for col in self.columns + ] + ) + grouper = self.copy() + grouper.columns = new_grouper_cols + + new_data_cols = pandas.MultiIndex.from_tuples( + [ + ("data", *col) if isinstance(col, tuple) else ("data", col) + for col in data.columns + ] + ) + data = data.copy() + data.columns = new_data_cols + + grouper = grouper.concat(axis=1, others=[data], how="right", sort=False) + + # since original column names were modified, have to modify 'key_columns' as well + key_columns = [ + ("grouper", *col) if isinstance(col, tuple) else ("grouper", col) + for col in key_columns + ] + if data_key_columns is None: + data_key_columns = [] + else: + data_key_columns = [ + ("data", *col) if isinstance(col, tuple) else ("data", col) + for col in data_key_columns + ] + key_columns += data_key_columns + else: + grouper = self + # If there's only one row partition can simply apply the function row-wise without the need to reshuffle - if self._partitions.shape[0] == 1: - result = self.apply_full_axis( + if grouper._partitions.shape[0] == 1: + result = grouper.apply_full_axis( axis=1, func=func, - new_columns=self.copy_columns_cache() if preserve_columns else None, + new_columns=grouper.copy_columns_cache() if preserve_columns else None, ) if preserve_columns: - result._set_axis_lengths_cache(self._column_widths_cache, axis=1) + result._set_axis_lengths_cache(grouper._column_widths_cache, axis=1) return result # don't want to inherit over-partitioning so doing this 'min' check - ideal_num_new_partitions = min(len(self._partitions), NPartitions.get()) - m = len(self) / ideal_num_new_partitions - sampling_probability = (1 / m) * np.log(ideal_num_new_partitions * len(self)) + ideal_num_new_partitions = min(len(grouper._partitions), NPartitions.get()) + m = len(grouper) / ideal_num_new_partitions + sampling_probability = (1 / m) * np.log(ideal_num_new_partitions * len(grouper)) # If this df is overpartitioned, we try to sample each partition with probability # greater than 1, which leads to an error. In this case, we can do one of the following # two things. If there is only enough rows for one partition, and we have only 1 column @@ -2471,39 +2524,39 @@ def _apply_func_to_range_partitioning( if sampling_probability >= 1: from modin.config import MinPartitionSize - ideal_num_new_partitions = round(len(self) / MinPartitionSize.get()) - if len(self) < MinPartitionSize.get() or ideal_num_new_partitions < 2: + ideal_num_new_partitions = round(len(grouper) / MinPartitionSize.get()) + if len(grouper) < MinPartitionSize.get() or ideal_num_new_partitions < 2: # If the data is too small, we shouldn't try reshuffling/repartitioning but rather # simply combine all partitions and apply the sorting to the whole dataframe - return self.combine_and_apply(func=func) + return grouper.combine_and_apply(func=func) - if ideal_num_new_partitions < len(self._partitions): - if len(self._partitions) % ideal_num_new_partitions == 0: + if ideal_num_new_partitions < len(grouper._partitions): + if len(grouper._partitions) % ideal_num_new_partitions == 0: joining_partitions = np.split( - self._partitions, ideal_num_new_partitions + grouper._partitions, ideal_num_new_partitions ) else: - step = round(len(self._partitions) / ideal_num_new_partitions) + step = round(len(grouper._partitions) / ideal_num_new_partitions) joining_partitions = np.split( - self._partitions, - range(step, len(self._partitions), step), + grouper._partitions, + range(step, len(grouper._partitions), step), ) new_partitions = np.array( [ - self._partition_mgr_cls.column_partitions( + grouper._partition_mgr_cls.column_partitions( ptn_grp, full_axis=False ) for ptn_grp in joining_partitions ] ) else: - new_partitions = self._partitions + new_partitions = grouper._partitions else: - new_partitions = self._partitions + new_partitions = grouper._partitions shuffling_functions = ShuffleSortFunctions( - self, + grouper, key_columns, ascending[0] if is_list_like(ascending) else ascending, ideal_num_new_partitions, @@ -2511,25 +2564,25 @@ def _apply_func_to_range_partitioning( ) # here we want to get indices of those partitions that hold the key columns - key_indices = self.columns.get_indexer_for(key_columns) + key_indices = grouper.columns.get_indexer_for(key_columns) partition_indices = np.unique( - np.digitize(key_indices, np.cumsum(self.column_widths)) + np.digitize(key_indices, np.cumsum(grouper.column_widths)) ) - new_partitions = self._partition_mgr_cls.shuffle_partitions( + new_partitions = grouper._partition_mgr_cls.shuffle_partitions( new_partitions, partition_indices, shuffling_functions, func, ) - result = self.__constructor__(new_partitions) + result = grouper.__constructor__(new_partitions) if preserve_columns: - result.set_columns_cache(self.copy_columns_cache()) + result.set_columns_cache(grouper.copy_columns_cache()) # We perform the final steps of the sort on full axis partitions, so we know that the # length of each partition is the full length of the dataframe. - if self.has_materialized_columns: - result._set_axis_lengths_cache([len(self.columns)], axis=1) + if grouper.has_materialized_columns: + result._set_axis_lengths_cache([len(grouper.columns)], axis=1) return result @lazy_metadata_decorator(apply_axis="both") @@ -3756,10 +3809,13 @@ def _compute_new_widths(): def groupby( self, axis: Union[int, Axis], - by: Union[str, List[str]], + internal_by: List[str], + external_by: List["PandasDataframe"], + by_positions: List[int], operator: Callable, result_schema: Optional[Dict[Hashable, type]] = None, align_result_columns: bool = False, + series_groupby: bool = False, add_missing_cats: bool = False, **kwargs: dict, ) -> "PandasDataframe": @@ -3770,8 +3826,22 @@ def groupby( ---------- axis : int or modin.core.dataframe.base.utils.Axis The axis to apply the grouping over. - by : string or list of strings - One or more column labels to use for grouping. + internal_by : list of strings + One or more column labels from the `self` dataframe to use for grouping. + external_by : list of PandasDataframes + PandasDataframes to group by (may be specified along with or without `internal_by`). + by_positions : list of ints + Specifies the order of grouping by `internal_by` and `external_by` columns. + Each element in `by_positions` specifies an index from either `external_by` or `internal_by`. + Indices for `external_by` are positive and start from 0. Indices for `internal_by` are negative + and start from -1 (so in order to convert them to a valid indices one should do ``-idx - 1``). + ''' + by_positions = [0, -1, 1, -2, 2, 3] + internal_by = ["col1", "col2"] + external_by = [sr1, sr2, sr3, sr4] + + df.groupby([sr1, "col1", sr2, "col2", sr3, sr4]) + '''. operator : callable(pandas.core.groupby.DataFrameGroupBy) -> pandas.DataFrame The operation to carry out on each of the groups. The operator is another algebraic operator with its own user-defined function parameter, depending @@ -3782,6 +3852,8 @@ def groupby( Whether to manually align columns between all the resulted row partitions. This flag is helpful when dealing with UDFs as they can change the partition's shape and labeling unpredictably, resulting in an invalid dataframe. + series_groupby : bool, default: False + Whether to convert a one-column DataFrame to a Series before performing groupby. add_missing_cats : bool, default: False Whether to add missing categories from `by` columns to the result. **kwargs : dict @@ -3810,14 +3882,56 @@ def groupby( f"Algebra groupby only implemented row-wise. {axis.name} axis groupby not implemented yet!" ) - if not isinstance(by, list): - by = [by] - - kwargs["observed"] = True + has_external_grouper = len(external_by) > 0 skip_on_aligning_flag = "__skip_me_on_aligning__" + duplicated_suffix = "__duplicated_suffix__" + duplicated_pattern = r"_[\d]*__duplicated_suffix__" + kwargs["observed"] = True def apply_func(df): # pragma: no cover + if has_external_grouper: + external_grouper = df["grouper"] + external_grouper = [ + # `df.groupby()` can only take a list of Series'es, so splitting + # the df into a list of individual Series'es + external_grouper.iloc[:, i] + for i in range(len(external_grouper.columns)) + ] + + # renaming 'None' and duplicated names back to their original names + for obj in external_grouper: + if not isinstance(obj, pandas.Series): + continue + name = obj.name + if isinstance(name, str): + if name.startswith(MODIN_UNNAMED_SERIES_LABEL): + name = None + elif name.endswith(duplicated_suffix): + name = re.sub(duplicated_pattern, "", name) + elif isinstance(name, tuple): + if name[-1].endswith(duplicated_suffix): + name = ( + *name[:-1], + re.sub(duplicated_pattern, "", name[-1]), + ) + obj.name = name + + df = df["data"] + else: + external_grouper = [] + + by = [] + # restoring original order of 'by' columns + for idx in by_positions: + if idx >= 0: + by.append(external_grouper[idx]) + else: + by.append(internal_by[-idx - 1]) + + if series_groupby: + df = df.squeeze(axis=1) result = operator(df.groupby(by, **kwargs)) + if ( align_result_columns and df.empty @@ -3832,9 +3946,49 @@ def apply_func(df): # pragma: no cover result.attrs[skip_on_aligning_flag] = True return result - result = self._apply_func_to_range_partitioning( - key_columns=by, + if has_external_grouper: + grouper = ( + external_by[0] + if len(external_by) == 1 + else external_by[0].concat( + axis=1, others=external_by[1:], how="left", sort=False + ) + ) + + new_grouper_cols = [] + columns_were_changed = False + same_columns = {} + # duplicated names break range-partitioning mechanism, so renaming them. + # original names will be reverted in the actual groupby kernel + for col in grouper.columns: + suffix = same_columns.get(col) + if suffix is None: + same_columns[col] = 0 + else: + same_columns[col] += 1 + col = ( + (*col[:-1], f"{col[-1]}_{suffix}{duplicated_suffix}") + if isinstance(col, tuple) + else f"{col}_{suffix}{duplicated_suffix}" + ) + columns_were_changed = True + new_grouper_cols.append(col) + + if columns_were_changed: + grouper.columns = pandas.Index(new_grouper_cols) + grouper_key_columns = grouper.columns + data = self + data_key_columns = internal_by + else: + grouper = self + grouper_key_columns = internal_by + data, data_key_columns = None, None + + result = grouper._apply_func_to_range_partitioning( + key_columns=grouper_key_columns, func=apply_func, + data=data, + data_key_columns=data_key_columns, ) # no need aligning columns if there's only one row partition if add_missing_cats or align_result_columns and result._partitions.shape[0] > 1: @@ -3869,7 +4023,7 @@ def apply_func(df): # pragma: no cover else: original_dtypes = None - def compute_aligned_columns(*dfs, initial_columns=None): + def compute_aligned_columns(*dfs, initial_columns=None, by=None): """Take row partitions, filter empty ones, and return joined columns for them.""" if align_result_columns: valid_dfs = [ @@ -3908,13 +4062,27 @@ def compute_aligned_columns(*dfs, initial_columns=None): else (None, masks) ) + external_by_cols = [ + None if col.startswith(MODIN_UNNAMED_SERIES_LABEL) else col + for obj in external_by + for col in obj.columns + ] + by = [] + # restoring original order of 'by' columns + for idx in by_positions: + if idx >= 0: + by.append(external_by_cols[idx]) + else: + by.append(internal_by[-idx - 1]) + # Passing all partitions to the 'compute_aligned_columns' kernel to get # aligned columns parts = result._partitions.flatten() aligned_columns = parts[0].apply( compute_aligned_columns, *[part._data for part in parts[1:]], - initial_columns=self.columns, + initial_columns=pandas.Index(external_by_cols).append(self.columns), + by=by, ) def apply_aligned(df, args, partition_idx): @@ -3979,8 +4147,8 @@ def join_cols(df, *cols): row_lengths=result._row_lengths_cache, ) - if not result.has_materialized_index: - by_dtypes = ModinDtypes(self._dtypes).lazy_get(by) + if not result.has_materialized_index and not has_external_grouper: + by_dtypes = ModinDtypes(self._dtypes).lazy_get(internal_by) if by_dtypes.is_materialized: new_index = ModinIndex(value=result, axis=0, dtypes=by_dtypes) result.set_index_cache(new_index) diff --git a/modin/core/storage_formats/pandas/query_compiler.py b/modin/core/storage_formats/pandas/query_compiler.py index 70e2aeffb7b..90a3557f067 100644 --- a/modin/core/storage_formats/pandas/query_compiler.py +++ b/modin/core/storage_formats/pandas/query_compiler.py @@ -3459,9 +3459,9 @@ def _callable_func(self, func, axis, *args, **kwargs): # nature. They require certain data to exist on the same partition, and # after the shuffle, there should be only a local map required. - def _groupby_internal_columns(self, by, drop): + def _groupby_separate_by(self, by, drop): """ - Extract internal columns from by argument of groupby. + Separate internal and external groupers in `by` argument of groupby. Parameters ---------- @@ -3472,31 +3472,52 @@ def _groupby_internal_columns(self, by, drop): Returns ------- - by : list of BaseQueryCompiler, column or index label, or Grouper + external_by : list of BaseQueryCompiler and arrays + Values to group by. internal_by : list of str - List of internal column name to be dropped during groupby. + List of column names from `self` to group by. + by_positions : list of ints + Specifies the order of grouping by `internal_by` and `external_by` columns. + Each element in `by_positions` specifies an index from either `external_by` or `internal_by`. + Indices for `external_by` are positive and start from 0. Indices for `internal_by` are negative + and start from -1 (so in order to convert them to a valid indices one should do ``-idx - 1``) + ''' + by_positions = [0, -1, 1, -2, 2, 3] + internal_by = ["col1", "col2"] + external_by = [sr1, sr2, sr3, sr4] + + df.groupby([sr1, "col1", sr2, "col2", sr3, sr4]) + '''. """ if isinstance(by, type(self)): if drop: - internal_by = by.columns - by = [by] + internal_by = by.columns.tolist() + external_by = [] + by_positions = [-i - 1 for i in range(len(internal_by))] else: internal_by = [] - by = [by] + external_by = [by] + by_positions = [i for i in range(len(external_by[0].columns))] else: if not isinstance(by, list): by = [by] if by is not None else [] internal_by = [] + external_by = [] + external_by_counter = 0 + by_positions = [] for o in by: if isinstance(o, pandas.Grouper) and o.key in self.columns: internal_by.append(o.key) + by_positions.append(-len(internal_by)) elif hashable(o) and o in self.columns: internal_by.append(o) - internal_qc = ( - [self.getitem_column_array(internal_by)] if len(internal_by) else [] - ) - by = internal_qc + by[len(internal_by) :] - return by, internal_by + by_positions.append(-len(internal_by)) + else: + external_by.append(o) + for _ in range(len(o.columns) if isinstance(o, type(self)) else 1): + by_positions.append(external_by_counter) + external_by_counter += 1 + return external_by, internal_by, by_positions groupby_all = GroupbyReduceImpl.build_qc_method("all") groupby_any = GroupbyReduceImpl.build_qc_method("any") @@ -3542,7 +3563,7 @@ def groupby_mean(self, by, axis, groupby_kwargs, agg_args, agg_kwargs, drop=Fals + "\nFalling back to a TreeReduce implementation." ) - _, internal_by = self._groupby_internal_columns(by, drop) + _, internal_by, _ = self._groupby_separate_by(by, drop) numeric_only = agg_kwargs.get("numeric_only", False) datetime_cols = ( @@ -3760,6 +3781,7 @@ def _groupby_shuffle( agg_kwargs, drop=False, how="axis_wise", + series_groupby=False, ): # Defaulting to pandas in case of an empty frame as we can't process it properly. # Higher API level won't pass empty data here unless the frame has delayed @@ -3770,19 +3792,31 @@ def _groupby_shuffle( by, agg_func, axis, groupby_kwargs, agg_args, agg_kwargs, how, drop ) - if isinstance(by, type(self)) and drop: - by = by.columns.tolist() + if groupby_kwargs.get("level") is not None: + raise NotImplementedError( + "Grouping on an index level is not yet supported by range-partitioning groupby implementation: " + + "https://github.com/modin-project/modin/issues/5926" + ) - if not isinstance(by, list): - by = [by] + if any( + isinstance(obj, pandas.Grouper) + for obj in (by if isinstance(by, list) else [by]) + ): + raise NotImplementedError( + "Grouping on a pandas.Grouper with range-partitioning groupby is not yet supported: " + + "https://github.com/modin-project/modin/issues/5926" + ) - is_all_labels = all(isinstance(col, (str, tuple)) for col in by) - is_all_column_names = ( - all(col in self.columns for col in by) if is_all_labels else False - ) + external_by, internal_by, by_positions = self._groupby_separate_by(by, drop) - is_transform = how == "transform" or GroupBy.is_transformation_kernel(agg_func) + all_external_are_qcs = all(isinstance(obj, type(self)) for obj in external_by) + if not all_external_are_qcs: + raise NotImplementedError( + "Grouping on an external grouper with range-partitioning groupby is only supported with Series'es: " + + "https://github.com/modin-project/modin/issues/5926" + ) + is_transform = how == "transform" or GroupBy.is_transformation_kernel(agg_func) if is_transform: # https://github.com/modin-project/modin/issues/5924 ErrorMessage.mismatch_with_pandas( @@ -3790,21 +3824,31 @@ def _groupby_shuffle( message="the order of rows may be shuffled for the result", ) - if not is_all_column_names: - raise NotImplementedError( - "Range-partitioning groupby is only supported when grouping on a column(s) of the same frame. " - + "https://github.com/modin-project/modin/issues/5926" - ) - # This check materializes dtypes for 'by' columns if not is_transform and groupby_kwargs.get("observed", False) in ( False, lib.no_default, ): - if isinstance(self._modin_frame._dtypes, ModinDtypes): - by_dtypes = self._modin_frame._dtypes.lazy_get(by).get() - else: - by_dtypes = self.dtypes[by] + # The following 'dtypes' check materializes dtypes for 'by' columns + internal_dtypes = pandas.Series() + external_dtypes = pandas.Series() + if len(internal_by) > 0: + internal_dtypes = ( + self._modin_frame._dtypes.lazy_get(internal_by).get() + if isinstance(self._modin_frame._dtypes, ModinDtypes) + else self.dtypes[internal_by] + ) + if len(external_by) > 0: + dtypes_list = [] + for obj in external_by: + if not isinstance(obj, type(self)): + # we're only interested in categorical dtypes here, which can only + # appear in modin objects + continue + dtypes_list.append(obj.dtypes) + external_dtypes = pandas.concat(dtypes_list) + + by_dtypes = pandas.concat([internal_dtypes, external_dtypes]) add_missing_cats = any( isinstance(dtype, pandas.CategoricalDtype) for dtype in by_dtypes ) @@ -3824,7 +3868,7 @@ def _groupby_shuffle( how == "axis_wise" ), f"Only 'axis_wise' aggregation is supported with dictionary functions, got: {how}" - subset = by + list(agg_func.keys()) + subset = internal_by + list(agg_func.keys()) # extracting unique values; no we can't use np.unique here as it would # convert a list of tuples to a 2D matrix and so mess up the result subset = list(dict.fromkeys(subset)) @@ -3832,7 +3876,9 @@ def _groupby_shuffle( else: obj = self - agg_method = GroupByDefault.get_aggregation_method(how) + agg_method = ( + SeriesGroupByDefault if series_groupby else GroupByDefault + ).get_aggregation_method(how) original_agg_func = agg_func def agg_func(grp, *args, **kwargs): @@ -3848,7 +3894,13 @@ def agg_func(grp, *args, **kwargs): result = obj._modin_frame.groupby( axis=axis, - by=by, + internal_by=internal_by, + external_by=[ + obj._modin_frame if isinstance(obj, type(self)) else obj + for obj in external_by + ], + by_positions=by_positions, + series_groupby=series_groupby, operator=lambda grp: agg_func(grp, *agg_args, **agg_kwargs), # UDFs passed to '.apply()' are allowed to produce results with arbitrary shapes, # that's why we have to align the partition's shapes/labeling across different @@ -3992,6 +4044,7 @@ def groupby_agg( agg_kwargs=agg_kwargs, drop=drop, how=how, + series_groupby=series_groupby, ) except NotImplementedError as e: # if a user wants to use range-partitioning groupby explicitly, then we should print a visible @@ -4033,7 +4086,11 @@ def agg_func(grp, *args, **kwargs): groupby_kwargs = groupby_kwargs.copy() as_index = groupby_kwargs.get("as_index", True) - by, internal_by = self._groupby_internal_columns(by, drop) + external_by, internal_by, _ = self._groupby_separate_by(by, drop) + internal_qc = ( + [self.getitem_column_array(internal_by)] if len(internal_by) else [] + ) + by = internal_qc + external_by broadcastable_by = [o._modin_frame for o in by if isinstance(o, type(self))] not_broadcastable_by = [o for o in by if not isinstance(o, type(self))] diff --git a/modin/pandas/test/test_groupby.py b/modin/pandas/test/test_groupby.py index b9079b2bec5..2dd1687188a 100644 --- a/modin/pandas/test/test_groupby.py +++ b/modin/pandas/test/test_groupby.py @@ -111,6 +111,52 @@ ] +def get_external_groupers(df, columns, drop_from_original_df=False, add_plus_one=False): + """ + Construct ``by`` argument containing external groupers. + + Parameters + ---------- + df : pandas.DataFrame or modin.pandas.DataFrame + columns : list[tuple[bool, str]] + Columns to group on. If ``True`` do ``df[col]``, otherwise keep the column name. + ''' + >>> columns = [(True, "a"), (False, "b")] + >>> get_external_groupers(df, columns) + [ + pandas.Series(..., name="a"), + "b" + ] + ''' + drop_from_original_df : bool, default: False + Whether to drop selected external columns from `df`. + add_plus_one : bool, default: False + Whether to do ``df[name] + 1`` for external groupers (so they won't be considered as + sibling with `df`). + + Returns + ------- + new_df : pandas.DataFrame or modin.pandas.DataFrame + If `drop_from_original_df` was True, returns a new dataframe with + dropped external columns, otherwise returns `df`. + by : list + Groupers to pass to `df.groupby(by)`. + """ + new_df = df + by = [] + for lookup, name in columns: + if lookup: + ser = df[name].copy() + if add_plus_one: + ser = ser + 1 + by.append(ser) + if drop_from_original_df: + new_df = new_df.drop(columns=[name]) + else: + by.append(name) + return new_df, by + + def modin_groupby_equals_pandas(modin_groupby, pandas_groupby): eval_general( modin_groupby, pandas_groupby, lambda grp: grp.indices, comparator=dict_equals @@ -1926,26 +1972,68 @@ def test_to_pandas_convertion(kwargs): [ [(False, "a"), (False, "b"), (False, "c")], [(False, "a"), (False, "b")], - [(True, "a"), (True, "b"), (True, "c")], + [(True, "b"), (True, "a"), (True, "c")], [(True, "a"), (True, "b")], - [(False, "a"), (False, "b"), (True, "c")], + [(True, "c"), (False, "a"), (False, "b")], [(False, "a"), (True, "c")], ], ) -def test_mixed_columns(columns): - def get_columns(df): - return [df[name] if lookup else name for (lookup, name) in columns] +@pytest.mark.parametrize("drop_from_original_df", [True, False]) +@pytest.mark.parametrize("as_index", [True, False]) +def test_mixed_columns(columns, drop_from_original_df, as_index): + data = { + "a": [1, 1, 2, 2] * 64, + "b": [11, 11, 22, 22] * 64, + "c": [111, 111, 222, 222] * 64, + "data": [1, 2, 3, 4] * 64, + } - data = {"a": [1, 1, 2], "b": [11, 11, 22], "c": [111, 111, 222]} + md_df, pd_df = create_test_dfs(data) + md_df, md_by = get_external_groupers(md_df, columns, drop_from_original_df) + pd_df, pd_by = get_external_groupers(pd_df, columns, drop_from_original_df) + + md_grp = md_df.groupby(md_by, as_index=as_index) + pd_grp = pd_df.groupby(pd_by, as_index=as_index) + + df_equals(md_grp.size(), pd_grp.size()) + df_equals(md_grp.sum(), pd_grp.sum()) + df_equals(md_grp.apply(lambda df: df.sum()), pd_grp.apply(lambda df: df.sum())) + + +@pytest.mark.parametrize("as_index", [True, False]) +def test_groupby_external_grouper_duplicated_names(as_index): + data = { + "a": [1, 1, 2, 2] * 64, + "b": [11, 11, 22, 22] * 64, + "c": [111, 111, 222, 222] * 64, + "data": [1, 2, 3, 4] * 64, + } + + md_df, pd_df = create_test_dfs(data) - df1 = pandas.DataFrame(data) - df1 = pandas.concat([df1]) - ref = df1.groupby(get_columns(df1)).size() + md_unnamed_series1, pd_unnamed_series1 = create_test_series([1, 1, 2, 2] * 64) + md_unnamed_series2, pd_unnamed_series2 = create_test_series([10, 10, 20, 20] * 64) - df2 = pd.DataFrame(data) - df2 = pd.concat([df2]) - exp = df2.groupby(get_columns(df2)).size() - df_equals(ref, exp) + md_grp = md_df.groupby([md_unnamed_series1, md_unnamed_series2], as_index=as_index) + pd_grp = pd_df.groupby([pd_unnamed_series1, pd_unnamed_series2], as_index=as_index) + + df_equals(md_grp.sum(), pd_grp.sum()) + + md_same_named_series1, pd_same_named_series1 = create_test_series( + [1, 1, 2, 2] * 64, name="series_name" + ) + md_same_named_series2, pd_same_named_series2 = create_test_series( + [10, 10, 20, 20] * 64, name="series_name" + ) + + md_grp = md_df.groupby( + [md_same_named_series1, md_same_named_series2], as_index=as_index + ) + pd_grp = pd_df.groupby( + [pd_same_named_series1, pd_same_named_series2], as_index=as_index + ) + + df_equals(md_grp.sum(), pd_grp.sum()) @pytest.mark.parametrize( @@ -1962,13 +2050,10 @@ def get_columns(df): ], ) def test_internal_by_detection(columns): - def get_columns(df): - return [(df[name] + 1) if lookup else name for (lookup, name) in columns] - data = {"a": [1, 1, 2], "b": [11, 11, 22], "c": [111, 111, 222]} md_df = pd.DataFrame(data) - by = get_columns(md_df) + _, by = get_external_groupers(md_df, columns, add_plus_one=True) md_grp = md_df.groupby(by) ref = frozenset( @@ -1996,15 +2081,13 @@ def test_mixed_columns_not_from_df(columns, as_index): Unlike the previous test, in this case the Series is not just a column from the original DataFrame, so you can't use a fasttrack. """ - - def get_columns(df): - return [(df[name] + 1) if lookup else name for (lookup, name) in columns] - data = {"a": [1, 1, 2], "b": [11, 11, 22], "c": [111, 111, 222]} groupby_kw = {"as_index": as_index} md_df, pd_df = create_test_dfs(data) - by_md, by_pd = map(get_columns, [md_df, pd_df]) + (_, by_md), (_, by_pd) = map( + lambda df: get_external_groupers(df, columns, add_plus_one=True), [md_df, pd_df] + ) pd_grp = pd_df.groupby(by_pd, **groupby_kw) md_grp = md_df.groupby(by_md, **groupby_kw) @@ -2031,16 +2114,13 @@ def get_columns(df): ], ) def test_unknown_groupby(columns): - def get_columns(df): - return [df[name] if lookup else name for (lookup, name) in columns] - data = {"b": [11, 11, 22, 200], "c": [111, 111, 222, 7000]} modin_df, pandas_df = pd.DataFrame(data), pandas.DataFrame(data) with pytest.raises(KeyError): - pandas_df.groupby(by=get_columns(pandas_df)) + pandas_df.groupby(by=get_external_groupers(pandas_df, columns)[1]) with pytest.raises(KeyError): - modin_df.groupby(by=get_columns(modin_df)) + modin_df.groupby(by=get_external_groupers(modin_df, columns)[1]) @pytest.mark.parametrize( @@ -3250,3 +3330,25 @@ def test_range_groupby_categories( # break the order of rows in the result for multi-column groupbys. Placing the sorting-hack for now # https://github.com/modin-project/modin/issues/6875 df_equals(md_res.sort_index(axis=0), pd_res.sort_index(axis=0)) + + +@pytest.mark.parametrize("cat_cols", [["a"], ["b"], ["a", "b"]]) +@pytest.mark.parametrize( + "columns", [[(False, "a"), (True, "b")], [(True, "a")], [(True, "a"), (True, "b")]] +) +def test_range_groupby_categories_external_grouper(columns, cat_cols): + data = { + "a": [1, 1, 2, 2] * 64, + "b": [11, 11, 22, 22] * 64, + "c": [111, 111, 222, 222] * 64, + "data": [1, 2, 3, 4] * 64, + } + + md_df, pd_df = create_test_dfs(data) + md_df = md_df.astype({col: "category" for col in cat_cols}) + pd_df = pd_df.astype({col: "category" for col in cat_cols}) + + md_df, md_by = get_external_groupers(md_df, columns, drop_from_original_df=True) + pd_df, pd_by = get_external_groupers(pd_df, columns, drop_from_original_df=True) + + eval_general(md_df.groupby(md_by), pd_df.groupby(pd_by), lambda grp: grp.count()) diff --git a/modin/pandas/test/test_series.py b/modin/pandas/test/test_series.py index 019b0896169..30ad96becfd 100644 --- a/modin/pandas/test/test_series.py +++ b/modin/pandas/test/test_series.py @@ -50,6 +50,7 @@ bool_arg_values, categories_equals, create_test_dfs, + create_test_series, default_to_pandas_ignore_string, df_equals, df_equals_with_non_stable_indices, @@ -227,19 +228,6 @@ def inter_df_math_helper_one_side( getattr(modin_df_multi_level, op)(modin_df_multi_level, level=1) -def create_test_series(vals, sort=False, **kwargs): - if isinstance(vals, dict): - modin_series = pd.Series(vals[next(iter(vals.keys()))], **kwargs) - pandas_series = pandas.Series(vals[next(iter(vals.keys()))], **kwargs) - else: - modin_series = pd.Series(vals, **kwargs) - pandas_series = pandas.Series(vals, **kwargs) - if sort: - modin_series = modin_series.sort_values().reset_index(drop=True) - pandas_series = pandas_series.sort_values().reset_index(drop=True) - return modin_series, pandas_series - - @pytest.mark.parametrize("data", test_data_values, ids=test_data_keys) def test_to_frame(data): modin_series, pandas_series = create_test_series(data) diff --git a/modin/pandas/test/utils.py b/modin/pandas/test/utils.py index 1303448f9d9..c6ca5868f56 100644 --- a/modin/pandas/test/utils.py +++ b/modin/pandas/test/utils.py @@ -1033,9 +1033,17 @@ def create_test_dfs(*args, **kwargs): ) -def create_test_series(*args, **kwargs): - post_fn = kwargs.pop("post_fn", lambda df: df) - return map(post_fn, [pd.Series(*args, **kwargs), pandas.Series(*args, **kwargs)]) +def create_test_series(vals, sort=False, **kwargs): + if isinstance(vals, dict): + modin_series = pd.Series(vals[next(iter(vals.keys()))], **kwargs) + pandas_series = pandas.Series(vals[next(iter(vals.keys()))], **kwargs) + else: + modin_series = pd.Series(vals, **kwargs) + pandas_series = pandas.Series(vals, **kwargs) + if sort: + modin_series = modin_series.sort_values().reset_index(drop=True) + pandas_series = pandas_series.sort_values().reset_index(drop=True) + return modin_series, pandas_series def generate_dfs(): From 382bf9d17478a3144d0cad82a97330006a8cc32f Mon Sep 17 00:00:00 2001 From: Andrey Pavlenko Date: Mon, 5 Feb 2024 14:03:45 +0100 Subject: [PATCH 161/201] FEAT-#5809: New implementation of the Ray lazy execution queue (#6731) Co-authored-by: Iaroslav Igoshev Signed-off-by: Andrey Pavlenko --- modin/config/__init__.py | 2 + modin/config/envvars.py | 7 + .../ray/common/deferred_execution.py | 819 ++++++++++++++++++ modin/core/execution/ray/common/utils.py | 108 --- .../pandas_on_ray/partitioning/partition.py | 347 +++----- .../storage_formats/pandas/test_internals.py | 98 --- 6 files changed, 961 insertions(+), 420 deletions(-) create mode 100644 modin/core/execution/ray/common/deferred_execution.py diff --git a/modin/config/__init__.py b/modin/config/__init__.py index fef851c0116..26d759324fd 100644 --- a/modin/config/__init__.py +++ b/modin/config/__init__.py @@ -33,6 +33,7 @@ IsDebug, IsExperimental, IsRayCluster, + LazyExecution, LogFileSize, LogMemoryInterval, LogMode, @@ -71,6 +72,7 @@ "RayRedisAddress", "RayRedisPassword", "TestRayClient", + "LazyExecution", # Partitioning "NPartitions", "MinPartitionSize", diff --git a/modin/config/envvars.py b/modin/config/envvars.py index 18413724358..c865e07a358 100644 --- a/modin/config/envvars.py +++ b/modin/config/envvars.py @@ -823,6 +823,13 @@ class ReadSqlEngine(EnvironmentVariable, type=str): choices = ("Pandas", "Connectorx") +class LazyExecution(EnvironmentVariable, type=bool): + """Prefer the lazy execution, when it's possible.""" + + varname = "MODIN_LAZY_EXECUTION" + default = False + + def _check_vars() -> None: """ Check validity of environment variables. diff --git a/modin/core/execution/ray/common/deferred_execution.py b/modin/core/execution/ray/common/deferred_execution.py new file mode 100644 index 00000000000..5198d83502e --- /dev/null +++ b/modin/core/execution/ray/common/deferred_execution.py @@ -0,0 +1,819 @@ +# Licensed to Modin Development Team under one or more contributor license agreements. +# See the NOTICE file distributed with this work for additional information regarding +# copyright ownership. The Modin Development Team licenses this file to you under the +# Apache License, Version 2.0 (the "License"); you may not use this file except in +# compliance with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software distributed under +# the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF +# ANY KIND, either express or implied. See the License for the specific language +# governing permissions and limitations under the License. + +"""Module with classes and utilities for deferred remote execution in Ray workers.""" + +from enum import Enum +from itertools import islice +from typing import ( + Any, + Callable, + Dict, + Generator, + Iterable, + List, + Optional, + Tuple, + Union, +) + +import pandas +import ray +from ray._private.services import get_node_ip_address +from ray.util.client.common import ClientObjectRef + +from modin.core.execution.ray.common import RayWrapper +from modin.logging import get_logger + +ObjectRefType = Union[ray.ObjectRef, ClientObjectRef, None] +ObjectRefOrListType = Union[ObjectRefType, List[ObjectRefType]] +ListOrTuple = (list, tuple) + + +class DeferredExecution: + """ + Deferred execution task. + + This class represents a single node in the execution tree. The input is either + an object reference or another node on which this node depends. + The output is calculated by the specified Callable. + + If the input is a DeferredExecution node, it is executed first and the execution + output is used as the input for this one. All the executions are performed in a + single batch (i.e. using a single remote call) and the results are saved in all + the nodes that have multiple subscribers. + + Parameters + ---------- + data : ObjectRefType or DeferredExecution + The execution input. + func : callable or ObjectRefType + A function to be executed. + args : list or tuple + Additional positional arguments to be passed in `func`. + kwargs : dict + Additional keyword arguments to be passed in `func`. + num_returns : int, optional + The number of the return values. + + Attributes + ---------- + data : ObjectRefType or DeferredExecution + The execution input. + func : callable or ObjectRefType + A function to be executed. + args : list or tuple + Additional positional arguments to be passed in `func`. + kwargs : dict + Additional keyword arguments to be passed in `func`. + num_returns : int + The number of the return values. + flat_args : bool + True means that there are no lists or DeferredExecution objects in `args`. + In this case, no arguments processing is performed and `args` is passed + to the remote method as is. + flat_kwargs : bool + The same as `flat_args` but for the `kwargs` values. + """ + + def __init__( + self, + data: Union[ + ObjectRefType, + "DeferredExecution", + List[Union[ObjectRefType, "DeferredExecution"]], + ], + func: Union[Callable, ObjectRefType], + args: Union[List[Any], Tuple[Any]], + kwargs: Dict[str, Any], + num_returns=1, + ): + if isinstance(data, DeferredExecution): + data.subscribe() + self.data = data + self.func = func + self.args = args + self.kwargs = kwargs + self.num_returns = num_returns + self.flat_args = self._flat_args(args) + self.flat_kwargs = self._flat_args(kwargs.values()) + self.subscribers = 0 + + @classmethod + def _flat_args(cls, args: Iterable): + """ + Check if the arguments list is flat and subscribe to all `DeferredExecution` objects. + + Parameters + ---------- + args : Iterable + + Returns + ------- + bool + """ + flat = True + for arg in args: + if isinstance(arg, DeferredExecution): + flat = False + arg.subscribe() + elif isinstance(arg, ListOrTuple): + flat = False + cls._flat_args(arg) + return flat + + def exec( + self, + ) -> Tuple[ObjectRefOrListType, Union["MetaList", List], Union[int, List[int]]]: + """ + Execute this task, if required. + + Returns + ------- + tuple + The execution result, MetaList, containing the length, width and + the worker's ip address (the last value in the list) and the values + offset in the list. I.e. length = meta_list[offset], + width = meta_list[offset + 1], ip = meta_list[-1]. + """ + if self.has_result: + return self.data, self.meta, self.meta_offset + + if ( + not isinstance(self.data, DeferredExecution) + and self.flat_args + and self.flat_kwargs + and self.num_returns == 1 + ): + result, length, width, ip = remote_exec_func.remote( + self.func, self.data, *self.args, **self.kwargs + ) + meta = MetaList([length, width, ip]) + self._set_result(result, meta, 0) + return result, meta, 0 + + # If there are no subscribers, we still need the result here. We don't need to decrement + # it back. After the execution, the result is saved and the counter has no effect. + self.subscribers += 2 + consumers, output = self._deconstruct() + # The last result is the MetaList, so adding +1 here. + num_returns = sum(c.num_returns for c in consumers) + 1 + results = self._remote_exec_chain(num_returns, *output) + meta = MetaList(results.pop()) + meta_offset = 0 + results = iter(results) + for de in consumers: + if de.num_returns == 1: + de._set_result(next(results), meta, meta_offset) + meta_offset += 2 + else: + res = list(islice(results, num_returns)) + offsets = list(range(0, 2 * num_returns, 2)) + de._set_result(res, meta, offsets) + meta_offset += 2 * num_returns + return self.data, self.meta, self.meta_offset + + @property + def has_result(self): + """ + Return true if this task has already been executed and the result is set. + + Returns + ------- + bool + """ + return not hasattr(self, "func") + + def subscribe(self): + """ + Increment the `subscribers` counter. + + Subscriber is any instance that could trigger the execution of this task. + In case of a multiple subscribers, the execution could be triggerred multiple + times. To prevent the multiple executions, the execution result is returned + from the worker and saved in this instance. Subsequent calls to `execute()` + return the previously saved result. + """ + self.subscribers += 1 + + def unsubscribe(self): + """Decrement the `subscribers` counter.""" + self.subscribers -= 1 + assert self.subscribers >= 0 + + def _deconstruct(self) -> Tuple[List["DeferredExecution"], List[Any]]: + """ + Convert the specified execution tree to a flat list. + + This is required for the automatic Ray object references + materialization before passing the list to a Ray worker. + + The format of the list is the following: + sequence< >... + If before is >= 0, then the next n objects are the function arguments. + If it is -1, it means that the method arguments contain list and/or + DeferredExecution (chain) objects. In this case the next values are read + one by one until `_Tag.END` is encountered. If the value is `_Tag.LIST`, + then the next sequence of values up to `_Tag.END` is converted to list. + If the value is `_Tag.CHAIN`, then the next sequence of values up to + `_Tag.END` has exactly the same format, as described here. + If the value is `_Tag.REF`, then the next value is a reference id, i.e. + the actual value should be retrieved by this id from the previously + saved objects. The could also be `_Tag.REF` or `_Tag.LIST`. + + If before is >=0, then the next 2*n values are the argument + names and values in the following format - [name1, value1, name2, value2...]. + If it's -1, then the next values are converted to list in the same way as + and the argument names are the next len() values. + + is an integer reference id. If it's not 0, then there is another + chain referring to the execution result of this method and, thus, it must + be saved so that other chains could retrieve the object by the id. + + field contains either the `num_returns` value or 0. If it's 0, the + execution result is not returned, but is just passed to the next task in the + chain. If it's 1, the result is returned as is. Otherwise, it's expected that + the result is iterable and the specified number of values is returned from + the iterator. The values lengths and widths are added to the meta list. + + Returns + ------- + tuple of list + * The first list is the result consumers. + If a DeferredExecution has multiple subscribers, the execution result + should be returned and saved in order to avoid duplicate executions. + These DeferredExecution tasks are added to this list and, after the + execution, the results are passed to the ``_set_result()`` method of + each task. + * The second is a flat list of arguments that could be passed to the remote executor. + """ + stack = [] + result_consumers = [] + output = [] + # Using stack and generators to avoid the ``RecursionError``s. + stack.append(self._deconstruct_chain(self, output, stack, result_consumers)) + while stack: + try: + gen = stack.pop() + next_gen = next(gen) + stack.append(gen) + stack.append(next_gen) + except StopIteration: + pass + return result_consumers, output + + @classmethod + def _deconstruct_chain( + cls, + de: "DeferredExecution", + output: List, + stack: List, + result_consumers: List["DeferredExecution"], + ): + """ + Deconstruct the specified DeferredExecution chain. + + Parameters + ---------- + de : DeferredExecution + The chain to be deconstructed. + output : list + Put the arguments to this list. + stack : list + Used to eliminate recursive calls, that may lead to the RecursionError. + result_consumers : list of DeferredExecution + The result consumers. + + Yields + ------ + Generator + The ``_deconstruct_list()`` generator. + """ + out_append = output.append + out_extend = output.extend + while True: + de.unsubscribe() + if (out_pos := getattr(de, "out_pos", None)) and not de.has_result: + out_append(_Tag.REF) + out_append(out_pos) + output[out_pos] = out_pos + if de.subscribers == 0: + # We may have subscribed to the same node multiple times. + # It could happen, for example, if it's passed to the args + # multiple times, or it's one of the parent nodes and also + # passed to the args. In this case, there are no multiple + # subscribers, and we don't need to return the result. + output[out_pos + 1] = 0 + result_consumers.remove(de) + break + elif not isinstance(data := de.data, DeferredExecution): + if isinstance(data, ListOrTuple): + yield cls._deconstruct_list( + data, output, stack, result_consumers, out_append + ) + else: + out_append(data) + if not de.has_result: + stack.append(de) + break + else: + stack.append(de) + de = data + + while stack and isinstance(stack[-1], DeferredExecution): + de: DeferredExecution = stack.pop() + args = de.args + kwargs = de.kwargs + out_append(de.func) + if de.flat_args: + out_append(len(args)) + out_extend(args) + else: + out_append(-1) + yield cls._deconstruct_list( + args, output, stack, result_consumers, out_append + ) + if de.flat_kwargs: + out_append(len(kwargs)) + for item in kwargs.items(): + out_extend(item) + else: + out_append(-1) + yield cls._deconstruct_list( + kwargs.values(), output, stack, result_consumers, out_append + ) + out_extend(kwargs) + + out_append(0) # Placeholder for ref id + if de.subscribers > 0: + # Ref id. This is the index in the output list. + de.out_pos = len(output) - 1 + result_consumers.append(de) + out_append(de.num_returns) # Return result for this node + else: + out_append(0) # Do not return result for this node + + @classmethod + def _deconstruct_list( + cls, + lst: Iterable, + output: List, + stack: List, + result_consumers: List["DeferredExecution"], + out_append: Callable, + ): + """ + Deconstruct the specified list. + + Parameters + ---------- + lst : list + output : list + stack : list + result_consumers : list + out_append : Callable + The reference to the ``list.append()`` method. + + Yields + ------ + Generator + Either ``_deconstruct_list()`` or ``_deconstruct_chain()`` generator. + """ + for obj in lst: + if isinstance(obj, DeferredExecution): + if out_pos := getattr(obj, "out_pos", None): + obj.unsubscribe() + if obj.has_result: + out_append(obj.data) + else: + out_append(_Tag.REF) + out_append(out_pos) + output[out_pos] = out_pos + if obj.subscribers == 0: + output[out_pos + 1] = 0 + result_consumers.remove(obj) + else: + out_append(_Tag.CHAIN) + yield cls._deconstruct_chain(obj, output, stack, result_consumers) + out_append(_Tag.END) + elif isinstance(obj, ListOrTuple): + out_append(_Tag.LIST) + yield cls._deconstruct_list( + obj, output, stack, result_consumers, out_append + ) + else: + out_append(obj) + out_append(_Tag.END) + + @staticmethod + def _remote_exec_chain(num_returns: int, *args: Tuple) -> List[Any]: + """ + Execute the deconstructed chain in a worker process. + + Parameters + ---------- + num_returns : int + The number of return values. + *args : tuple + A deconstructed chain to be executed. + + Returns + ------- + list + The execution results. The last element of this list is the ``MetaList``. + """ + # Prefer _remote_exec_single_chain(). It has fewer arguments and + # does not require the num_returns to be specified in options. + if num_returns == 2: + return _remote_exec_single_chain.remote(*args) + else: + return _remote_exec_multi_chain.options(num_returns=num_returns).remote( + num_returns, *args + ) + + def _set_result( + self, + result: ObjectRefOrListType, + meta: "MetaList", + meta_offset: Union[int, List[int]], + ): + """ + Set the execution result. + + Parameters + ---------- + result : ObjectRefOrListType + meta : MetaList + meta_offset : int or list of int + """ + del self.func, self.args, self.kwargs, self.flat_args, self.flat_kwargs + self.data = result + self.meta = meta + self.meta_offset = meta_offset + + def __reduce__(self): + """Not serializable.""" + raise NotImplementedError("DeferredExecution is not serializable!") + + +class MetaList: + """ + Meta information, containing the result lengths and the worker address. + + Parameters + ---------- + obj : ray.ObjectID or list + """ + + def __init__(self, obj: Union[ray.ObjectID, ClientObjectRef, List]): + self._obj = obj + + def __getitem__(self, index): + """ + Get item at the specified index. + + Parameters + ---------- + index : int + + Returns + ------- + Any + """ + obj = self._obj + if not isinstance(obj, list): + self._obj = obj = RayWrapper.materialize(obj) + return obj[index] + + def __setitem__(self, index, value): + """ + Set item at the specified index. + + Parameters + ---------- + index : int + value : Any + """ + obj = self._obj + if not isinstance(obj, list): + self._obj = obj = RayWrapper.materialize(obj) + obj[index] = value + + +class _Tag(Enum): # noqa: PR01 + """ + A set of special values used for the method arguments de/construction. + + See ``DeferredExecution._deconstruct()`` for details. + """ + + # The next item is an execution chain + CHAIN = 0 + # The next item is a reference + REF = 1 + # The next item a list + LIST = 2 + # End of list or chain + END = 3 + + +class _RemoteExecutor: + """Remote functions for DeferredExecution.""" + + @staticmethod + def exec_func(fn: Callable, obj: Any, args: Tuple, kwargs: Dict) -> Any: + """ + Execute the specified function. + + Parameters + ---------- + fn : Callable + obj : Any + args : Tuple + kwargs : dict + + Returns + ------- + Any + """ + try: + try: + return fn(obj, *args, **kwargs) + # Sometimes Arrow forces us to make a copy of an object before we operate on it. We + # don't want the error to propagate to the user, and we want to avoid copying unless + # we absolutely have to. + except ValueError as err: + if isinstance(obj, (pandas.DataFrame, pandas.Series)): + return fn(obj.copy(), *args, **kwargs) + else: + raise err + except Exception as err: + get_logger().error( + f"{err}. fn={fn}, obj={obj}, args={args}, kwargs={kwargs}" + ) + raise err + + @classmethod + def construct(cls, num_returns: int, args: Tuple): # pragma: no cover + """ + Construct and execute the specified chain. + + This function is called in a worker process. The last value, returned by + this generator, is the meta list, containing the objects lengths and widths + and the worker ip address, as the last value in the list. + + Parameters + ---------- + num_returns : int + args : tuple + + Yields + ------ + Any + The execution results and the MetaList as the last value. + """ + chain = list(reversed(args)) + meta = [] + try: + stack = [cls.construct_chain(chain, {}, meta, None)] + while stack: + try: + gen = stack.pop() + obj = next(gen) + stack.append(gen) + if isinstance(obj, Generator): + stack.append(obj) + else: + yield obj + except StopIteration: + pass + except Exception as err: + get_logger().error(f"{err}. args={args}, chain={list(reversed(chain))}") + raise err + meta.append(get_node_ip_address()) + yield meta + + @classmethod + def construct_chain( + cls, + chain: List, + refs: Dict[int, Any], + meta: List, + lst: Optional[List], + ): # pragma: no cover + """ + Construct the chain and execute it one by one. + + Parameters + ---------- + chain : list + A flat list containing the execution tree, deconstructed by + ``DeferredExecution._deconstruct()``. + refs : dict + If an execution result is required for multiple chains, the + reference to this result is saved in this dict. + meta : list + The lengths of the returned objects are added to this list. + lst : list + If specified, the execution result is added to this list. + This is used when a chain is passed as an argument to a + DeferredExecution task. + + Yields + ------ + Any + Either the ``construct_list()`` generator or the execution results. + """ + pop = chain.pop + tg_e = _Tag.END + + obj = pop() + if obj is _Tag.REF: + obj = refs[pop()] + elif obj is _Tag.LIST: + obj = [] + yield cls.construct_list(obj, chain, refs, meta) + + while chain: + fn = pop() + if fn == tg_e: + lst.append(obj) + break + + if (args_len := pop()) >= 0: + if args_len == 0: + args = [] + else: + args = chain[-args_len:] + del chain[-args_len:] + args.reverse() + else: + args = [] + yield cls.construct_list(args, chain, refs, meta) + if (args_len := pop()) >= 0: + kwargs = {pop(): pop() for _ in range(args_len)} + else: + values = [] + yield cls.construct_list(values, chain, refs, meta) + kwargs = {pop(): v for v in values} + + obj = cls.exec_func(fn, obj, args, kwargs) + + if ref := pop(): # is not 0 - adding the result to refs + refs[ref] = obj + if (num_returns := pop()) == 0: + continue + + itr = iter([obj] if num_returns == 1 else obj) + for _ in range(num_returns): + obj = next(itr) + meta.append(len(obj) if hasattr(obj, "__len__") else 0) + meta.append(len(obj.columns) if hasattr(obj, "columns") else 0) + yield obj + + @classmethod + def construct_list( + cls, + lst: List, + chain: List, + refs: Dict[int, Any], + meta: List, + ): # pragma: no cover + """ + Construct the list. + + Parameters + ---------- + lst : list + chain : list + refs : dict + meta : list + + Yields + ------ + Any + Either ``construct_chain()`` or ``construct_list()`` generator. + """ + pop = chain.pop + lst_append = lst.append + while True: + obj = pop() + if isinstance(obj, _Tag): + if obj == _Tag.END: + break + elif obj == _Tag.CHAIN: + yield cls.construct_chain(chain, refs, meta, lst) + elif obj == _Tag.LIST: + lst_append([]) + yield cls.construct_list(lst[-1], chain, refs, meta) + elif obj is _Tag.REF: + lst_append(refs[pop()]) + else: + raise ValueError(f"Unexpected tag {obj}") + else: + lst_append(obj) + + def __reduce__(self): + """ + Use a single instance on deserialization. + + Returns + ------- + str + Returns the ``_REMOTE_EXEC`` attribute name. + """ + return "_REMOTE_EXEC" + + +_REMOTE_EXEC = _RemoteExecutor() + + +@ray.remote(num_returns=4) +def remote_exec_func( + fn: Callable, + obj: Any, + *flat_args: Tuple, + remote_executor=_REMOTE_EXEC, + **flat_kwargs: Dict, +): # pragma: no cover + """ + Execute the specified function with the arguments in a worker process. + + The object `obj` is passed to the function as the first argument. + Note: all the arguments must be flat, i.e. no lists, no chains. + + Parameters + ---------- + fn : Callable + obj : Any + *flat_args : list + remote_executor : _RemoteExecutor, default: _REMOTE_EXEC + Do not change, it's used to avoid excessive serializations. + **flat_kwargs : dict + + Returns + ------- + tuple[Any, int, int, str] + The execution result, the result length and width, the worked address. + """ + obj = remote_executor.exec_func(fn, obj, flat_args, flat_kwargs) + return ( + obj, + len(obj) if hasattr(obj, "__len__") else 0, + len(obj.columns) if hasattr(obj, "columns") else 0, + get_node_ip_address(), + ) + + +@ray.remote(num_returns=2) +def _remote_exec_single_chain( + *args: Tuple, remote_executor=_REMOTE_EXEC +) -> Generator: # pragma: no cover + """ + Execute the deconstructed chain with a single return value in a worker process. + + Parameters + ---------- + *args : tuple + A deconstructed chain to be executed. + remote_executor : _RemoteExecutor, default: _REMOTE_EXEC + Do not change, it's used to avoid excessive serializations. + + Returns + ------- + Generator + """ + return remote_executor.construct(num_returns=2, args=args) + + +@ray.remote +def _remote_exec_multi_chain( + num_returns: int, *args: Tuple, remote_executor=_REMOTE_EXEC +) -> Generator: # pragma: no cover + """ + Execute the deconstructed chain with a multiple return values in a worker process. + + Parameters + ---------- + num_returns : int + The number of return values. + *args : tuple + A deconstructed chain to be executed. + remote_executor : _RemoteExecutor, default: _REMOTE_EXEC + Do not change, it's used to avoid excessive serializations. + + Returns + ------- + Generator + """ + return remote_executor.construct(num_returns, args) diff --git a/modin/core/execution/ray/common/utils.py b/modin/core/execution/ray/common/utils.py index 0e79d3da0a8..f24be8fe2cf 100644 --- a/modin/core/execution/ray/common/utils.py +++ b/modin/core/execution/ray/common/utils.py @@ -283,111 +283,3 @@ def deserialize(obj): # pragma: no cover return dict(zip(obj.keys(), RayWrapper.materialize(list(obj.values())))) else: return obj - - -def deconstruct_call_queue(call_queue): - """ - Deconstruct the passed call queue into a 1D list. - - This is required, so the call queue can be then passed to a Ray's kernel - as a variable-length argument ``kernel(*queue)`` so the Ray engine - automatically materialize all the futures that the queue might have contained. - - Parameters - ---------- - call_queue : list[list[func, args, kwargs], ...] - - Returns - ------- - num_funcs : int - The number of functions in the call queue. - arg_lengths : list of ints - The number of positional arguments for each function in the call queue. - kw_key_lengths : list of ints - The number of key-word arguments for each function in the call queue. - kw_value_lengths : 2D list of dict{"len": int, "was_iterable": bool} - Description of keyword arguments for each function. For example, `kw_value_lengths[i][j]` - describes the j-th keyword argument of the i-th function in the call queue. - The describtion contains of the lengths of the argument and whether it's a list at all - (for example, {"len": 1, "was_iterable": False} describes a non-list argument). - unfolded_queue : list - A 1D call queue that can be reconstructed using ``reconstruct_call_queue`` function. - """ - num_funcs = len(call_queue) - arg_lengths = [] - kw_key_lengths = [] - kw_value_lengths = [] - unfolded_queue = [] - for call in call_queue: - unfolded_queue.append(call[0]) - unfolded_queue.extend(call[1]) - arg_lengths.append(len(call[1])) - # unfold keyword dict - ## unfold keys - unfolded_queue.extend(call[2].keys()) - kw_key_lengths.append(len(call[2])) - ## unfold values - value_lengths = [] - for value in call[2].values(): - was_iterable = True - if not isinstance(value, (list, tuple)): - was_iterable = False - value = (value,) - unfolded_queue.extend(value) - value_lengths.append({"len": len(value), "was_iterable": was_iterable}) - kw_value_lengths.append(value_lengths) - - return num_funcs, arg_lengths, kw_key_lengths, kw_value_lengths, *unfolded_queue - - -def reconstruct_call_queue( - num_funcs, arg_lengths, kw_key_lengths, kw_value_lengths, unfolded_queue -): - """ - Reconstruct original call queue from the result of the ``deconstruct_call_queue()``. - - Parameters - ---------- - num_funcs : int - The number of functions in the call queue. - arg_lengths : list of ints - The number of positional arguments for each function in the call queue. - kw_key_lengths : list of ints - The number of key-word arguments for each function in the call queue. - kw_value_lengths : 2D list of dict{"len": int, "was_iterable": bool} - Description of keyword arguments for each function. For example, `kw_value_lengths[i][j]` - describes the j-th keyword argument of the i-th function in the call queue. - The describtion contains of the lengths of the argument and whether it's a list at all - (for example, {"len": 1, "was_iterable": False} describes a non-list argument). - unfolded_queue : list - A 1D call queue that is result of the ``deconstruct_call_queue()`` function. - - Returns - ------- - list[list[func, args, kwargs], ...] - Original call queue. - """ - items_took = 0 - - def take_n_items(n): - nonlocal items_took - res = unfolded_queue[items_took : items_took + n] - items_took += n - return res - - call_queue = [] - for i in range(num_funcs): - func = take_n_items(1)[0] - args = take_n_items(arg_lengths[i]) - kw_keys = take_n_items(kw_key_lengths[i]) - kwargs = {} - value_lengths = kw_value_lengths[i] - for key, value_length in zip(kw_keys, value_lengths): - vals = take_n_items(value_length["len"]) - if value_length["len"] == 1 and not value_length["was_iterable"]: - vals = vals[0] - kwargs[key] = vals - - call_queue.append((func, args, kwargs)) - - return call_queue diff --git a/modin/core/execution/ray/implementations/pandas_on_ray/partitioning/partition.py b/modin/core/execution/ray/implementations/pandas_on_ray/partitioning/partition.py index 36469b0ccd3..7c6bb38b2a0 100644 --- a/modin/core/execution/ray/implementations/pandas_on_ray/partitioning/partition.py +++ b/modin/core/execution/ray/implementations/pandas_on_ray/partitioning/partition.py @@ -13,19 +13,25 @@ """Module houses class that wraps data (block partition) and its metadata.""" +from typing import TYPE_CHECKING, Callable, Union + import pandas import ray -from ray.util import get_node_ip_address +if TYPE_CHECKING: + from ray.util.client.common import ClientObjectRef + +from modin.config import LazyExecution from modin.core.dataframe.pandas.partitioning.partition import PandasDataframePartition from modin.core.execution.ray.common import RayWrapper -from modin.core.execution.ray.common.utils import ( - ObjectIDType, - deconstruct_call_queue, - reconstruct_call_queue, +from modin.core.execution.ray.common.deferred_execution import ( + DeferredExecution, + MetaList, ) +from modin.core.execution.ray.common.utils import ObjectIDType from modin.logging import get_logger from modin.pandas.indexing import compute_sliced_len +from modin.utils import _inherit_docstrings compute_sliced_len = ray.remote(compute_sliced_len) @@ -36,30 +42,47 @@ class PandasOnRayDataframePartition(PandasDataframePartition): Parameters ---------- - data : ray.ObjectRef - A reference to ``pandas.DataFrame`` that need to be wrapped with this class. - length : ray.ObjectRef or int, optional + data : ObjectIDType or DeferredExecution + A reference to ``pandas.DataFrame`` that needs to be wrapped with this class + or a reference to DeferredExecution that needs to be executed on demand. + length : ObjectIDType or int, optional Length or reference to it of wrapped ``pandas.DataFrame``. - width : ray.ObjectRef or int, optional + width : ObjectIDType or int, optional Width or reference to it of wrapped ``pandas.DataFrame``. - ip : ray.ObjectRef or str, optional + ip : ObjectIDType or str, optional Node IP address or reference to it that holds wrapped ``pandas.DataFrame``. - call_queue : list - Call queue that needs to be executed on wrapped ``pandas.DataFrame``. + meta : MetaList + Meta information, containing the lengths and the worker address (the last value). + meta_offset : int + The lengths offset in the meta list. """ execution_wrapper = RayWrapper - def __init__(self, data, length=None, width=None, ip=None, call_queue=None): + def __init__( + self, + data: Union[ray.ObjectRef, "ClientObjectRef", DeferredExecution], + length: int = None, + width: int = None, + ip: str = None, + meta: MetaList = None, + meta_offset: int = 0, + ): super().__init__() - assert isinstance(data, ObjectIDType) - self._data = data - if call_queue is None: - call_queue = [] - self.call_queue = call_queue - self._length_cache = length - self._width_cache = width - self._ip_cache = ip + if isinstance(data, DeferredExecution): + data.subscribe() + self._data_ref = data + # The metadata is stored in the MetaList at 0 offset. If the data is + # a DeferredExecution, the _meta will be replaced with the list, returned + # by the remote function. The returned list may contain data for multiple + # results and, in this case, _meta_offset corresponds to the meta related to + # this partition. + if meta is None: + self._meta = MetaList([length, width, ip]) + self._meta_offset = 0 + else: + self._meta = meta + self._meta_offset = meta_offset log = get_logger() self._is_debug(log) and log.debug( @@ -71,7 +94,12 @@ def __init__(self, data, length=None, width=None, ip=None, call_queue=None): ) ) - def apply(self, func, *args, **kwargs): + def __del__(self): + """Unsubscribe from DeferredExecution.""" + if isinstance(self._data_ref, DeferredExecution): + self._data_ref.unsubscribe() + + def apply(self, func: Callable, *args, **kwargs): """ Apply a function to the object wrapped by this partition. @@ -93,76 +121,46 @@ def apply(self, func, *args, **kwargs): ----- It does not matter if `func` is callable or an ``ray.ObjectRef``. Ray will handle it correctly either way. The keyword arguments are sent as a dictionary. + + If ``LazyExecution`` is enabled, the function is not applied immediately, + but is added to the execution tree. """ + de = DeferredExecution(self._data_ref, func, args, kwargs) + if LazyExecution.get(): + return self.__constructor__(de) log = get_logger() self._is_debug(log) and log.debug(f"ENTER::Partition.apply::{self._identity}") - data = self._data - call_queue = self.call_queue + [[func, args, kwargs]] - if len(call_queue) > 1: - self._is_debug(log) and log.debug( - f"SUBMIT::_apply_list_of_funcs::{self._identity}" - ) - result, length, width, ip = _apply_list_of_funcs.remote( - data, *deconstruct_call_queue(call_queue) - ) - else: - # We handle `len(call_queue) == 1` in a different way because - # this dramatically improves performance. - func, f_args, f_kwargs = call_queue[0] - result, length, width, ip = _apply_func.remote( - data, func, *f_args, **f_kwargs - ) - self._is_debug(log) and log.debug(f"SUBMIT::_apply_func::{self._identity}") + data, meta, meta_offset = de.exec() self._is_debug(log) and log.debug(f"EXIT::Partition.apply::{self._identity}") - return self.__constructor__(result, length, width, ip) + return self.__constructor__(data, meta=meta, meta_offset=meta_offset) + + @_inherit_docstrings(PandasDataframePartition.add_to_apply_calls) + def add_to_apply_calls(self, func, *args, length=None, width=None, **kwargs): + return self.__constructor__( + data=DeferredExecution(self._data_ref, func, args, kwargs), + length=length, + width=width, + ) + @_inherit_docstrings(PandasDataframePartition.drain_call_queue) def drain_call_queue(self): - """Execute all operations stored in the call queue on the object wrapped by this partition.""" + data = self._data_ref + if not isinstance(data, DeferredExecution): + return data + log = get_logger() self._is_debug(log) and log.debug( f"ENTER::Partition.drain_call_queue::{self._identity}" ) - if len(self.call_queue) == 0: - return - data = self._data - call_queue = self.call_queue - if len(call_queue) > 1: - self._is_debug(log) and log.debug( - f"SUBMIT::_apply_list_of_funcs::{self._identity}" - ) - ( - self._data, - new_length, - new_width, - self._ip_cache, - ) = _apply_list_of_funcs.remote(data, *deconstruct_call_queue(call_queue)) - else: - # We handle `len(call_queue) == 1` in a different way because - # this dramatically improves performance. - func, f_args, f_kwargs = call_queue[0] - self._is_debug(log) and log.debug(f"SUBMIT::_apply_func::{self._identity}") - ( - self._data, - new_length, - new_width, - self._ip_cache, - ) = _apply_func.remote(data, func, *f_args, **f_kwargs) + self._data_ref, self._meta, self._meta_offset = data.exec() self._is_debug(log) and log.debug( f"EXIT::Partition.drain_call_queue::{self._identity}" ) - self.call_queue = [] - - # GH#4732 if we already have evaluated width/length cached as ints, - # don't overwrite that cache with non-evaluated values. - if not isinstance(self._length_cache, int): - self._length_cache = new_length - if not isinstance(self._width_cache, int): - self._width_cache = new_width + @_inherit_docstrings(PandasDataframePartition.wait) def wait(self): - """Wait completing computations on the object wrapped by the partition.""" self.drain_call_queue() - RayWrapper.wait(self._data) + RayWrapper.wait(self._data_ref) def __copy__(self): """ @@ -174,11 +172,9 @@ def __copy__(self): A copy of this partition. """ return self.__constructor__( - self._data, - length=self._length_cache, - width=self._width_cache, - ip=self._ip_cache, - call_queue=self.call_queue, + self._data_ref, + meta=self._meta, + meta_offset=self._meta_offset, ) def mask(self, row_labels, col_labels): @@ -224,14 +220,14 @@ def mask(self, row_labels, col_labels): return new_obj @classmethod - def put(cls, obj): + def put(cls, obj: pandas.DataFrame): """ - Put an object into Plasma store and wrap it with partition object. + Put the data frame into Plasma store and wrap it with partition object. Parameters ---------- - obj : any - An object to be put. + obj : pandas.DataFrame + A data frame to be put. Returns ------- @@ -273,16 +269,16 @@ def length(self, materialize=True): int or ray.ObjectRef The length of the object. """ - if self._length_cache is None: - if len(self.call_queue): - self.drain_call_queue() - else: - self._length_cache, self._width_cache = _get_index_and_columns.remote( - self._data + if (length := self._length_cache) is None: + self.drain_call_queue() + if (length := self._length_cache) is None: + length, self._width_cache = _get_index_and_columns.remote( + self._data_ref ) - if materialize and isinstance(self._length_cache, ObjectIDType): - self._length_cache = RayWrapper.materialize(self._length_cache) - return self._length_cache + self._length_cache = length + if materialize and isinstance(length, ObjectIDType): + self._length_cache = length = RayWrapper.materialize(length) + return length def width(self, materialize=True): """ @@ -300,16 +296,16 @@ def width(self, materialize=True): int or ray.ObjectRef The width of the object. """ - if self._width_cache is None: - if len(self.call_queue): - self.drain_call_queue() - else: - self._length_cache, self._width_cache = _get_index_and_columns.remote( - self._data + if (width := self._width_cache) is None: + self.drain_call_queue() + if (width := self._width_cache) is None: + self._length_cache, width = _get_index_and_columns.remote( + self._data_ref ) - if materialize and isinstance(self._width_cache, ObjectIDType): - self._width_cache = RayWrapper.materialize(self._width_cache) - return self._width_cache + self._width_cache = width + if materialize and isinstance(width, ObjectIDType): + self._width_cache = width = RayWrapper.materialize(width) + return width def ip(self, materialize=True): """ @@ -327,14 +323,40 @@ def ip(self, materialize=True): str IP address of the node that holds the data. """ - if self._ip_cache is None: - if len(self.call_queue): - self.drain_call_queue() - else: - self._ip_cache = self.apply(lambda df: pandas.DataFrame([]))._ip_cache - if materialize and isinstance(self._ip_cache, ObjectIDType): - self._ip_cache = RayWrapper.materialize(self._ip_cache) - return self._ip_cache + if (ip := self._ip_cache) is None: + self.drain_call_queue() + if materialize and isinstance(ip, ObjectIDType): + self._ip_cache = ip = RayWrapper.materialize(ip) + return ip + + @property + def _data(self) -> Union[ray.ObjectRef, "ClientObjectRef"]: # noqa: GL08 + self.drain_call_queue() + return self._data_ref + + @property + def _length_cache(self): # noqa: GL08 + return self._meta[self._meta_offset] + + @_length_cache.setter + def _length_cache(self, value): # noqa: GL08 + self._meta[self._meta_offset] = value + + @property + def _width_cache(self): # noqa: GL08 + return self._meta[self._meta_offset + 1] + + @_width_cache.setter + def _width_cache(self, value): # noqa: GL08 + self._meta[self._meta_offset + 1] = value + + @property + def _ip_cache(self): # noqa: GL08 + return self._meta[-1] + + @_ip_cache.setter + def _ip_cache(self, value): # noqa: GL08 + self._meta[-1] = value @ray.remote(num_returns=2) @@ -355,106 +377,3 @@ def _get_index_and_columns(df): # pragma: no cover The number of columns. """ return len(df.index), len(df.columns) - - -@ray.remote(num_returns=4) -def _apply_func(partition, func, *args, **kwargs): # pragma: no cover - """ - Execute a function on the partition in a worker process. - - Parameters - ---------- - partition : pandas.DataFrame - A pandas DataFrame the function needs to be executed on. - func : callable - The function to perform on the partition. - *args : list - Positional arguments to pass to ``func``. - **kwargs : dict - Keyword arguments to pass to ``func``. - - Returns - ------- - pandas.DataFrame - The resulting pandas DataFrame. - int - The number of rows of the resulting pandas DataFrame. - int - The number of columns of the resulting pandas DataFrame. - str - The node IP address of the worker process. - - Notes - ----- - Directly passing a call queue entry (i.e. a list of [func, args, kwargs]) instead of - destructuring it causes a performance penalty. - """ - try: - result = func(partition, *args, **kwargs) - # Sometimes Arrow forces us to make a copy of an object before we operate on it. We - # don't want the error to propagate to the user, and we want to avoid copying unless - # we absolutely have to. - except ValueError: - result = func(partition.copy(), *args, **kwargs) - return ( - result, - len(result) if hasattr(result, "__len__") else 0, - len(result.columns) if hasattr(result, "columns") else 0, - get_node_ip_address(), - ) - - -@ray.remote(num_returns=4) -def _apply_list_of_funcs( - partition, num_funcs, arg_lengths, kw_key_lengths, kw_value_lengths, *futures -): # pragma: no cover - """ - Execute all operations stored in the call queue on the partition in a worker process. - - Parameters - ---------- - partition : pandas.DataFrame - A pandas DataFrame the call queue needs to be executed on. - num_funcs : int - The number of functions in the call queue. - arg_lengths : list of ints - The number of positional arguments for each function in the call queue. - kw_key_lengths : list of ints - The number of key-word arguments for each function in the call queue. - kw_value_lengths : 2D list of dict{"len": int, "was_iterable": bool} - Description of keyword arguments for each function. For example, `kw_value_lengths[i][j]` - describes the j-th keyword argument of the i-th function in the call queue. - The describtion contains of the lengths of the argument and whether it's a list at all - (for example, {"len": 1, "was_iterable": False} describes a non-list argument). - *futures : list - A 1D call queue that is result of the ``deconstruct_call_queue()`` function. - - Returns - ------- - pandas.DataFrame - The resulting pandas DataFrame. - int - The number of rows of the resulting pandas DataFrame. - int - The number of columns of the resulting pandas DataFrame. - str - The node IP address of the worker process. - """ - call_queue = reconstruct_call_queue( - num_funcs, arg_lengths, kw_key_lengths, kw_value_lengths, futures - ) - for func, args, kwargs in call_queue: - try: - partition = func(partition, *args, **kwargs) - # Sometimes Arrow forces us to make a copy of an object before we operate on it. We - # don't want the error to propagate to the user, and we want to avoid copying unless - # we absolutely have to. - except ValueError: - partition = func(partition.copy(), *args, **kwargs) - - return ( - partition, - len(partition) if hasattr(partition, "__len__") else 0, - len(partition.columns) if hasattr(partition, "columns") else 0, - get_node_ip_address(), - ) diff --git a/modin/test/storage_formats/pandas/test_internals.py b/modin/test/storage_formats/pandas/test_internals.py index b448b718e98..f61c3e5c0a1 100644 --- a/modin/test/storage_formats/pandas/test_internals.py +++ b/modin/test/storage_formats/pandas/test_internals.py @@ -1523,104 +1523,6 @@ def __eq__(self, other): return False -@pytest.mark.parametrize( - "call_queue", - [ - # empty call queue - [], - # a single-function call queue (the function has no argument and it's materialized) - [(0, [], {})], - # a single-function call queue (the function has no argument and it's serialized) - [(DummyFuture(), [], {})], - # a multiple-functions call queue, none of the functions have arguments - [(DummyFuture(), [], {}), (DummyFuture(), [], {}), (0, [], {})], - # a single-function call queue (the function has both positional and keyword arguments) - [ - ( - DummyFuture(), - [DummyFuture()], - { - "a": DummyFuture(), - "b": [DummyFuture()], - "c": [DummyFuture, DummyFuture()], - }, - ) - ], - # a multiple-functions call queue with mixed types of functions/arguments - [ - ( - DummyFuture(), - [1, DummyFuture(), DummyFuture(), [4, 5]], - {"a": [DummyFuture(), 2], "b": DummyFuture(), "c": [1]}, - ), - (0, [], {}), - (0, [1], {}), - (0, [DummyFuture(), DummyFuture()], {}), - ], - ], -) -def test_call_queue_serialization(call_queue): - """ - Test that the process of passing a call queue to Ray's kernel works correctly. - - Before passing a call queue to the kernel that actually executes it, the call queue - is unwrapped into a 1D list using the ``deconstruct_call_queue`` function. After that, - the 1D list is passed as a variable length argument to the kernel ``kernel(*queue)``, - this is done so the Ray engine automatically materialize all the futures that the queue - might have contained. In the end, inside of the kernel, the ``reconstruct_call_queue`` function - is called to rebuild the call queue into its original structure. - - This test emulates the described flow and verifies that it works properly. - """ - from modin.core.execution.ray.implementations.pandas_on_ray.partitioning.partition import ( - deconstruct_call_queue, - reconstruct_call_queue, - ) - - def materialize_queue(*values): - """ - Walk over the `values` and materialize all the future types. - - This function emulates how Ray remote functions materialize their positional arguments. - """ - return [ - val.materialize() if isinstance(val, DummyFuture) else val for val in values - ] - - def assert_everything_materialized(queue): - """Walk over the call queue and verify that all entities there are materialized.""" - - def assert_materialized(obj): - assert ( - isinstance(obj, DummyFuture) and obj._was_materialized - ) or not isinstance(obj, DummyFuture) - - for func, args, kwargs in queue: - assert_materialized(func) - for arg in args: - assert_materialized(arg) - for value in kwargs.values(): - if not isinstance(value, (list, tuple)): - value = [value] - for val in value: - assert_materialized(val) - - ( - num_funcs, - arg_lengths, - kw_key_lengths, - kw_value_lengths, - *queue, - ) = deconstruct_call_queue(call_queue) - queue = materialize_queue(*queue) - reconstructed_queue = reconstruct_call_queue( - num_funcs, arg_lengths, kw_key_lengths, kw_value_lengths, queue - ) - - assert call_queue == reconstructed_queue - assert_everything_materialized(reconstructed_queue) - - class TestModinDtypes: """Test ``ModinDtypes`` and ``DtypesDescriptor`` classes.""" From 01c529cf06cfaf412b5725f41c81a5f914b44b95 Mon Sep 17 00:00:00 2001 From: Dmitry Chigarev Date: Mon, 5 Feb 2024 14:21:08 +0100 Subject: [PATCH 162/201] REFACTOR-#6903: Remove duplicated definitions of 'create_test_series' (#6910) Signed-off-by: Dmitry Chigarev --- modin/pandas/test/test_expanding.py | 11 +---------- modin/pandas/test/test_rolling.py | 11 +---------- 2 files changed, 2 insertions(+), 20 deletions(-) diff --git a/modin/pandas/test/test_expanding.py b/modin/pandas/test/test_expanding.py index 2555aa38772..811637f21f4 100644 --- a/modin/pandas/test/test_expanding.py +++ b/modin/pandas/test/test_expanding.py @@ -21,6 +21,7 @@ from .utils import ( create_test_dfs, + create_test_series, df_equals, eval_general, test_data, @@ -31,16 +32,6 @@ NPartitions.put(4) -def create_test_series(vals): - if isinstance(vals, dict): - modin_series = pd.Series(vals[next(iter(vals.keys()))]) - pandas_series = pandas.Series(vals[next(iter(vals.keys()))]) - else: - modin_series = pd.Series(vals) - pandas_series = pandas.Series(vals) - return modin_series, pandas_series - - @pytest.mark.parametrize("data", test_data_values, ids=test_data_keys) @pytest.mark.parametrize("min_periods", [None, 5]) @pytest.mark.parametrize("axis", [0, 1]) diff --git a/modin/pandas/test/test_rolling.py b/modin/pandas/test/test_rolling.py index 687bd59aa94..a46bbfd2c63 100644 --- a/modin/pandas/test/test_rolling.py +++ b/modin/pandas/test/test_rolling.py @@ -21,6 +21,7 @@ from .utils import ( create_test_dfs, + create_test_series, default_to_pandas_ignore_string, df_equals, eval_general, @@ -50,16 +51,6 @@ ] -def create_test_series(vals): - if isinstance(vals, dict): - modin_series = pd.Series(vals[next(iter(vals.keys()))]) - pandas_series = pandas.Series(vals[next(iter(vals.keys()))]) - else: - modin_series = pd.Series(vals) - pandas_series = pandas.Series(vals) - return modin_series, pandas_series - - @pytest.mark.parametrize("data", test_data_values, ids=test_data_keys) @pytest.mark.parametrize("window", [5, 100]) @pytest.mark.parametrize("min_periods", [None, 5]) From 807298d7e154b47ae922638660d4c4cb6490a76c Mon Sep 17 00:00:00 2001 From: Dmitry Chigarev Date: Mon, 5 Feb 2024 16:01:32 +0100 Subject: [PATCH 163/201] FIX-#6911: Remove unidist specific workaround in '.from_pandas()' (#6912) Signed-off-by: Dmitry Chigarev --- .../pandas/partitioning/partition_manager.py | 9 +-------- modin/pandas/test/test_general.py | 12 ++++++++++++ 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/modin/core/dataframe/pandas/partitioning/partition_manager.py b/modin/core/dataframe/pandas/partitioning/partition_manager.py index f7b3899550c..2ccbd1de47f 100644 --- a/modin/core/dataframe/pandas/partitioning/partition_manager.py +++ b/modin/core/dataframe/pandas/partitioning/partition_manager.py @@ -869,14 +869,7 @@ def split_pandas_df_into_partitions( put_func = cls._partition_class.put # even a full-axis slice can cost something (https://github.com/pandas-dev/pandas/issues/55202) # so we try not to do it if unnecessary. - # FIXME: it appears that this optimization doesn't work for Unidist correctly as it - # doesn't explicitly copy the data when putting it into storage (as the rest engines do) - # causing it to eventially share memory with a pandas object that was provided by user. - # Everything works fine if we do this column slicing as pandas then would set some flags - # to perform in COW mode apparently (and so it wouldn't crash our tests). - # @YarShev promised that this will be eventially fixed on Unidist's side, but for now there's - # this hacky condition - if col_chunksize >= len(df.columns) and Engine.get() != "Unidist": + if col_chunksize >= len(df.columns): col_parts = [df] else: col_parts = [ diff --git a/modin/pandas/test/test_general.py b/modin/pandas/test/test_general.py index 7a445657d2d..72f33d55d80 100644 --- a/modin/pandas/test/test_general.py +++ b/modin/pandas/test/test_general.py @@ -971,3 +971,15 @@ def make_frame(lib): def test_get(key): modin_df, pandas_df = create_test_dfs({"col0": [0, 1]}) eval_general(modin_df, pandas_df, lambda df: df.get(key)) + + +def test_df_immutability(): + """ + Verify that modifications of the source data doesn't propagate to Modin's DataFrame objects. + """ + src_data = pandas.DataFrame({"a": [1]}) + + md_df = pd.DataFrame(src_data) + src_data.iloc[0, 0] = 100 + + assert md_df._to_pandas().iloc[0, 0] == 1 From 33b38344d80a2f52b9e959f53facfa9dd16281dc Mon Sep 17 00:00:00 2001 From: Anatoly Myachev Date: Tue, 6 Feb 2024 16:27:53 +0100 Subject: [PATCH 164/201] FIX-#6916: unpin 'pydantic' dependency (#6917) Signed-off-by: Anatoly Myachev --- docs/requirements-doc.txt | 2 -- environment-dev.yml | 2 -- .../tutorial/jupyter/execution/pandas_on_ray/requirements.txt | 2 -- requirements-dev.txt | 2 -- setup.py | 3 +-- 5 files changed, 1 insertion(+), 10 deletions(-) diff --git a/docs/requirements-doc.txt b/docs/requirements-doc.txt index c9ad25b1c01..8e9160e3d41 100644 --- a/docs/requirements-doc.txt +++ b/docs/requirements-doc.txt @@ -14,8 +14,6 @@ sphinx<6.0.0 sphinx-click # ray==2.5.0 broken: https://github.com/conda-forge/ray-packages-feedstock/issues/100 ray[default]>=1.13.0,!=2.5.0 -# https://github.com/modin-project/modin/issues/6336 -pydantic<2 # Override to latest version of modin-spreadsheet git+https://github.com/modin-project/modin-spreadsheet.git@49ffd89f683f54c311867d602c55443fb11bf2a5 sphinxcontrib_plantuml diff --git a/environment-dev.yml b/environment-dev.yml index 20ef24aa1de..69be3f5d47e 100644 --- a/environment-dev.yml +++ b/environment-dev.yml @@ -14,8 +14,6 @@ dependencies: # optional dependencies # ray==2.5.0 broken: https://github.com/conda-forge/ray-packages-feedstock/issues/100 - ray-default>=1.13.0,!=2.5.0 - # https://github.com/modin-project/modin/issues/6336 - - pydantic<2 - pyarrow>=7.0.0 # workaround for https://github.com/conda/conda/issues/11744 - grpcio!=1.45.* diff --git a/examples/tutorial/jupyter/execution/pandas_on_ray/requirements.txt b/examples/tutorial/jupyter/execution/pandas_on_ray/requirements.txt index d0e7b5d2308..f6aa7dec4d3 100644 --- a/examples/tutorial/jupyter/execution/pandas_on_ray/requirements.txt +++ b/examples/tutorial/jupyter/execution/pandas_on_ray/requirements.txt @@ -3,6 +3,4 @@ jupyterlab ipywidgets tqdm>=4.60.0 modin[ray] -# https://github.com/modin-project/modin/issues/6336 -pydantic<2 modin[spreadsheet] diff --git a/requirements-dev.txt b/requirements-dev.txt index cdd68b5ab85..132ac6ff19f 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -8,8 +8,6 @@ psutil>=5.8.0 ## optional dependencies # ray==2.5.0 broken: https://github.com/conda-forge/ray-packages-feedstock/issues/100 ray[default]>=1.13.0,!=2.5.0 -# https://github.com/modin-project/modin/issues/6336 -pydantic<2 pyarrow>=7.0.0 dask[complete]>=2.22.0 distributed>=2.22.0 diff --git a/setup.py b/setup.py index 8e8036d872e..acc32c0f26a 100644 --- a/setup.py +++ b/setup.py @@ -7,8 +7,7 @@ dask_deps = ["dask>=2.22.0", "distributed>=2.22.0"] # ray==2.5.0 broken: https://github.com/conda-forge/ray-packages-feedstock/issues/100 -# pydantic<2: https://github.com/modin-project/modin/issues/6336 -ray_deps = ["ray[default]>=1.13.0,!=2.5.0", "pyarrow>=7.0.0", "pydantic<2"] +ray_deps = ["ray[default]>=1.13.0,!=2.5.0", "pyarrow>=7.0.0"] mpi_deps = ["unidist[mpi]>=0.2.1"] spreadsheet_deps = ["modin-spreadsheet>=0.1.0"] # Currently, Modin does not include `mpi` option in `all`. From 5f6f80942c53012a3e0e85c0479b3cc4f56253cf Mon Sep 17 00:00:00 2001 From: Dmitry Chigarev Date: Tue, 6 Feb 2024 17:33:27 +0100 Subject: [PATCH 165/201] FIX-#6904: Align levels of partially known dtypes with MultiIndex labels (#6905) Signed-off-by: Dmitry Chigarev --- .../core/dataframe/pandas/metadata/dtypes.py | 106 +++++++++++++++++- .../storage_formats/pandas/test_internals.py | 33 +++++- 2 files changed, 137 insertions(+), 2 deletions(-) diff --git a/modin/core/dataframe/pandas/metadata/dtypes.py b/modin/core/dataframe/pandas/metadata/dtypes.py index 0d228c569db..9f8cec8a0d5 100644 --- a/modin/core/dataframe/pandas/metadata/dtypes.py +++ b/modin/core/dataframe/pandas/metadata/dtypes.py @@ -159,7 +159,10 @@ def columns_order(self) -> Optional[dict[int, IndexLabel]]: if self._parent_df is None or not self._parent_df.has_materialized_columns: return None - self._columns_order = {i: col for i, col in enumerate(self._parent_df.columns)} + actual_columns = self._parent_df.columns + self._normalize_self_levels(actual_columns) + + self._columns_order = {i: col for i, col in enumerate(actual_columns)} # we got information about new columns and thus can potentially # extend our knowledge about missing dtypes if len(self._columns_order) > ( @@ -360,6 +363,7 @@ def _materialize_all_names(self): return all_cols = self._parent_df.columns + self._normalize_self_levels(all_cols) for col in all_cols: if ( col not in self._known_dtypes @@ -403,6 +407,7 @@ def materialize(self): if self._remaining_dtype is not None: cols = self._parent_df.columns + self._normalize_self_levels(cols) self._known_dtypes.update( { col: self._remaining_dtype @@ -630,6 +635,105 @@ def concat( know_all_names=know_all_names, ) + @staticmethod + def _normalize_levels(columns, reference=None): + """ + Normalize levels of MultiIndex column names. + + The function fills missing levels with empty strings as pandas do: + ''' + >>> columns = ["a", ("l1", "l2"), ("l1a", "l2a", "l3a")] + >>> _normalize_levels(columns) + [("a", "", ""), ("l1", "l2", ""), ("l1a", "l2a", "l3a")] + >>> # with a reference + >>> idx = pandas.MultiIndex(...) + >>> idx.nlevels + 4 + >>> _normalize_levels(columns, reference=idx) + [("a", "", "", ""), ("l1", "l2", "", ""), ("l1a", "l2a", "l3a", "")] + ''' + + Parameters + ---------- + columns : sequence + Labels to normalize. If dictionary, will replace keys with normalized columns. + reference : pandas.Index, optional + An index to match the number of levels with. If reference is a MultiIndex, then the reference number + of levels should not be greater than the maximum number of levels in `columns`. If not specified, + the `columns` themselves become a `reference`. + + Returns + ------- + sequence + Column values with normalized levels. + dict[hashable, hashable] + Mapping from old column names to new names, only contains column names that + were changed. + + Raises + ------ + ValueError + When the reference number of levels is greater than the maximum number of levels + in `columns`. + """ + if reference is None: + reference = columns + + if isinstance(reference, pandas.Index): + max_nlevels = reference.nlevels + else: + max_nlevels = 1 + for col in reference: + if isinstance(col, tuple): + max_nlevels = max(max_nlevels, len(col)) + + # if the reference is a regular flat index, then no actions are required (the result will be + # a flat index containing tuples of different lengths, this behavior fully matches pandas). + # Yes, this shortcut skips the 'if max_columns_nlevels > max_nlevels' below check on purpose. + if max_nlevels == 1: + return columns, {} + + max_columns_nlevels = 1 + for col in columns: + if isinstance(col, tuple): + max_columns_nlevels = max(max_columns_nlevels, len(col)) + + if max_columns_nlevels > max_nlevels: + raise ValueError( + f"The reference number of levels is greater than the maximum number of levels in columns: {max_columns_nlevels} > {max_nlevels}" + ) + + new_columns = [] + old_to_new_mapping = {} + for col in columns: + old_col = col + if not isinstance(col, tuple): + col = (col,) + col = col + ("",) * (max_nlevels - len(col)) + new_columns.append(col) + if old_col != col: + old_to_new_mapping[old_col] = col + + return new_columns, old_to_new_mapping + + def _normalize_self_levels(self, reference=None): + """ + Call ``self._normalize_levels()`` for known and unknown dtypes of this object. + + Parameters + ---------- + reference : pandas.Index, optional + """ + _, old_to_new_mapping = self._normalize_levels( + self._known_dtypes.keys(), reference + ) + for old_col, new_col in old_to_new_mapping.items(): + value = self._known_dtypes.pop(old_col) + self._known_dtypes[new_col] = value + self._cols_with_unknown_dtypes, _ = self._normalize_levels( + self._cols_with_unknown_dtypes, reference + ) + class ModinDtypes: """ diff --git a/modin/test/storage_formats/pandas/test_internals.py b/modin/test/storage_formats/pandas/test_internals.py index f61c3e5c0a1..1b7bf242962 100644 --- a/modin/test/storage_formats/pandas/test_internals.py +++ b/modin/test/storage_formats/pandas/test_internals.py @@ -30,7 +30,12 @@ ) from modin.core.storage_formats.pandas.utils import split_result_of_axis_func_pandas from modin.distributed.dataframe.pandas import from_partitions -from modin.pandas.test.utils import create_test_dfs, df_equals, test_data_values +from modin.pandas.test.utils import ( + create_test_dfs, + df_equals, + eval_general, + test_data_values, +) from modin.utils import try_cast_to_pandas NPartitions.put(4) @@ -2121,6 +2126,32 @@ def test_set_index_with_dupl_labels(self): pandas.Series([np.dtype(int), np.dtype("float64")], index=["a", "a"]) ) + def test_reset_index_mi_columns(self): + # reproducer from: https://github.com/modin-project/modin/issues/6904 + md_df, pd_df = create_test_dfs({"a": [1, 1, 2, 2], "b": [3, 3, 4, 4]}) + eval_general( + md_df, + pd_df, + lambda df: df.groupby("a").agg({"b": ["min", "std"]}).reset_index().dtypes, + ) + + def test_concat_mi(self): + """ + Verify that concatenating dfs with non-MultiIndex and MultiIndex columns results into valid indices for lazy dtypes. + """ + md_df1, pd_df1 = create_test_dfs({"a": [1, 1, 2, 2], "b": [3, 3, 4, 4]}) + md_df2, pd_df2 = create_test_dfs( + {("l1", "v1"): [1, 1, 2, 2], ("l1", "v2"): [3, 3, 4, 4]} + ) + + # Drop actual dtypes in order to use partially-known dtypes + md_df1._query_compiler._modin_frame.set_dtypes_cache(None) + md_df2._query_compiler._modin_frame.set_dtypes_cache(None) + + md_res = pd.concat([md_df1, md_df2], axis=1) + pd_res = pandas.concat([pd_df1, pd_df2], axis=1) + df_equals(md_res.dtypes, pd_res.dtypes) + class TestZeroComputationDtypes: """ From 91c8301f15f8f828cfef7d0b5e85ad90de8ef5c6 Mon Sep 17 00:00:00 2001 From: Iaroslav Igoshev Date: Tue, 6 Feb 2024 17:43:27 +0100 Subject: [PATCH 166/201] FEAT-#6908: Remove the warning regarding engine initialization (#6909) Signed-off-by: Igoshev, Iaroslav --- docs/getting_started/quickstart.rst | 1 + docs/getting_started/troubleshooting.rst | 5 +- .../using_modin/using_modin_locally.rst | 49 - docs/usage_guide/advanced_usage/index.rst | 15 + .../advanced_usage/modin_engines.rst | 76 + .../advanced_usage/modin_xgboost.rst | 2 + docs/usage_guide/benchmarking.rst | 2 + examples/jupyter/integrations/NLTK.ipynb | 1186 +-------------- examples/jupyter/integrations/altair.ipynb | 147 +- .../jupyter/integrations/huggingface.ipynb | 363 +---- .../jupyter/integrations/matplotlib.ipynb | 191 +-- examples/jupyter/integrations/plotly.ipynb | 703 +-------- examples/jupyter/integrations/sklearn.ipynb | 1331 +---------------- .../jupyter/integrations/statsmodels.ipynb | 469 +----- .../jupyter/integrations/tensorflow.ipynb | 579 +------ examples/jupyter/integrations/xgboost.ipynb | 66 +- examples/quickstart.ipynb | 1 + modin/core/execution/dask/common/utils.py | 10 - modin/core/execution/ray/common/utils.py | 9 - modin/core/execution/unidist/common/utils.py | 10 - 20 files changed, 372 insertions(+), 4843 deletions(-) create mode 100644 docs/usage_guide/advanced_usage/modin_engines.rst diff --git a/docs/getting_started/quickstart.rst b/docs/getting_started/quickstart.rst index dc6661bc7ab..c91693bade6 100644 --- a/docs/getting_started/quickstart.rst +++ b/docs/getting_started/quickstart.rst @@ -61,6 +61,7 @@ For the purpose of demonstration, we will load in modin as ``pd`` and pandas as ############################################# import time import ray + # Look at the Ray documentation with respect to the Ray configuration suited to you most. ray.init() ############################################# diff --git a/docs/getting_started/troubleshooting.rst b/docs/getting_started/troubleshooting.rst index 85d3b7f4ad0..75f4fc17b6f 100644 --- a/docs/getting_started/troubleshooting.rst +++ b/docs/getting_started/troubleshooting.rst @@ -215,6 +215,7 @@ once Python interpreter is started in them so that to avoid a race condition in import modin.pandas as pd import modin.config as cfg + # Look at the Ray documentation with respect to the Ray configuration suited to you most. ray.init(runtime_env={'env_vars': {'__MODIN_AUTOIMPORT_PANDAS__': '1'}}) pandas_df = pandas.DataFrame( @@ -357,7 +358,9 @@ or cfg.Engine.put("dask") if __name__ == "__main__": - client = Client() # Explicit Dask Client creation. + # Explicit Dask Client creation. + # Look at the Dask Distributed documentation with respect to the Client configuration suited to you most. + client = Client() df = pd.DataFrame([0, 1, 2, 3]) print(df) diff --git a/docs/getting_started/using_modin/using_modin_locally.rst b/docs/getting_started/using_modin/using_modin_locally.rst index d69cf7a6b1e..4d68ef6d8b2 100644 --- a/docs/getting_started/using_modin/using_modin_locally.rst +++ b/docs/getting_started/using_modin/using_modin_locally.rst @@ -23,55 +23,6 @@ just like you would pandas, since the API is identical to pandas. **That's it. You're ready to use Modin on your previous pandas workflows!** -Optional Configurations ------------------------ - -When using Modin locally on a single machine or laptop (without a cluster), Modin will -automatically create and manage a local Dask or Ray cluster for the executing your -code. So when you run an operation for the first time with Modin, you will see a -message like this, indicating that a Modin has automatically initialized a local -cluster for you: - -.. code-block:: python - - df = pd.DataFrame({'col1': [1, 2], 'col2': [3, 4]}) - -.. code-block:: text - - UserWarning: Ray execution environment not yet initialized. Initializing... - To remove this warning, run the following python code before doing dataframe operations: - - import ray - ray.init() - - If you prefer to use Dask over Ray as your execution backend, you can use the - following code to modify the default configuration: - -.. code-block:: python - - import modin - modin.config.Engine.put("Dask") - -.. code-block:: python - - df = pd.DataFrame({'col1': [1, 2], 'col2': [3, 4]}) - - -.. code-block:: text - - UserWarning: Dask execution environment not yet initialized. Initializing... - To remove this warning, run the following python code before doing dataframe operations: - - from distributed import Client - - client = Client() - -Finally, if you already have an Ray or Dask engine initialized, Modin will -automatically attach to whichever engine is available. If you are interested in using -Modin with HDK engine, please refer to :doc:`these instructions `. -For additional information on other settings you can configure, see -:doc:`Modin's config ` page for more details. - Advanced: Configuring the resources Modin uses ---------------------------------------------- diff --git a/docs/usage_guide/advanced_usage/index.rst b/docs/usage_guide/advanced_usage/index.rst index 7263036d5d0..35c910acb01 100644 --- a/docs/usage_guide/advanced_usage/index.rst +++ b/docs/usage_guide/advanced_usage/index.rst @@ -12,6 +12,7 @@ Advanced Usage modin_xgboost modin_logging batch + modin_engines .. meta:: :description lang=en: @@ -22,6 +23,16 @@ integrated toolkit for data scientists. We are actively developing data science such as DataFrame spreadsheet integration, DataFrame algebra, progress bars, SQL queries on DataFrames, and more. Join us on `Slack`_ and `Discourse`_ for the latest updates! +Modin engines +------------- + +Modin supports a series of execution engines such as Ray_, Dask_, `MPI through unidist`_, `HDK`_, +each of which might be a more beneficial choice for a specific scenario. When doing the first operation +with Modin it automatically initializes one of the engines to further perform distributed/parallel computation. +If you are familiar with a concrete execution engine, it is possible to initialize the engine on your own and +Modin will automatically attach to it. Refer to :doc:`Modin engines ` page +for more details. + Experimental APIs ----------------- @@ -118,3 +129,7 @@ downloaded as an artifact from the GitHub Actions tab for further inspection. Se .. _`tqdm`: https://github.com/tqdm/tqdm .. _`distributed XGBoost`: https://medium.com/intel-analytics-software/distributed-xgboost-with-modin-on-ray-fc17edef7720 .. _`fuzzydata`: https://github.com/suhailrehman/fuzzydata +.. _Ray: https://github.com/ray-project/ray +.. _Dask: https://github.com/dask/distributed +.. _`MPI through unidist`: https://github.com/modin-project/unidist +.. _HDK: https://github.com/intel-ai/hdk diff --git a/docs/usage_guide/advanced_usage/modin_engines.rst b/docs/usage_guide/advanced_usage/modin_engines.rst new file mode 100644 index 00000000000..53079c67e6c --- /dev/null +++ b/docs/usage_guide/advanced_usage/modin_engines.rst @@ -0,0 +1,76 @@ +Modin engines +============= + +As a rule, you don't have to worry about initialization of an execution engine as +Modin itself automatically initializes one when performing the first operation. +Also, Modin has a broad range of :doc:`configuration settings `, which +you can use to configure an execution engine. If there is a reason to initialize an execution engine +on your own and you are sure what to do, Modin will automatically attach to whichever engine is available. +Below, you can find some examples on how to initialize a specific execution engine on your own. + +Ray +--- + +You can initialize Ray engine with a specific number of CPUs (worker processes) to perform computation. + +.. code-block:: python + + import ray + import modin.config as modin_cfg + + ray.init(num_cpus=) + modin_cfg.Engine.put("ray") # Modin will use Ray engine + modin_cfg.CpuCount.put() + +To get more details on all possible parameters for initialization refer to `Ray documentation`_. + +Dask +---- + +You can initialize Dask engine with a specific number of worker processes and threads per worker to perform computation. + +.. code-block:: python + + from distributed import Client + import modin.config as modin_cfg + + client = Client(n_workers=, threads_per_worker=) + modin_cfg.Engine.put("dask") # # Modin will use Dask engine + modin_cfg.CpuCount.put() + +To get more details on all possible parameters for initialization refer to `Dask Distributed documentation`_. + +MPI through unidist +------------------- + +You can initialize MPI through unidist engine with a specific number of CPUs (worker processes) to perform computation. + +.. code-block:: python + + import unidist + import unidist.config as unidist_cfg + import modin.config as modin_cfg + + unidist_cfg.Backend.put("mpi") + unidist_cfg.CpuCount.put() + unidist.init() + + modin_cfg.Engine.put("unidist") # # Modin will use MPI through unidist engine + modin_cfg.CpuCount.put() + +To get more details on all possible parameters for initialization refer to `unidist documentation`_. + +HDK +--- + +For now it is not possible to initialize HDK beforehand. Modin itself initializes it with the required configuration. + +.. code-block:: python + + import modin.config as modin_cfg + + modin_cfg.StorageFormat.put("hdk") # # Modin will use HDK engine + +.. _`Ray documentation`: https://docs.ray.io/en/latest +.. _Dask Distributed documentation: https://distributed.dask.org/en/latest +.. _`unidist documentation`: https://unidist.readthedocs.io/en/latest diff --git a/docs/usage_guide/advanced_usage/modin_xgboost.rst b/docs/usage_guide/advanced_usage/modin_xgboost.rst index e77a464a528..af62158e437 100644 --- a/docs/usage_guide/advanced_usage/modin_xgboost.rst +++ b/docs/usage_guide/advanced_usage/modin_xgboost.rst @@ -55,6 +55,7 @@ To start the Ray runtime on a single node: .. code-block:: python import ray + # Look at the Ray documentation with respect to the Ray configuration suited to you most. ray.init() If you already had the Ray cluster you can connect to it by next way: @@ -78,6 +79,7 @@ All processing will be in a `single node` mode. from sklearn import datasets import ray + # Look at the Ray documentation with respect to the Ray configuration suited to you most. ray.init() # Start the Ray runtime for single-node import modin.pandas as pd diff --git a/docs/usage_guide/benchmarking.rst b/docs/usage_guide/benchmarking.rst index 551c9950ae7..f26a9dac3ec 100644 --- a/docs/usage_guide/benchmarking.rst +++ b/docs/usage_guide/benchmarking.rst @@ -35,6 +35,7 @@ Consider the following ipython script: import time import ray + # Look at the Ray documentation with respect to the Ray configuration suited to you most. ray.init() df = pd.DataFrame(list(range(MinPartitionSize.get() * 2))) %time result = df.map(lambda x: time.sleep(0.1) or x) @@ -146,6 +147,7 @@ That will typically block on any asynchronous computation: time.sleep(10) return x + 1 + # Look at the Ray documentation with respect to the Ray configuration suited to you most. ray.init() df1 = pd.DataFrame(list(range(10_000)), columns=['A']) result = df1.map(slow_add_one) diff --git a/examples/jupyter/integrations/NLTK.ipynb b/examples/jupyter/integrations/NLTK.ipynb index 0b9a945de38..504d56bcae9 100644 --- a/examples/jupyter/integrations/NLTK.ipynb +++ b/examples/jupyter/integrations/NLTK.ipynb @@ -16,7 +16,7 @@ }, { "cell_type": "code", - "execution_count": 1, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ @@ -29,96 +29,9 @@ }, { "cell_type": "code", - "execution_count": 2, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "UserWarning: Ray execution environment not yet initialized. Initializing...\n", - "To remove this warning, run the following python code before doing dataframe operations:\n", - "\n", - " import ray\n", - " ray.init(runtime_env={'env_vars': {'__MODIN_AUTOIMPORT_PANDAS__': '1'}})\n", - "\n", - "2023-04-05 18:22:43,278\tINFO worker.py:1553 -- Started a local Ray instance.\n" - ] - }, - { - "data": { - "text/html": [ - "

\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
Unnamed: 0created_atidauthor_idtext
002022-05-16T21:24:35.000Z1526312680226799618813286It’s despicable, it’s dangerous — and it needs...
112022-05-16T21:24:34.000Z1526312678951641088813286We need to repudiate in the strongest terms th...
222022-05-16T21:24:34.000Z1526312677521428480813286This weekend’s shootings in Buffalo offer a tr...
\n", - "
" - ], - "text/plain": [ - " Unnamed: 0 created_at id author_id \\\n", - "0 0 2022-05-16T21:24:35.000Z 1526312680226799618 813286 \n", - "1 1 2022-05-16T21:24:34.000Z 1526312678951641088 813286 \n", - "2 2 2022-05-16T21:24:34.000Z 1526312677521428480 813286 \n", - "\n", - " text \n", - "0 It’s despicable, it’s dangerous — and it needs... \n", - "1 We need to repudiate in the strongest terms th... \n", - "2 This weekend’s shootings in Buffalo offer a tr... " - ] - }, - "execution_count": 2, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "# Import some Tweets from Barack Obama \n", "modin_df = pd.read_csv(\"https://raw.githubusercontent.com/kirenz/twitter-tweepy/main/tweets-obama.csv\")\n", @@ -127,83 +40,9 @@ }, { "cell_type": "code", - "execution_count": 3, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "data": { - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
Unnamed: 0created_atidauthor_idtext
002022-05-16T21:24:35.000Z1526312680226799618813286it’s despicable, it’s dangerous — and it needs...
112022-05-16T21:24:34.000Z1526312678951641088813286we need to repudiate in the strongest terms th...
222022-05-16T21:24:34.000Z1526312677521428480813286this weekend’s shootings in buffalo offer a tr...
\n", - "
" - ], - "text/plain": [ - " Unnamed: 0 created_at id author_id \\\n", - "0 0 2022-05-16T21:24:35.000Z 1526312680226799618 813286 \n", - "1 1 2022-05-16T21:24:34.000Z 1526312678951641088 813286 \n", - "2 2 2022-05-16T21:24:34.000Z 1526312677521428480 813286 \n", - "\n", - " text \n", - "0 it’s despicable, it’s dangerous — and it needs... \n", - "1 we need to repudiate in the strongest terms th... \n", - "2 this weekend’s shootings in buffalo offer a tr... " - ] - }, - "execution_count": 3, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "modin_df['text'] = modin_df['text'].astype(str).str.lower()\n", "modin_df.head(3)" @@ -211,92 +50,9 @@ }, { "cell_type": "code", - "execution_count": 4, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "data": { - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
Unnamed: 0created_atidauthor_idtexttext_token
002022-05-16T21:24:35.000Z1526312680226799618813286it’s despicable, it’s dangerous — and it needs...[it, s, despicable, it, s, dangerous, and, it,...
112022-05-16T21:24:34.000Z1526312678951641088813286we need to repudiate in the strongest terms th...[we, need, to, repudiate, in, the, strongest, ...
222022-05-16T21:24:34.000Z1526312677521428480813286this weekend’s shootings in buffalo offer a tr...[this, weekend, s, shootings, in, buffalo, off...
\n", - "
" - ], - "text/plain": [ - " Unnamed: 0 created_at id author_id \\\n", - "0 0 2022-05-16T21:24:35.000Z 1526312680226799618 813286 \n", - "1 1 2022-05-16T21:24:34.000Z 1526312678951641088 813286 \n", - "2 2 2022-05-16T21:24:34.000Z 1526312677521428480 813286 \n", - "\n", - " text \\\n", - "0 it’s despicable, it’s dangerous — and it needs... \n", - "1 we need to repudiate in the strongest terms th... \n", - "2 this weekend’s shootings in buffalo offer a tr... \n", - "\n", - " text_token \n", - "0 [it, s, despicable, it, s, dangerous, and, it,... \n", - "1 [we, need, to, repudiate, in, the, strongest, ... \n", - "2 [this, weekend, s, shootings, in, buffalo, off... " - ] - }, - "execution_count": 4, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "regexp = RegexpTokenizer('\\w+')\n", "\n", @@ -306,36 +62,16 @@ }, { "cell_type": "code", - "execution_count": 5, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "[nltk_data] Downloading package stopwords to\n", - "[nltk_data] /Users/labanyamukhopadhyay/nltk_data...\n", - "[nltk_data] Package stopwords is already up-to-date!\n" - ] - }, - { - "data": { - "text/plain": [ - "True" - ] - }, - "execution_count": 5, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "nltk.download('stopwords')" ] }, { "cell_type": "code", - "execution_count": 6, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ @@ -349,92 +85,9 @@ }, { "cell_type": "code", - "execution_count": 7, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "data": { - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
Unnamed: 0created_atidauthor_idtexttext_token
002022-05-16T21:24:35.000Z1526312680226799618813286it’s despicable, it’s dangerous — and it needs...[despicable, dangerous, needs, stop, co, 0ch2z...
112022-05-16T21:24:34.000Z1526312678951641088813286we need to repudiate in the strongest terms th...[need, repudiate, strongest, terms, politician...
222022-05-16T21:24:34.000Z1526312677521428480813286this weekend’s shootings in buffalo offer a tr...[weekend, shootings, buffalo, offer, tragic, r...
\n", - "
" - ], - "text/plain": [ - " Unnamed: 0 created_at id author_id \\\n", - "0 0 2022-05-16T21:24:35.000Z 1526312680226799618 813286 \n", - "1 1 2022-05-16T21:24:34.000Z 1526312678951641088 813286 \n", - "2 2 2022-05-16T21:24:34.000Z 1526312677521428480 813286 \n", - "\n", - " text \\\n", - "0 it’s despicable, it’s dangerous — and it needs... \n", - "1 we need to repudiate in the strongest terms th... \n", - "2 this weekend’s shootings in buffalo offer a tr... \n", - "\n", - " text_token \n", - "0 [despicable, dangerous, needs, stop, co, 0ch2z... \n", - "1 [need, repudiate, strongest, terms, politician... \n", - "2 [weekend, shootings, buffalo, offer, tragic, r... " - ] - }, - "execution_count": 7, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "# Remove stopwords\n", "modin_df['text_token'] = modin_df['text_token'].apply(lambda x: [item for item in x if item not in stopwords])\n", @@ -443,98 +96,9 @@ }, { "cell_type": "code", - "execution_count": 8, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "data": { - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
texttext_tokentext_string
0it’s despicable, it’s dangerous — and it needs...[despicable, dangerous, needs, stop, co, 0ch2z...despicable dangerous needs stop 0ch2zosmhb
1we need to repudiate in the strongest terms th...[need, repudiate, strongest, terms, politician...need repudiate strongest terms politicians med...
2this weekend’s shootings in buffalo offer a tr...[weekend, shootings, buffalo, offer, tragic, r...weekend shootings buffalo offer tragic reminde...
3i’m proud to announce the voyager scholarship ...[proud, announce, voyager, scholarship, friend...proud announce voyager scholarship friend bche...
4across the country, americans are standing up ...[across, country, americans, standing, abortio...across country americans standing abortion rig...
\n", - "
" - ], - "text/plain": [ - " text \\\n", - "0 it’s despicable, it’s dangerous — and it needs... \n", - "1 we need to repudiate in the strongest terms th... \n", - "2 this weekend’s shootings in buffalo offer a tr... \n", - "3 i’m proud to announce the voyager scholarship ... \n", - "4 across the country, americans are standing up ... \n", - "\n", - " text_token \\\n", - "0 [despicable, dangerous, needs, stop, co, 0ch2z... \n", - "1 [need, repudiate, strongest, terms, politician... \n", - "2 [weekend, shootings, buffalo, offer, tragic, r... \n", - "3 [proud, announce, voyager, scholarship, friend... \n", - "4 [across, country, americans, standing, abortio... \n", - "\n", - " text_string \n", - "0 despicable dangerous needs stop 0ch2zosmhb \n", - "1 need repudiate strongest terms politicians med... \n", - "2 weekend shootings buffalo offer tragic reminde... \n", - "3 proud announce voyager scholarship friend bche... \n", - "4 across country americans standing abortion rig... " - ] - }, - "execution_count": 8, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "modin_df['text_string'] = modin_df['text_token'].apply(lambda x: ' '.join([item for item in x if len(item)>2]))\n", "modin_df[['text', 'text_token', 'text_string']].head()" @@ -542,36 +106,16 @@ }, { "cell_type": "code", - "execution_count": 9, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "[nltk_data] Downloading package punkt to\n", - "[nltk_data] /Users/labanyamukhopadhyay/nltk_data...\n", - "[nltk_data] Package punkt is already up-to-date!\n" - ] - }, - { - "data": { - "text/plain": [ - "True" - ] - }, - "execution_count": 9, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "nltk.download('punkt')" ] }, { "cell_type": "code", - "execution_count": 10, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ @@ -581,20 +125,9 @@ }, { "cell_type": "code", - "execution_count": 11, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "FreqDist({'need': 2, 'americans': 2, 'proud': 2, 'despicable': 1, 'dangerous': 1, 'needs': 1, 'stop': 1, '0ch2zosmhb': 1, 'repudiate': 1, 'strongest': 1, ...})" - ] - }, - "execution_count": 11, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "from nltk.probability import FreqDist\n", "\n", @@ -604,111 +137,9 @@ }, { "cell_type": "code", - "execution_count": 12, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "data": { - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
texttext_tokentext_stringtext_string_fdist
0it’s despicable, it’s dangerous — and it needs...[despicable, dangerous, needs, stop, co, 0ch2z...despicable dangerous needs stop 0ch2zosmhbdespicable dangerous needs stop 0ch2zosmhb
1we need to repudiate in the strongest terms th...[need, repudiate, strongest, terms, politician...need repudiate strongest terms politicians med...need repudiate strongest terms politicians med...
2this weekend’s shootings in buffalo offer a tr...[weekend, shootings, buffalo, offer, tragic, r...weekend shootings buffalo offer tragic reminde...weekend shootings buffalo offer tragic reminde...
3i’m proud to announce the voyager scholarship ...[proud, announce, voyager, scholarship, friend...proud announce voyager scholarship friend bche...proud announce voyager scholarship friend bche...
4across the country, americans are standing up ...[across, country, americans, standing, abortio...across country americans standing abortion rig...across country americans standing abortion rig...
\n", - "
" - ], - "text/plain": [ - " text \\\n", - "0 it’s despicable, it’s dangerous — and it needs... \n", - "1 we need to repudiate in the strongest terms th... \n", - "2 this weekend’s shootings in buffalo offer a tr... \n", - "3 i’m proud to announce the voyager scholarship ... \n", - "4 across the country, americans are standing up ... \n", - "\n", - " text_token \\\n", - "0 [despicable, dangerous, needs, stop, co, 0ch2z... \n", - "1 [need, repudiate, strongest, terms, politician... \n", - "2 [weekend, shootings, buffalo, offer, tragic, r... \n", - "3 [proud, announce, voyager, scholarship, friend... \n", - "4 [across, country, americans, standing, abortio... \n", - "\n", - " text_string \\\n", - "0 despicable dangerous needs stop 0ch2zosmhb \n", - "1 need repudiate strongest terms politicians med... \n", - "2 weekend shootings buffalo offer tragic reminde... \n", - "3 proud announce voyager scholarship friend bche... \n", - "4 across country americans standing abortion rig... \n", - "\n", - " text_string_fdist \n", - "0 despicable dangerous needs stop 0ch2zosmhb \n", - "1 need repudiate strongest terms politicians med... \n", - "2 weekend shootings buffalo offer tragic reminde... \n", - "3 proud announce voyager scholarship friend bche... \n", - "4 across country americans standing abortion rig... " - ] - }, - "execution_count": 12, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "modin_df['text_string_fdist'] = modin_df['text_token'].apply(lambda x: ' '.join([item for item in x if fdist[item] >= 1 ]))\n", "modin_df[['text', 'text_token', 'text_string', 'text_string_fdist']].head()" @@ -716,32 +147,9 @@ }, { "cell_type": "code", - "execution_count": 13, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "[nltk_data] Downloading package wordnet to\n", - "[nltk_data] /Users/labanyamukhopadhyay/nltk_data...\n", - "[nltk_data] Package wordnet is already up-to-date!\n", - "[nltk_data] Downloading package omw-1.4 to\n", - "[nltk_data] /Users/labanyamukhopadhyay/nltk_data...\n", - "[nltk_data] Package omw-1.4 is already up-to-date!\n" - ] - }, - { - "data": { - "text/plain": [ - "True" - ] - }, - "execution_count": 13, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "#lemmatization\n", "nltk.download('wordnet')\n", @@ -750,7 +158,7 @@ }, { "cell_type": "code", - "execution_count": 14, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ @@ -763,7 +171,7 @@ }, { "cell_type": "code", - "execution_count": 15, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ @@ -773,21 +181,9 @@ }, { "cell_type": "code", - "execution_count": 16, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "True 5\n", - "Name: is_equal, dtype: int64" - ] - }, - "execution_count": 16, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "# show level count\n", "modin_df.is_equal.value_counts()" @@ -795,7 +191,7 @@ }, { "cell_type": "code", - "execution_count": 17, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ @@ -804,22 +200,9 @@ }, { "cell_type": "code", - "execution_count": 18, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "data": { - "image/png": "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", - "text/plain": [ - "
" - ] - }, - "metadata": { - "needs_background": "light" - }, - "output_type": "display_data" - } - ], + "outputs": [], "source": [ "%matplotlib inline\n", "import matplotlib.pyplot as plt\n", @@ -844,7 +227,7 @@ }, { "cell_type": "code", - "execution_count": 19, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ @@ -854,83 +237,9 @@ }, { "cell_type": "code", - "execution_count": 20, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "data": { - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
Unnamed: 0created_atidauthor_idtext
002022-05-16T21:24:35.000Z1526312680226799618813286it’s despicable, it’s dangerous — and it needs...
112022-05-16T21:24:34.000Z1526312678951641088813286we need to repudiate in the strongest terms th...
222022-05-16T21:24:34.000Z1526312677521428480813286this weekend’s shootings in buffalo offer a tr...
\n", - "
" - ], - "text/plain": [ - " Unnamed: 0 created_at id author_id \\\n", - "0 0 2022-05-16T21:24:35.000Z 1526312680226799618 813286 \n", - "1 1 2022-05-16T21:24:34.000Z 1526312678951641088 813286 \n", - "2 2 2022-05-16T21:24:34.000Z 1526312677521428480 813286 \n", - "\n", - " text \n", - "0 it’s despicable, it’s dangerous — and it needs... \n", - "1 we need to repudiate in the strongest terms th... \n", - "2 this weekend’s shootings in buffalo offer a tr... " - ] - }, - "execution_count": 20, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "pandas_df['text'] = pandas_df['text'].astype(str).str.lower()\n", "pandas_df.head(3)" @@ -938,92 +247,9 @@ }, { "cell_type": "code", - "execution_count": 21, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "data": { - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
Unnamed: 0created_atidauthor_idtexttext_token
002022-05-16T21:24:35.000Z1526312680226799618813286it’s despicable, it’s dangerous — and it needs...[it, s, despicable, it, s, dangerous, and, it,...
112022-05-16T21:24:34.000Z1526312678951641088813286we need to repudiate in the strongest terms th...[we, need, to, repudiate, in, the, strongest, ...
222022-05-16T21:24:34.000Z1526312677521428480813286this weekend’s shootings in buffalo offer a tr...[this, weekend, s, shootings, in, buffalo, off...
\n", - "
" - ], - "text/plain": [ - " Unnamed: 0 created_at id author_id \\\n", - "0 0 2022-05-16T21:24:35.000Z 1526312680226799618 813286 \n", - "1 1 2022-05-16T21:24:34.000Z 1526312678951641088 813286 \n", - "2 2 2022-05-16T21:24:34.000Z 1526312677521428480 813286 \n", - "\n", - " text \\\n", - "0 it’s despicable, it’s dangerous — and it needs... \n", - "1 we need to repudiate in the strongest terms th... \n", - "2 this weekend’s shootings in buffalo offer a tr... \n", - "\n", - " text_token \n", - "0 [it, s, despicable, it, s, dangerous, and, it,... \n", - "1 [we, need, to, repudiate, in, the, strongest, ... \n", - "2 [this, weekend, s, shootings, in, buffalo, off... " - ] - }, - "execution_count": 21, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "regexp = RegexpTokenizer('\\w+')\n", "\n", @@ -1033,92 +259,9 @@ }, { "cell_type": "code", - "execution_count": 22, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "data": { - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
Unnamed: 0created_atidauthor_idtexttext_token
002022-05-16T21:24:35.000Z1526312680226799618813286it’s despicable, it’s dangerous — and it needs...[despicable, dangerous, needs, stop, co, 0ch2z...
112022-05-16T21:24:34.000Z1526312678951641088813286we need to repudiate in the strongest terms th...[need, repudiate, strongest, terms, politician...
222022-05-16T21:24:34.000Z1526312677521428480813286this weekend’s shootings in buffalo offer a tr...[weekend, shootings, buffalo, offer, tragic, r...
\n", - "
" - ], - "text/plain": [ - " Unnamed: 0 created_at id author_id \\\n", - "0 0 2022-05-16T21:24:35.000Z 1526312680226799618 813286 \n", - "1 1 2022-05-16T21:24:34.000Z 1526312678951641088 813286 \n", - "2 2 2022-05-16T21:24:34.000Z 1526312677521428480 813286 \n", - "\n", - " text \\\n", - "0 it’s despicable, it’s dangerous — and it needs... \n", - "1 we need to repudiate in the strongest terms th... \n", - "2 this weekend’s shootings in buffalo offer a tr... \n", - "\n", - " text_token \n", - "0 [despicable, dangerous, needs, stop, co, 0ch2z... \n", - "1 [need, repudiate, strongest, terms, politician... \n", - "2 [weekend, shootings, buffalo, offer, tragic, r... " - ] - }, - "execution_count": 22, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "# Remove stopwords\n", "pandas_df['text_token'] = pandas_df['text_token'].apply(lambda x: [item for item in x if item not in stopwords])\n", @@ -1127,98 +270,9 @@ }, { "cell_type": "code", - "execution_count": 23, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "data": { - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
texttext_tokentext_string
0it’s despicable, it’s dangerous — and it needs...[despicable, dangerous, needs, stop, co, 0ch2z...despicable dangerous needs stop 0ch2zosmhb
1we need to repudiate in the strongest terms th...[need, repudiate, strongest, terms, politician...need repudiate strongest terms politicians med...
2this weekend’s shootings in buffalo offer a tr...[weekend, shootings, buffalo, offer, tragic, r...weekend shootings buffalo offer tragic reminde...
3i’m proud to announce the voyager scholarship ...[proud, announce, voyager, scholarship, friend...proud announce voyager scholarship friend bche...
4across the country, americans are standing up ...[across, country, americans, standing, abortio...across country americans standing abortion rig...
\n", - "
" - ], - "text/plain": [ - " text \\\n", - "0 it’s despicable, it’s dangerous — and it needs... \n", - "1 we need to repudiate in the strongest terms th... \n", - "2 this weekend’s shootings in buffalo offer a tr... \n", - "3 i’m proud to announce the voyager scholarship ... \n", - "4 across the country, americans are standing up ... \n", - "\n", - " text_token \\\n", - "0 [despicable, dangerous, needs, stop, co, 0ch2z... \n", - "1 [need, repudiate, strongest, terms, politician... \n", - "2 [weekend, shootings, buffalo, offer, tragic, r... \n", - "3 [proud, announce, voyager, scholarship, friend... \n", - "4 [across, country, americans, standing, abortio... \n", - "\n", - " text_string \n", - "0 despicable dangerous needs stop 0ch2zosmhb \n", - "1 need repudiate strongest terms politicians med... \n", - "2 weekend shootings buffalo offer tragic reminde... \n", - "3 proud announce voyager scholarship friend bche... \n", - "4 across country americans standing abortion rig... " - ] - }, - "execution_count": 23, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "pandas_df['text_string'] = pandas_df['text_token'].apply(lambda x: ' '.join([item for item in x if len(item)>2]))\n", "pandas_df[['text', 'text_token', 'text_string']].head()" @@ -1226,7 +280,7 @@ }, { "cell_type": "code", - "execution_count": 24, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ @@ -1236,20 +290,9 @@ }, { "cell_type": "code", - "execution_count": 25, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "FreqDist({'need': 2, 'americans': 2, 'proud': 2, 'despicable': 1, 'dangerous': 1, 'needs': 1, 'stop': 1, '0ch2zosmhb': 1, 'repudiate': 1, 'strongest': 1, ...})" - ] - }, - "execution_count": 25, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "from nltk.probability import FreqDist\n", "\n", @@ -1259,111 +302,9 @@ }, { "cell_type": "code", - "execution_count": 26, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "data": { - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
texttext_tokentext_stringtext_string_fdist
0it’s despicable, it’s dangerous — and it needs...[despicable, dangerous, needs, stop, co, 0ch2z...despicable dangerous needs stop 0ch2zosmhbdespicable dangerous needs stop 0ch2zosmhb
1we need to repudiate in the strongest terms th...[need, repudiate, strongest, terms, politician...need repudiate strongest terms politicians med...need repudiate strongest terms politicians med...
2this weekend’s shootings in buffalo offer a tr...[weekend, shootings, buffalo, offer, tragic, r...weekend shootings buffalo offer tragic reminde...weekend shootings buffalo offer tragic reminde...
3i’m proud to announce the voyager scholarship ...[proud, announce, voyager, scholarship, friend...proud announce voyager scholarship friend bche...proud announce voyager scholarship friend bche...
4across the country, americans are standing up ...[across, country, americans, standing, abortio...across country americans standing abortion rig...across country americans standing abortion rig...
\n", - "
" - ], - "text/plain": [ - " text \\\n", - "0 it’s despicable, it’s dangerous — and it needs... \n", - "1 we need to repudiate in the strongest terms th... \n", - "2 this weekend’s shootings in buffalo offer a tr... \n", - "3 i’m proud to announce the voyager scholarship ... \n", - "4 across the country, americans are standing up ... \n", - "\n", - " text_token \\\n", - "0 [despicable, dangerous, needs, stop, co, 0ch2z... \n", - "1 [need, repudiate, strongest, terms, politician... \n", - "2 [weekend, shootings, buffalo, offer, tragic, r... \n", - "3 [proud, announce, voyager, scholarship, friend... \n", - "4 [across, country, americans, standing, abortio... \n", - "\n", - " text_string \\\n", - "0 despicable dangerous needs stop 0ch2zosmhb \n", - "1 need repudiate strongest terms politicians med... \n", - "2 weekend shootings buffalo offer tragic reminde... \n", - "3 proud announce voyager scholarship friend bche... \n", - "4 across country americans standing abortion rig... \n", - "\n", - " text_string_fdist \n", - "0 despicable dangerous needs stop 0ch2zosmhb \n", - "1 need repudiate strongest terms politicians med... \n", - "2 weekend shootings buffalo offer tragic reminde... \n", - "3 proud announce voyager scholarship friend bche... \n", - "4 across country americans standing abortion rig... " - ] - }, - "execution_count": 26, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "pandas_df['text_string_fdist'] = pandas_df['text_token'].apply(lambda x: ' '.join([item for item in x if fdist[item] >= 1 ]))\n", "pandas_df[['text', 'text_token', 'text_string', 'text_string_fdist']].head()" @@ -1371,7 +312,7 @@ }, { "cell_type": "code", - "execution_count": 27, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ @@ -1384,7 +325,7 @@ }, { "cell_type": "code", - "execution_count": 28, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ @@ -1394,21 +335,9 @@ }, { "cell_type": "code", - "execution_count": 29, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "True 5\n", - "Name: is_equal, dtype: int64" - ] - }, - "execution_count": 29, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "# show level count\n", "pandas_df.is_equal.value_counts()" @@ -1416,7 +345,7 @@ }, { "cell_type": "code", - "execution_count": 30, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ @@ -1425,22 +354,9 @@ }, { "cell_type": "code", - "execution_count": 31, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "data": { - "image/png": "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", - "text/plain": [ - "
" - ] - }, - "metadata": { - "needs_background": "light" - }, - "output_type": "display_data" - } - ], + "outputs": [], "source": [ "%matplotlib inline\n", "import matplotlib.pyplot as plt\n", diff --git a/examples/jupyter/integrations/altair.ipynb b/examples/jupyter/integrations/altair.ipynb index 406aaed007b..8502334bb75 100644 --- a/examples/jupyter/integrations/altair.ipynb +++ b/examples/jupyter/integrations/altair.ipynb @@ -10,7 +10,7 @@ }, { "cell_type": "code", - "execution_count": 1, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ @@ -20,24 +20,9 @@ }, { "cell_type": "code", - "execution_count": 2, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "UserWarning: Ray execution environment not yet initialized. Initializing...\n", - "To remove this warning, run the following python code before doing dataframe operations:\n", - "\n", - " import ray\n", - " ray.init(runtime_env={'env_vars': {'__MODIN_AUTOIMPORT_PANDAS__': '1'}})\n", - "\n", - "2023-04-06 12:15:19,701\tINFO worker.py:1553 -- Started a local Ray instance.\n", - "UserWarning: Distributing object. This may take some time.\n" - ] - } - ], + "outputs": [], "source": [ "from vega_datasets import data\n", "pandas_cars = data.cars()\n", @@ -46,50 +31,9 @@ }, { "cell_type": "code", - "execution_count": 3, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "UserWarning: data of type not recognized\n", - "UserWarning: `DataFrame.to_dict` is not currently supported by PandasOnRay, defaulting to pandas implementation.\n", - "Please refer to https://modin.readthedocs.io/en/stable/supported_apis/defaulting_to_pandas.html for explanation.\n" - ] - }, - { - "ename": "ValueError", - "evalue": "Origin encoding field is specified without a type; the type cannot be automatically inferred because the data is not specified as a pandas.DataFrame.", - "output_type": "error", - "traceback": [ - "\u001b[0;31m---------------------------------------------------------------------------\u001b[0m", - "\u001b[0;31mValueError\u001b[0m Traceback (most recent call last)", - "\u001b[0;32m~/.local/lib/python3.9/site-packages/altair/vegalite/v4/api.py\u001b[0m in \u001b[0;36mto_dict\u001b[0;34m(self, *args, **kwargs)\u001b[0m\n\u001b[1;32m 2018\u001b[0m \u001b[0mcopy\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mdata\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mcore\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mInlineData\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mvalues\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0;34m[\u001b[0m\u001b[0;34m{\u001b[0m\u001b[0;34m}\u001b[0m\u001b[0;34m]\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 2019\u001b[0m \u001b[0;32mreturn\u001b[0m \u001b[0msuper\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mChart\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mcopy\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mto_dict\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m*\u001b[0m\u001b[0margs\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0;34m**\u001b[0m\u001b[0mkwargs\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m-> 2020\u001b[0;31m \u001b[0;32mreturn\u001b[0m \u001b[0msuper\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mto_dict\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m*\u001b[0m\u001b[0margs\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0;34m**\u001b[0m\u001b[0mkwargs\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m\u001b[1;32m 2021\u001b[0m \u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 2022\u001b[0m \u001b[0;32mdef\u001b[0m \u001b[0madd_selection\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mself\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0;34m*\u001b[0m\u001b[0mselections\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n", - "\u001b[0;32m~/.local/lib/python3.9/site-packages/altair/vegalite/v4/api.py\u001b[0m in \u001b[0;36mto_dict\u001b[0;34m(self, *args, **kwargs)\u001b[0m\n\u001b[1;32m 382\u001b[0m \u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 383\u001b[0m \u001b[0;32mtry\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m--> 384\u001b[0;31m \u001b[0mdct\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0msuper\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mTopLevelMixin\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mcopy\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mto_dict\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m*\u001b[0m\u001b[0margs\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0;34m**\u001b[0m\u001b[0mkwargs\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m\u001b[1;32m 385\u001b[0m \u001b[0;32mexcept\u001b[0m \u001b[0mjsonschema\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mValidationError\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 386\u001b[0m \u001b[0mdct\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0;32mNone\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n", - "\u001b[0;32m~/.local/lib/python3.9/site-packages/altair/utils/schemapi.py\u001b[0m in \u001b[0;36mto_dict\u001b[0;34m(self, validate, ignore, context)\u001b[0m\n\u001b[1;32m 324\u001b[0m \u001b[0mresult\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0m_todict\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0m_args\u001b[0m\u001b[0;34m[\u001b[0m\u001b[0;36m0\u001b[0m\u001b[0;34m]\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mvalidate\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0msub_validate\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mcontext\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0mcontext\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 325\u001b[0m \u001b[0;32melif\u001b[0m \u001b[0;32mnot\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0m_args\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m--> 326\u001b[0;31m result = _todict(\n\u001b[0m\u001b[1;32m 327\u001b[0m \u001b[0;34m{\u001b[0m\u001b[0mk\u001b[0m\u001b[0;34m:\u001b[0m \u001b[0mv\u001b[0m \u001b[0;32mfor\u001b[0m \u001b[0mk\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mv\u001b[0m \u001b[0;32min\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0m_kwds\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mitems\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m)\u001b[0m \u001b[0;32mif\u001b[0m \u001b[0mk\u001b[0m \u001b[0;32mnot\u001b[0m \u001b[0;32min\u001b[0m \u001b[0mignore\u001b[0m\u001b[0;34m}\u001b[0m\u001b[0;34m,\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 328\u001b[0m \u001b[0mvalidate\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0msub_validate\u001b[0m\u001b[0;34m,\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n", - "\u001b[0;32m~/.local/lib/python3.9/site-packages/altair/utils/schemapi.py\u001b[0m in \u001b[0;36m_todict\u001b[0;34m(obj, validate, context)\u001b[0m\n\u001b[1;32m 58\u001b[0m \u001b[0;32mreturn\u001b[0m \u001b[0;34m[\u001b[0m\u001b[0m_todict\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mv\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mvalidate\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mcontext\u001b[0m\u001b[0;34m)\u001b[0m \u001b[0;32mfor\u001b[0m \u001b[0mv\u001b[0m \u001b[0;32min\u001b[0m \u001b[0mobj\u001b[0m\u001b[0;34m]\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 59\u001b[0m \u001b[0;32melif\u001b[0m \u001b[0misinstance\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mobj\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mdict\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m---> 60\u001b[0;31m return {\n\u001b[0m\u001b[1;32m 61\u001b[0m \u001b[0mk\u001b[0m\u001b[0;34m:\u001b[0m \u001b[0m_todict\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mv\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mvalidate\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mcontext\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 62\u001b[0m \u001b[0;32mfor\u001b[0m \u001b[0mk\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mv\u001b[0m \u001b[0;32min\u001b[0m \u001b[0mobj\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mitems\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n", - "\u001b[0;32m~/.local/lib/python3.9/site-packages/altair/utils/schemapi.py\u001b[0m in \u001b[0;36m\u001b[0;34m(.0)\u001b[0m\n\u001b[1;32m 59\u001b[0m \u001b[0;32melif\u001b[0m \u001b[0misinstance\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mobj\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mdict\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 60\u001b[0m return {\n\u001b[0;32m---> 61\u001b[0;31m \u001b[0mk\u001b[0m\u001b[0;34m:\u001b[0m \u001b[0m_todict\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mv\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mvalidate\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mcontext\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m\u001b[1;32m 62\u001b[0m \u001b[0;32mfor\u001b[0m \u001b[0mk\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mv\u001b[0m \u001b[0;32min\u001b[0m \u001b[0mobj\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mitems\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 63\u001b[0m \u001b[0;32mif\u001b[0m \u001b[0mv\u001b[0m \u001b[0;32mis\u001b[0m \u001b[0;32mnot\u001b[0m \u001b[0mUndefined\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n", - "\u001b[0;32m~/.local/lib/python3.9/site-packages/altair/utils/schemapi.py\u001b[0m in \u001b[0;36m_todict\u001b[0;34m(obj, validate, context)\u001b[0m\n\u001b[1;32m 54\u001b[0m \u001b[0;34m\"\"\"Convert an object to a dict representation.\"\"\"\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 55\u001b[0m \u001b[0;32mif\u001b[0m \u001b[0misinstance\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mobj\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mSchemaBase\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m---> 56\u001b[0;31m \u001b[0;32mreturn\u001b[0m \u001b[0mobj\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mto_dict\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mvalidate\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0mvalidate\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mcontext\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0mcontext\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m\u001b[1;32m 57\u001b[0m \u001b[0;32melif\u001b[0m \u001b[0misinstance\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mobj\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0;34m(\u001b[0m\u001b[0mlist\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mtuple\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mnp\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mndarray\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 58\u001b[0m \u001b[0;32mreturn\u001b[0m \u001b[0;34m[\u001b[0m\u001b[0m_todict\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mv\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mvalidate\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mcontext\u001b[0m\u001b[0;34m)\u001b[0m \u001b[0;32mfor\u001b[0m \u001b[0mv\u001b[0m \u001b[0;32min\u001b[0m \u001b[0mobj\u001b[0m\u001b[0;34m]\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n", - "\u001b[0;32m~/.local/lib/python3.9/site-packages/altair/utils/schemapi.py\u001b[0m in \u001b[0;36mto_dict\u001b[0;34m(self, validate, ignore, context)\u001b[0m\n\u001b[1;32m 324\u001b[0m \u001b[0mresult\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0m_todict\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0m_args\u001b[0m\u001b[0;34m[\u001b[0m\u001b[0;36m0\u001b[0m\u001b[0;34m]\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mvalidate\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0msub_validate\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mcontext\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0mcontext\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 325\u001b[0m \u001b[0;32melif\u001b[0m \u001b[0;32mnot\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0m_args\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m--> 326\u001b[0;31m result = _todict(\n\u001b[0m\u001b[1;32m 327\u001b[0m \u001b[0;34m{\u001b[0m\u001b[0mk\u001b[0m\u001b[0;34m:\u001b[0m \u001b[0mv\u001b[0m \u001b[0;32mfor\u001b[0m \u001b[0mk\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mv\u001b[0m \u001b[0;32min\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0m_kwds\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mitems\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m)\u001b[0m \u001b[0;32mif\u001b[0m \u001b[0mk\u001b[0m \u001b[0;32mnot\u001b[0m \u001b[0;32min\u001b[0m \u001b[0mignore\u001b[0m\u001b[0;34m}\u001b[0m\u001b[0;34m,\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 328\u001b[0m \u001b[0mvalidate\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0msub_validate\u001b[0m\u001b[0;34m,\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n", - "\u001b[0;32m~/.local/lib/python3.9/site-packages/altair/utils/schemapi.py\u001b[0m in \u001b[0;36m_todict\u001b[0;34m(obj, validate, context)\u001b[0m\n\u001b[1;32m 58\u001b[0m \u001b[0;32mreturn\u001b[0m \u001b[0;34m[\u001b[0m\u001b[0m_todict\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mv\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mvalidate\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mcontext\u001b[0m\u001b[0;34m)\u001b[0m \u001b[0;32mfor\u001b[0m \u001b[0mv\u001b[0m \u001b[0;32min\u001b[0m \u001b[0mobj\u001b[0m\u001b[0;34m]\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 59\u001b[0m \u001b[0;32melif\u001b[0m \u001b[0misinstance\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mobj\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mdict\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m---> 60\u001b[0;31m return {\n\u001b[0m\u001b[1;32m 61\u001b[0m \u001b[0mk\u001b[0m\u001b[0;34m:\u001b[0m \u001b[0m_todict\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mv\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mvalidate\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mcontext\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 62\u001b[0m \u001b[0;32mfor\u001b[0m \u001b[0mk\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mv\u001b[0m \u001b[0;32min\u001b[0m \u001b[0mobj\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mitems\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n", - "\u001b[0;32m~/.local/lib/python3.9/site-packages/altair/utils/schemapi.py\u001b[0m in \u001b[0;36m\u001b[0;34m(.0)\u001b[0m\n\u001b[1;32m 59\u001b[0m \u001b[0;32melif\u001b[0m \u001b[0misinstance\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mobj\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mdict\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 60\u001b[0m return {\n\u001b[0;32m---> 61\u001b[0;31m \u001b[0mk\u001b[0m\u001b[0;34m:\u001b[0m \u001b[0m_todict\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mv\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mvalidate\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mcontext\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m\u001b[1;32m 62\u001b[0m \u001b[0;32mfor\u001b[0m \u001b[0mk\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mv\u001b[0m \u001b[0;32min\u001b[0m \u001b[0mobj\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mitems\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 63\u001b[0m \u001b[0;32mif\u001b[0m \u001b[0mv\u001b[0m \u001b[0;32mis\u001b[0m \u001b[0;32mnot\u001b[0m \u001b[0mUndefined\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n", - "\u001b[0;32m~/.local/lib/python3.9/site-packages/altair/utils/schemapi.py\u001b[0m in \u001b[0;36m_todict\u001b[0;34m(obj, validate, context)\u001b[0m\n\u001b[1;32m 54\u001b[0m \u001b[0;34m\"\"\"Convert an object to a dict representation.\"\"\"\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 55\u001b[0m \u001b[0;32mif\u001b[0m \u001b[0misinstance\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mobj\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mSchemaBase\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m---> 56\u001b[0;31m \u001b[0;32mreturn\u001b[0m \u001b[0mobj\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mto_dict\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mvalidate\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0mvalidate\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mcontext\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0mcontext\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m\u001b[1;32m 57\u001b[0m \u001b[0;32melif\u001b[0m \u001b[0misinstance\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mobj\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0;34m(\u001b[0m\u001b[0mlist\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mtuple\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mnp\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mndarray\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 58\u001b[0m \u001b[0;32mreturn\u001b[0m \u001b[0;34m[\u001b[0m\u001b[0m_todict\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mv\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mvalidate\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mcontext\u001b[0m\u001b[0;34m)\u001b[0m \u001b[0;32mfor\u001b[0m \u001b[0mv\u001b[0m \u001b[0;32min\u001b[0m \u001b[0mobj\u001b[0m\u001b[0;34m]\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n", - "\u001b[0;32m~/.local/lib/python3.9/site-packages/altair/vegalite/v4/schema/channels.py\u001b[0m in \u001b[0;36mto_dict\u001b[0;34m(self, validate, ignore, context)\u001b[0m\n\u001b[1;32m 42\u001b[0m \"match any column in the data.\".format(shorthand))\n\u001b[1;32m 43\u001b[0m \u001b[0;32melse\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m---> 44\u001b[0;31m raise ValueError(\"{} encoding field is specified without a type; \"\n\u001b[0m\u001b[1;32m 45\u001b[0m \u001b[0;34m\"the type cannot be automatically inferred because \"\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 46\u001b[0m \u001b[0;34m\"the data is not specified as a pandas.DataFrame.\"\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n", - "\u001b[0;31mValueError\u001b[0m: Origin encoding field is specified without a type; the type cannot be automatically inferred because the data is not specified as a pandas.DataFrame." - ] - }, - { - "data": { - "text/plain": [ - "alt.Chart(...)" - ] - }, - "execution_count": 3, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "# Create a visualization with Modin df\n", "alt.Chart(modin_cars).mark_point().encode(\n", @@ -101,84 +45,9 @@ }, { "cell_type": "code", - "execution_count": 4, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "FutureWarning: iteritems is deprecated and will be removed in a future version. Use .items instead.\n" - ] - }, - { - "data": { - "text/html": [ - "\n", - "
\n", - "" - ], - "text/plain": [ - "alt.Chart(...)" - ] - }, - "execution_count": 4, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "# Create a visualization with pandas df\n", "alt.Chart(pandas_cars).mark_point().encode(\n", @@ -208,7 +77,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.9.7" + "version": "3.9.18" }, "orig_nbformat": 4 }, diff --git a/examples/jupyter/integrations/huggingface.ipynb b/examples/jupyter/integrations/huggingface.ipynb index 69370054deb..ebb011c699b 100644 --- a/examples/jupyter/integrations/huggingface.ipynb +++ b/examples/jupyter/integrations/huggingface.ipynb @@ -10,7 +10,7 @@ }, { "cell_type": "code", - "execution_count": 1, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ @@ -20,7 +20,7 @@ }, { "cell_type": "code", - "execution_count": 2, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ @@ -31,20 +31,9 @@ }, { "cell_type": "code", - "execution_count": 3, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "('imdb.csv', )" - ] - }, - "execution_count": 3, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "import urllib.request\n", "url_path = \"https://modin-datasets.intel.com/testing/IMDB_Dataset.csv\"\n", @@ -53,31 +42,9 @@ }, { "cell_type": "code", - "execution_count": 4, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "UserWarning: Ray execution environment not yet initialized. Initializing...\n", - "To remove this warning, run the following python code before doing dataframe operations:\n", - "\n", - " import ray\n", - " ray.init(runtime_env={'env_vars': {'__MODIN_AUTOIMPORT_PANDAS__': '1'}})\n", - "\n", - "2023-04-11 10:27:18,363\tINFO worker.py:1553 -- Started a local Ray instance.\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "CPU times: user 575 ms, sys: 261 ms, total: 836 ms\n", - "Wall time: 8.58 s\n" - ] - } - ], + "outputs": [], "source": [ "%%time\n", "modin_df = pd.read_csv(\"imdb.csv\")" @@ -85,159 +52,34 @@ }, { "cell_type": "code", - "execution_count": 5, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "data": { - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
reviewsentiment
0One of the other reviewers has mentioned that ...positive
1A wonderful little production. <br /><br />The...positive
2I thought this was a wonderful way to spend ti...positive
3Basically there's a family where a little boy ...negative
4Petter Mattei's \"Love in the Time of Money\" is...positive
\n", - "
" - ], - "text/plain": [ - " review sentiment\n", - "0 One of the other reviewers has mentioned that ... positive\n", - "1 A wonderful little production.

The... positive\n", - "2 I thought this was a wonderful way to spend ti... positive\n", - "3 Basically there's a family where a little boy ... negative\n", - "4 Petter Mattei's \"Love in the Time of Money\" is... positive" - ] - }, - "execution_count": 5, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "modin_df.head()" ] }, { "cell_type": "code", - "execution_count": 6, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "modin.pandas.dataframe.DataFrame" - ] - }, - "execution_count": 6, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "type(modin_df)" ] }, { "cell_type": "code", - "execution_count": 7, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "data": { - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
reviewsentiment
30204Jack Lemmon was one of our great actors. His p...negative
\n", - "
" - ], - "text/plain": [ - " review sentiment\n", - "30204 Jack Lemmon was one of our great actors. His p... negative" - ] - }, - "execution_count": 7, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "modin_df.sample()" ] }, { "cell_type": "code", - "execution_count": 8, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ @@ -246,22 +88,9 @@ }, { "cell_type": "code", - "execution_count": 9, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "2023-04-11 10:27:24.824712: I tensorflow/core/platform/cpu_feature_guard.cc:151] This TensorFlow binary is optimized with oneAPI Deep Neural Network Library (oneDNN) to use the following CPU instructions in performance-critical operations: AVX2 FMA\n", - "To enable them in other operations, rebuild TensorFlow with the appropriate compiler flags.\n", - "All model checkpoint layers were used when initializing TFBertForSequenceClassification.\n", - "\n", - "Some layers of TFBertForSequenceClassification were not initialized from the model checkpoint at bert-base-uncased and are newly initialized: ['classifier']\n", - "You should probably TRAIN this model on a down-stream task to be able to use it for predictions and inference.\n" - ] - } - ], + "outputs": [], "source": [ "# Loading the BERT Classifier and Tokenizer along with Input module\n", "from transformers import InputExample, InputFeatures\n", @@ -272,38 +101,16 @@ }, { "cell_type": "code", - "execution_count": 10, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Model: \"tf_bert_for_sequence_classification\"\n", - "_________________________________________________________________\n", - " Layer (type) Output Shape Param # \n", - "=================================================================\n", - " bert (TFBertMainLayer) multiple 109482240 \n", - " \n", - " dropout_37 (Dropout) multiple 0 \n", - " \n", - " classifier (Dense) multiple 1538 \n", - " \n", - "=================================================================\n", - "Total params: 109,483,778\n", - "Trainable params: 109,483,778\n", - "Non-trainable params: 0\n", - "_________________________________________________________________\n" - ] - } - ], + "outputs": [], "source": [ "model.summary()" ] }, { "cell_type": "code", - "execution_count": 11, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ @@ -322,18 +129,9 @@ }, { "cell_type": "code", - "execution_count": 12, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "['in', 'this', 'ka', '##ggle', 'notebook', ',', 'i', 'will', 'do', 'sentiment', 'analysis', 'using', 'bert', 'with', 'hugging', '##face']\n", - "[1999, 2023, 10556, 24679, 14960, 1010, 1045, 2097, 2079, 15792, 4106, 2478, 14324, 2007, 17662, 12172]\n" - ] - } - ], + "outputs": [], "source": [ "# But first see BERT tokenizer exmaples and other required stuff!\n", "\n", @@ -346,27 +144,16 @@ }, { "cell_type": "code", - "execution_count": 13, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "modin.pandas.dataframe.DataFrame" - ] - }, - "execution_count": 13, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "type(train)" ] }, { "cell_type": "code", - "execution_count": 14, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ @@ -386,7 +173,7 @@ }, { "cell_type": "code", - "execution_count": 15, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ @@ -438,49 +225,18 @@ }, { "cell_type": "code", - "execution_count": 16, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "0 InputExample(guid=None, text_a=\"One of the oth...\n", - "1 InputExample(guid=None, text_a='A wonderful li...\n", - "2 InputExample(guid=None, text_a='I thought this...\n", - "3 InputExample(guid=None, text_a=\"Basically ther...\n", - "4 InputExample(guid=None, text_a='Petter Mattei\\...\n", - " ... \n", - "44995 InputExample(guid=None, text_a=\"I watched this...\n", - "44996 InputExample(guid=None, text_a=\"I am a sucker ...\n", - "44997 InputExample(guid=None, text_a=\"I am a college...\n", - "44998 InputExample(guid=None, text_a=\"huge Ramones f...\n", - "44999 InputExample(guid=None, text_a='I rented this ...\n", - "Length: 45000, dtype: object" - ] - }, - "execution_count": 16, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "train_InputExamples" ] }, { "cell_type": "code", - "execution_count": 17, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - " 0%| | 0/45000 [00:00\u001b[0;34m\u001b[0m\n\u001b[0;32m----> 1\u001b[0;31m \u001b[0mmodel\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mfit\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mtrain_data\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mepochs\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0;36m2\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mvalidation_data\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0mvalidation_data\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m", - "\u001b[0;32m~/opt/anaconda3/lib/python3.9/site-packages/keras/utils/traceback_utils.py\u001b[0m in \u001b[0;36merror_handler\u001b[0;34m(*args, **kwargs)\u001b[0m\n\u001b[1;32m 62\u001b[0m \u001b[0mfiltered_tb\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0;32mNone\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 63\u001b[0m \u001b[0;32mtry\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m---> 64\u001b[0;31m \u001b[0;32mreturn\u001b[0m \u001b[0mfn\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m*\u001b[0m\u001b[0margs\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0;34m**\u001b[0m\u001b[0mkwargs\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m\u001b[1;32m 65\u001b[0m \u001b[0;32mexcept\u001b[0m \u001b[0mException\u001b[0m \u001b[0;32mas\u001b[0m \u001b[0me\u001b[0m\u001b[0;34m:\u001b[0m \u001b[0;31m# pylint: disable=broad-except\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 66\u001b[0m \u001b[0mfiltered_tb\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0m_process_traceback_frames\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0me\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0m__traceback__\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n", - "\u001b[0;32m~/opt/anaconda3/lib/python3.9/site-packages/keras/engine/training.py\u001b[0m in \u001b[0;36mfit\u001b[0;34m(self, x, y, batch_size, epochs, verbose, callbacks, validation_split, validation_data, shuffle, class_weight, sample_weight, initial_epoch, steps_per_epoch, validation_steps, validation_batch_size, validation_freq, max_queue_size, workers, use_multiprocessing)\u001b[0m\n\u001b[1;32m 1382\u001b[0m _r=1):\n\u001b[1;32m 1383\u001b[0m \u001b[0mcallbacks\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mon_train_batch_begin\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mstep\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m-> 1384\u001b[0;31m \u001b[0mtmp_logs\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mtrain_function\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0miterator\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m\u001b[1;32m 1385\u001b[0m \u001b[0;32mif\u001b[0m \u001b[0mdata_handler\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mshould_sync\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 1386\u001b[0m \u001b[0mcontext\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0masync_wait\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n", - "\u001b[0;32m~/opt/anaconda3/lib/python3.9/site-packages/tensorflow/python/util/traceback_utils.py\u001b[0m in \u001b[0;36merror_handler\u001b[0;34m(*args, **kwargs)\u001b[0m\n\u001b[1;32m 148\u001b[0m \u001b[0mfiltered_tb\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0;32mNone\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 149\u001b[0m \u001b[0;32mtry\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m--> 150\u001b[0;31m \u001b[0;32mreturn\u001b[0m \u001b[0mfn\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m*\u001b[0m\u001b[0margs\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0;34m**\u001b[0m\u001b[0mkwargs\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m\u001b[1;32m 151\u001b[0m \u001b[0;32mexcept\u001b[0m \u001b[0mException\u001b[0m \u001b[0;32mas\u001b[0m \u001b[0me\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 152\u001b[0m \u001b[0mfiltered_tb\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0m_process_traceback_frames\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0me\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0m__traceback__\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n", - "\u001b[0;32m~/opt/anaconda3/lib/python3.9/site-packages/tensorflow/python/eager/def_function.py\u001b[0m in \u001b[0;36m__call__\u001b[0;34m(self, *args, **kwds)\u001b[0m\n\u001b[1;32m 913\u001b[0m \u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 914\u001b[0m \u001b[0;32mwith\u001b[0m \u001b[0mOptionalXlaContext\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0m_jit_compile\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m--> 915\u001b[0;31m \u001b[0mresult\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0m_call\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m*\u001b[0m\u001b[0margs\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0;34m**\u001b[0m\u001b[0mkwds\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m\u001b[1;32m 916\u001b[0m \u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 917\u001b[0m \u001b[0mnew_tracing_count\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mexperimental_get_tracing_count\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n", - "\u001b[0;32m~/opt/anaconda3/lib/python3.9/site-packages/tensorflow/python/eager/def_function.py\u001b[0m in \u001b[0;36m_call\u001b[0;34m(self, *args, **kwds)\u001b[0m\n\u001b[1;32m 945\u001b[0m \u001b[0;31m# In this case we have created variables on the first call, so we run the\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 946\u001b[0m \u001b[0;31m# defunned version which is guaranteed to never create variables.\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m--> 947\u001b[0;31m \u001b[0;32mreturn\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0m_stateless_fn\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m*\u001b[0m\u001b[0margs\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0;34m**\u001b[0m\u001b[0mkwds\u001b[0m\u001b[0;34m)\u001b[0m \u001b[0;31m# pylint: disable=not-callable\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m\u001b[1;32m 948\u001b[0m \u001b[0;32melif\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0m_stateful_fn\u001b[0m \u001b[0;32mis\u001b[0m \u001b[0;32mnot\u001b[0m \u001b[0;32mNone\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 949\u001b[0m \u001b[0;31m# Release the lock early so that multiple threads can perform the call\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n", - "\u001b[0;32m~/opt/anaconda3/lib/python3.9/site-packages/tensorflow/python/eager/function.py\u001b[0m in \u001b[0;36m__call__\u001b[0;34m(self, *args, **kwargs)\u001b[0m\n\u001b[1;32m 2954\u001b[0m (graph_function,\n\u001b[1;32m 2955\u001b[0m filtered_flat_args) = self._maybe_define_function(args, kwargs)\n\u001b[0;32m-> 2956\u001b[0;31m return graph_function._call_flat(\n\u001b[0m\u001b[1;32m 2957\u001b[0m filtered_flat_args, captured_inputs=graph_function.captured_inputs) # pylint: disable=protected-access\n\u001b[1;32m 2958\u001b[0m \u001b[0;34m\u001b[0m\u001b[0m\n", - "\u001b[0;32m~/opt/anaconda3/lib/python3.9/site-packages/tensorflow/python/eager/function.py\u001b[0m in \u001b[0;36m_call_flat\u001b[0;34m(self, args, captured_inputs, cancellation_manager)\u001b[0m\n\u001b[1;32m 1851\u001b[0m and executing_eagerly):\n\u001b[1;32m 1852\u001b[0m \u001b[0;31m# No tape is watching; skip to running the function.\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m-> 1853\u001b[0;31m return self._build_call_outputs(self._inference_function.call(\n\u001b[0m\u001b[1;32m 1854\u001b[0m ctx, args, cancellation_manager=cancellation_manager))\n\u001b[1;32m 1855\u001b[0m forward_backward = self._select_forward_and_backward_functions(\n", - "\u001b[0;32m~/opt/anaconda3/lib/python3.9/site-packages/tensorflow/python/eager/function.py\u001b[0m in \u001b[0;36mcall\u001b[0;34m(self, ctx, args, cancellation_manager)\u001b[0m\n\u001b[1;32m 497\u001b[0m \u001b[0;32mwith\u001b[0m \u001b[0m_InterpolateFunctionError\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mself\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 498\u001b[0m \u001b[0;32mif\u001b[0m \u001b[0mcancellation_manager\u001b[0m \u001b[0;32mis\u001b[0m \u001b[0;32mNone\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m--> 499\u001b[0;31m outputs = execute.execute(\n\u001b[0m\u001b[1;32m 500\u001b[0m \u001b[0mstr\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0msignature\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mname\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m,\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 501\u001b[0m \u001b[0mnum_outputs\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0m_num_outputs\u001b[0m\u001b[0;34m,\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n", - "\u001b[0;32m~/opt/anaconda3/lib/python3.9/site-packages/tensorflow/python/eager/execute.py\u001b[0m in \u001b[0;36mquick_execute\u001b[0;34m(op_name, num_outputs, inputs, attrs, ctx, name)\u001b[0m\n\u001b[1;32m 52\u001b[0m \u001b[0;32mtry\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 53\u001b[0m \u001b[0mctx\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mensure_initialized\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m---> 54\u001b[0;31m tensors = pywrap_tfe.TFE_Py_Execute(ctx._handle, device_name, op_name,\n\u001b[0m\u001b[1;32m 55\u001b[0m inputs, attrs, num_outputs)\n\u001b[1;32m 56\u001b[0m \u001b[0;32mexcept\u001b[0m \u001b[0mcore\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0m_NotOkStatusException\u001b[0m \u001b[0;32mas\u001b[0m \u001b[0me\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n", - "\u001b[0;31mKeyboardInterrupt\u001b[0m: " - ] - } - ], + "outputs": [], "source": [ "model.fit(train_data, epochs=2, validation_data=validation_data)" ] }, { "cell_type": "code", - "execution_count": 21, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ @@ -566,18 +284,9 @@ }, { "cell_type": "code", - "execution_count": 22, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "worst movie of my life, will never watch movies from this series : Negative\n", - "Wow, blew my mind, what a movie by Marvel, animation and story is amazing : Positive\n" - ] - } - ], + "outputs": [], "source": [ "tf_batch = tokenizer(pred_sentences, max_length=128, padding=True, truncation=True, return_tensors='tf') # we are tokenizing before sending into our trained model\n", "tf_outputs = model(tf_batch) \n", diff --git a/examples/jupyter/integrations/matplotlib.ipynb b/examples/jupyter/integrations/matplotlib.ipynb index 4c1e53a4dbf..8c2a5dec3c7 100644 --- a/examples/jupyter/integrations/matplotlib.ipynb +++ b/examples/jupyter/integrations/matplotlib.ipynb @@ -9,7 +9,7 @@ }, { "cell_type": "code", - "execution_count": 1, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ @@ -21,37 +21,9 @@ }, { "cell_type": "code", - "execution_count": 2, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "UserWarning: Ray execution environment not yet initialized. Initializing...\n", - "To remove this warning, run the following python code before doing dataframe operations:\n", - "\n", - " import ray\n", - " ray.init(runtime_env={'env_vars': {'__MODIN_AUTOIMPORT_PANDAS__': '1'}})\n", - "\n", - "2023-01-06 09:40:24,085\tINFO worker.py:1529 -- Started a local Ray instance. View the dashboard at \u001b[1m\u001b[32m127.0.0.1:8267 \u001b[39m\u001b[22m\n", - "UserWarning: Distributing object. This may take some time.\n", - "UserWarning: Distributing object. This may take some time.\n" - ] - }, - { - "data": { - "image/png": "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", - "text/plain": [ - "
" - ] - }, - "metadata": { - "needs_background": "light" - }, - "output_type": "display_data" - } - ], + "outputs": [], "source": [ "# Create a visualization with Modin df\n", "# Example modified from https://matplotlib.org/3.1.1/gallery/lines_bars_and_markers/xcorr_acorr_demo.html#sphx-glr-gallery-lines-bars-and-markers-xcorr-acorr-demo-py\n", @@ -74,22 +46,9 @@ }, { "cell_type": "code", - "execution_count": 3, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "data": { - "image/png": "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", - "text/plain": [ - "
" - ] - }, - "metadata": { - "needs_background": "light" - }, - "output_type": "display_data" - } - ], + "outputs": [], "source": [ "# Create a visualization with pandas df\n", "# Example modified from https://matplotlib.org/3.1.1/gallery/lines_bars_and_markers/xcorr_acorr_demo.html#sphx-glr-gallery-lines-bars-and-markers-xcorr-acorr-demo-py\n", @@ -112,29 +71,9 @@ }, { "cell_type": "code", - "execution_count": 4, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "UserWarning: Distributing object. This may take some time.\n" - ] - }, - { - "data": { - "image/png": "iVBORw0KGgoAAAANSUhEUgAAAXoAAADYCAYAAADlAyjBAAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjQuMywgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy/MnkTPAAAACXBIWXMAAAsTAAALEwEAmpwYAAAd00lEQVR4nO3deZgcdb3v8feH7OsEyCQkE0IIiaxhHQEF2QL3qIDBBURQIyJxA/Gcw/VBHz167rki91yvl6CiJ1fRHK8XRQQBF4QMRETZJmHJBpkQskzWSUISCGtmvvePqk4mw0wyM713f17PM093V1dX/aqm6tPf+lV1tyICMzOrXPsVuwFmZpZfDnozswrnoDczq3AOejOzCuegNzOrcA56M7MK56A3s32S9IqkiVlO4+eS/nsvXneWpOZs5t1heuPT5emTq2mWOge9WYFIukxSYxoy6yT9SdLp3XxtSJqU7zZ2JSKGRsTyfE1f0qcktabrZrukpyVd0IvpvO3NRNIKSedmHkfEqnR5WnPR9nLgoDcrAEn/BNwE3ACMBsYDtwDTitisfZLUt4CzezQihgIjgJ8Ct0s6oIDzr1gOerM8k1QD/DfgixFxZ0TsiIi3IuLeiPiv6TgnS3pU0ta02v+BpP7pcw+nk3omrXg/mg6/IK18t0r6u6Rj283zRElPSXpZ0m8k/bp9pSvpKknLJG2RdI+kse2eC0lflNQENLUbNim9P0jS/5K0UtI2SY9IGpQ+9xtJ69PhD0s6uqfrKyLagFuBQcDbuoskHSlpbrrciyR9IB0+A7gc+Eq6nu6V9AuSN9V702FfkTQhXZ6+6evmSvo3SX9L19f9kka2m98n02XdLOkbHY8QyoGD3iz/3gUMBO7ayzitwD8CI9PxpwJfAIiIM9Jxjku7HH4t6USSMPwscCDwH8A9kgakbxB3AT8HDgBuAz6YmZGkc4DvAJcAY4CVwK86tOci4BTgqE7a+l3gJODd6fS/ArSlz/0JmAyMAuYDv9zLMncqDeDPAK+QvtG0e64fcC9wfzqPa4BfSjo8Imal8/v3dD1dGBGfAFYBF6bD/r2L2V4GXJFOsz9wXTq/o0iOvC4nWVc1QF1Pl6nYHPRm+XcgsCkidnY1QkTMi4jHImJnRKwgCe4z9zLNq4D/iIjHI6I1ImYDbwCnpn99gZvTI4c7gSfavfZy4NaImB8RbwBfBd4laUK7cb4TEVsi4rX2M5W0H/Bp4NqIWJPO++/pdIiIWyPi5fTxt4Dj0iOa7jhV0lZgPfAx4IMRsa3jOMBQ4MaIeDMiHgR+n46fjZ9FxNJ0eW8Hjk+HfwS4NyIeiYg3gX8Byu4LwgrZ/2ZWrTYDIyX17SrsJb0D+B5QDwwm2Tfn7WWahwDTJV3Tblh/YCxJEK2JPb+xcHW7+2NJqm0AIuIVSZtJKtUVnYzf3kiSo5MXOlmGPsC3gYuBWnZX+SOBjoHdmcciYl8np8cCq9PunYyVZF9lr293/1WSN5Nd88s8ERGvpuuqrLiiN8u/R4HXSbpDuvIj4DlgckQMB74GaC/jrwa+HREj2v0NjojbgHVAnaT2rz+43f21JG8UAEgaQnLUsabdOF1VrZvSZTmsk+cuIzm5fC5JF8eEzCz2shw9tRY4OD2yyBjP7rZ31u5sKvB1wLjMg/RcxIFZTK8oHPRmeZZ2P/wL8ENJF0kaLKmfpPdJyvQZDwO2A69IOgL4fIfJbGDPE5P/B/icpFOUGCLpfEnDSN5YWoGrJfWVNA04ud1r/x9whaTjJQ0guRLo8bTLaF/LkjlR+j1JYyX1kfSudDrDSLqPNpMcldzQ/bXUbY8DO0hOuPaTdBZwIbvPMXRcT10N6647gAslvTs99/Gv5PaNqyAc9GYFEBHfA/4J+DrQQlKRXw38Lh3lOpKK+GWSEP91h0l8C5idXmlySUQ0kvTT/wB4CVgGfCqd15vAh4Arga3Ax0n6sTP96A3AN4DfklSshwGX9mBxrgMWAE8CW4D/QZIl/0nSjbIGWAw81oNpdku6bB8A3kdydHEL8MmIeC4d5afAUel6+l067DvA19Nh1/VwfotITvj+imRdvQxsJF2X5UL+4RGzyifpceDHEfGzYrelnEkaSvLmOTkiXixyc7rNFb1ZBZJ0pqSD0q6b6cCxwH3Fblc5knRh2t02hOTS0gXsPmldFhz0ZpXpcOAZkqtd/hn4SESsK26TytY0kpPAa0k+I3BplFlXiLtuzMwqnCt6M7MK56A3M6twDnozswrnoDczq3AOejOzCuegNzOrcA56M7MKVxJfUzxy5MiYMGFCsZthFWLevHmbIqK20PP1dmy5kuttuCSCfsKECTQ2Nha7GVYhJK0sxny9HVuu5HobdteNmVmFc9CbmVU4B71VBUm3StooaWG7YQdIekBSU3q7f7vnvippmaTnJf1DcVptlhv7DHrvIFYhfg68t8Ow64GGiJgMNKSPkXQUyQ9xHJ2+5pb091DNylJ3Kvqf4x3EylxEPEzya0jtTQNmp/dns/s3XacBv4qIN9Ifl1jGnj/FZ5aVN3a28o3fLeTFTTsKMr99Br13EKtgozPf0Z7ejkqH15H81F9GczrsbSTNkNQoqbGlpSWvjbXKcfuTq/nFYytZ89JrBZlfby+v3GMHkdR+B2n/O5F73UGAGQDjx4/vZTMs3yZc/4diN6FTK248P5+T7+zHnzv94YaImAXMAqivr/ePO9g+vbGzlVvmvkD9Iftz2qQDCzLPXJ+M7dEOEhH1EVFfW1vwz7aYAWyQNAYgvd2YDm8GDm433jiSXxcyy9rtjc2s2/Y61547GamzyMy93ga9dxCrBPcA09P704G72w2/VNIASYeS/HzcE0Von1WYN3a28qOHlnHSIftz+qSRBZtvb4PeO4iVFUm3AY8Ch0tqlnQlcCNwnqQm4Lz0MRGxCLgdWEzyg9pfjIjW4rTcKslvGptZu+11rp1auGoeutFHn+4gZwEjJTUD3yTZIW5Pd5ZVwMWQ7CCSMjvITryDWImIiI918dTULsb/NvDt/LXIqs2bO9u45aFlnDh+BO+ZXLhqHroR9N5BzMyy95t5q1m77XW+8+FjC1rNgz8Za2aWd0k1/wInjB/BGQWu5sFBb2aWd3fMa2bN1tcK3jef4aA3M8ujN3e28cOHlnH8wSM48x3FuZTcQW9mlke/nZ9W8wW8br4jB72ZWZ5kqvnjDh7BWUWq5sFBb2aWN3fOb6b5pdf4cpH65jMc9GZmefBWaxs/eGgZx42r4azDi/s1Lw56M7M8yFTzxeybz3DQm5nl2FutbXz/wWUcO66Gsw8fte8X5JmD3swsx+6avyap5ovcN5/hoDczy6G3Wtv4/kNNTKmr4Zwjil/Ng4PezCyn7npqDau3lE41Dw56M7Oceau1jR88uIxj6oYz9cjSqObBQW9mljO/e2oNq7a8ypenvqNkqnlw0JuZ5cTO9Lr5UqvmwUFvZpYTv3t6LSs3v8q1JVbNg4PezCxrO1vb+P6DTRw9djjnllg1Dw56M7Os3b2rmi+dK23ac9CbmWUhU80fNWY45x01utjN6ZSD3swsC/c8s5YVm18tie+06YqD3qqapH+UtEjSQkm3SRoo6QBJD0hqSm/3L3Y7rTTtTL/T5sgxw/kvJVrNg4PeqpikOuBLQH1EHAP0AS4FrgcaImIy0JA+Nnube59dy4ubdpRs33xGVkHvasgqQF9gkKS+wGBgLTANmJ0+Pxu4qDhNs1LW2hZ8v2EZRxw0rKSrecgi6F0NWbmLiDXAd4FVwDpgW0TcD4yOiHXpOOuALq+XkzRDUqOkxpaWlkI020rEvc+sZfmmHXz53Mnst1/pVvOQfdeNqyErW+nR5jTgUGAsMETSx3syjYiYFRH1EVFfW1vcXxGywmltC25+sCmt5g8qdnP2qddBn2015ErISsC5wIsR0RIRbwF3Au8GNkgaA5DebixiG60E/f7ZtSxvSfrmS72ah+y6brKqhlwJWQlYBZwqabCSM2lTgSXAPcD0dJzpwN1Fap+VoNa2YGZDE4ePHsY/HF361TwkXS+9tasaApC0RzUUEetcDVkpi4jHJd0BzAd2Ak8Bs4ChwO2SriR5M7i4eK20UpOp5m+5/MSyqOYhu6DfVQ0Br5FUQ43ADpIq6EZcDVmJi4hvAt/sMPgNku3ZbA+tbcHNaTX/3jKp5iGLoHc1ZGbV5vfPruWFlh388LLyqeYhu4re1ZCZVY3WtuD7Dy7jHaOH8r5jyqeaB38y1sysW/6wYB3LNr7Cl8rkSpv2HPRmZvuQfAq2icmjhvL+Y8YUuzk95qA3M9uHPy5YR1OZVvPgoDcz26u29EqbyaOG8v4p5VfNg4PezGyv/rgwqeavmTqZPmVYzYOD3sysS5lqftKooZxfptU8OOjNzLr0p4XrWbrhFa45Z1LZVvPgoDcz61Smmj+sdggXHDu22M3JioPezKwT9y1az/MbXuZLZdw3n+GgNzPrIFPNT6yAah4c9GZmb/PnRet5bv3LfOmc8q/mwUFvZraHtvT75ieOHMKFx5V/NQ8OejOzPdy/OKnmr5la3lfatOegNzNLJdX8sqSar4C++QwHvZlZ6v7FG1iybjtXnzOJvn0qJx4rZ0nMzLKQ6Zs/dOQQPlAhffMZDnozM+CBJWk1f3ZlVfPgoDczIyKYOaeJCQcOZtrxlVXNg4PezIwHFm9g8brtXHPO5Iqr5sFBb4akEZLukPScpCWS3iXpAEkPSGpKb/cvdjstPyKSvvlKrebBQW8GMBO4LyKOAI4DlgDXAw0RMRloSB9bBZqzZCOL1m7n6gqt5sFBb1VO0nDgDOCnABHxZkRsBaYBs9PRZgMXFaN9ll8RwU1zlnLIgYO5qEKrecgy6H3IaxVgItAC/EzSU5J+ImkIMDoi1gGkt6M6e7GkGZIaJTW2tLQUrtWWEw2Zar4Cr7RpL9sl8yGvlbu+wInAjyLiBGAHPdhmI2JWRNRHRH1tbW2+2mh5EBHc1LCU8QcM5oMn1BW7OXnV66D3Ia9ViGagOSIeTx/fQRL8GySNAUhvNxapfZYnDz63kYVrKu9TsJ3JZul8yGtlLyLWA6slHZ4OmgosBu4BpqfDpgN3F6F5lidJ33wTBx8wqOKrecgu6H3Ia5XiGuCXkp4FjgduAG4EzpPUBJyXPrYK8dDzG1mwZhvXnD2ZfhVezUMS1r3V2SHv9aSHvBGxzoe8Vg4i4mmgvpOnpha4KVYAe1TzJ1Z+NQ9ZVPQ+5DWzcjT3+Raebd7G1WdPqopqHrKr6GH3IW9/YDlwBcmbx+2SrgRWARdnOQ8zs5zIXDc/bv9BfOjEccVuTsFkFfQ+5DWzcjJ3aQvPNG/jxg9NqZpqHvzJWDOrEpm++boR1VXNg4PezKrEX5a28MzqrVx9ziT6962u6KuupTWzqtS+mv9wlVXz4KA3syrwl6UtPL16K188u/qqeXDQm1mFy3zffN2IQXzkpOqr5sFBb2YV7uGmTTy1aitfOPuwqqzmwUFvZhUs+S3YpYytGcjFJx1c7OYUjYPezCrWX5s2MX/VVr5QpX3zGdW75GZW0TJ982NrBnJxfXX2zWc46M2sIj2ybBPzVr7E58+exIC+fYrdnKJy0JtZxUn65psYUzOQS6q8mgcHvZlVoL8t20zjypf4wlmHVX01Dw56M6swSd/8Ug4aPpBL3lm9V9q056A3s4ry9xc28+SKl/jC2a7mMxz0ZlYxMn3zBw0fyCX1ruYzHPRmVjEefWEzT6zYwufPOoyB/VzNZzjozawiRAQ3NTQxevgAPuq++T046M2sIjy6fDNPvLiFz5/par4jB71VPUl9JD0l6ffp4wMkPSCpKb3dv9httH2bOaeJUcMGcOnJ44vdlJLjoDeDa4El7R5fDzRExGSgIX1sJezRFzbz+Ivum++Kg96qmqRxwPnAT9oNngbMTu/PBi4qcLOsh2Y2LGXUsAF8zNV8pxz0Vu1uAr4CtLUbNjoi1gGkt6O6erGkGZIaJTW2tLTktaHWuUdf2Mxjy7fwOffNdynroHf/ppUrSRcAGyNiXm+nERGzIqI+Iupra2tz2DrrrpkNS6kdNoDLTnE135VcVPTu37RydRrwAUkrgF8B50j6v8AGSWMA0tuNxWui7c1jy5Nq3lfa7F1WQe/+TStnEfHViBgXEROAS4EHI+LjwD3A9HS06cDdRWqi7cPMOU2u5rsh24r+JnrZv+m+TSthNwLnSWoCzksfW4l5fPlmHl2+2X3z3dDroM+2f9N9m1ZKImJuRFyQ3t8cEVMjYnJ6u6XY7bO3m9nQxMihA7jc1fw+9c3itZn+zfcDA4Hh7fs3I2Kd+zfNLB+eeHELf39hM18//0hX893Q64re/ZtmViwzG5am1fwhxW5KWcjHdfTu3zSzvHlyxRb+tmwznztzIoP6u5rvjmy6bnaJiLnA3PT+ZmBqLqZrZtbRzDlNjBza39V8D/iTsWZWNhpXbOGRZZv47BmHuZrvAQe9mZWNmQ1NHDikP5ef6ittesJBb2ZlYd7KLfy1aROfPXMig/vnpNe5ajjozaws3DQnqeY/fqr75nvKQW9mJW/eypf4a9MmZpzhar43HPRmVvJmNjRxwJD+fOJdruZ7w0FvZiVt/qqXeHhpi6v5LDjozaykzZyTVvPum+81B72ZlaynVr3EX5a2cNV7JjJkgKv53nLQm1nJmtnQxP6D+/FJ981nxUFvZiXp6dVbmft8C1ed4Wo+Ww56MytJM+csTav5CcVuStlz0JtZyXl69VYeer6Fz7xnIkNdzWfNQW9mJefmhiZGDO7H9HdPKHZTKoKD3sxKyjOrt/Lgcxu5ytV8zjjozaykZKp5X2mTOw56MysZzzZvpeG5jXzm9EMZNrBfsZtTMRz0VtUkHSzpIUlLJC2SdG06/ABJD0hqSm/3L3Zbq8HNDU3UDHLffK456K3a7QT+OSKOBE4FvijpKOB6oCEiJgMN6WPLowXN25izxNV8PjjorapFxLqImJ/efxlYAtQB04DZ6WizgYuK0sAqMjNTzZ82odhNqTgOerOUpAnACcDjwOiIWAfJmwEwqovXzJDUKKmxpaWlYG2tNAvXbGPOkg1cefqhDHc1n3MOejNA0lDgt8CXI2J7d18XEbMioj4i6mtra/PXwAo3s6GJ4QP78ilX83nR66D3SSyrFJL6kYT8LyPiznTwBklj0ufHABuL1b5Kt3DNNh5YvIErT5/oaj5PsqnofRLLyp4kAT8FlkTE99o9dQ8wPb0/Hbi70G2rFje7ms+7Xge9T2JZhTgN+ARwjqSn07/3AzcC50lqAs5LH1uOLVq7jfsXb+DTpx9KzSBX8/mSk88X7+0klqQuT2IBMwDGjx+fi2aY9VhEPAKoi6enFrIt1ejmhiaGDezLFacdWuymVLSsT8b6JJaZ9cbitdv586INfPo0V/P5llXQ+ySWmfVWppr/9Omu5vMtm6tufBLLzHpl8drt3LdoPVe4mi+IbProMyexFkh6Oh32NZKTVrdLuhJYBVycVQvNrOLc3NDEsAF9udJ98wXR66D3SSwz640l65Jq/ktTJ1Mz2NV8IfiTsWZWUK7mC89Bb2YF89z67fxp4XquOG2Cq/kCctCbWcHc3NDE0AG+0qbQ/IOMZpY3O1vbaNr4CgvWbOPZ5q38ccF6rjlnEiMG9y9206qKg97McmJnaxvLWl5hQfM2Fq7ZxrNrtrFk3XZef6sNgCH9+3DOEaP4zOkTi9zS6uOgN7Me29naxgstO1iwJg315q0s7hDqR9fVcPkphzClroYp42o49MAh7LdfVxfqWT456M1sr1rbghfSSn3BmuRv8drtvPZWKwCD+/fhmLE1XHbyIUwZN5wpdSOYONKhXkoc9Ga2S2tbsLwl06eeVOuL2oX6oH59OKZuOJeefDBT6mo4dlwNh44cSh+Heklz0JtVqda24MVNbw/1V9/cHepHjx3OR9+5O9Qn1jrUy5GDPs8mXP+HYjehUytuPL/YTbACamsLlm/akfanZ0J9GzvSUB/Ybz+OHlvDJfUHc0wa6oc51CuGg96swrS1BS9u3h3qmT71V97YCcCAvvtx9NjhfOSkcUwZN4IpdTUcVjuEvn38sZpK5aA3K2NtbcGKzcnVL5mTpYs6hPpRY4fzoRPrdl39Mql2qEO9yjjozcpEW1uwcsuraahvTUJ9zXZebhfqR44ZzgdPqGPKuBqm1NUweZRD3Rz0ZiUpIli5+dVdlzMuaN7GwrXbePn1JNT7p6E+7YSxHFs3gmPqapg8eij9HOrWCQe9WZFFBKt2Veq7r1XfFep99uPIMcP4wHFjOXZcDcfU1fCO0cMc6tZtDnqzAooIVm95Lbmkcc1WFqbhvr1dqB+RhvqUut2h3r+vQ916z0Fv1gVJ7wVmAn2An0TEjT2dxobtrzNv5Uu7LmlcsGYb2157C4B+fcQRBw3ngjTUpzjULU8c9GadkNQH+CFwHtAMPCnpnohY3JPp3DGvmf/55+fp10ccftAw3j9lzO5QP2goA/r2yUfzzfbgoDfr3MnAsohYDiDpV8A0oEdBf9EJdbxn8kgOP2iYQ92KxkFv1rk6YHW7x83AKR1HkjQDmAEwfvz4t09kxCDqRgzKUxPNusedgWad6+yz//G2ARGzIqI+Iupra2sL0CyznnPQm3WuGTi43eNxwNoitcUsK3kLeknvlfS8pGWSrs/XfMzy5ElgsqRDJfUHLgXuKXKbzHolL330ubpiAfztj1YcEbFT0tXAn0kur7w1IhYVuVlmvZKvk7E5uWLBrJgi4o/AH4vdDrNsKeJt55eyn6j0EeC9EfGZ9PEngFMi4up24+y6WgE4HHg+5w15u5HApgLMJ1/c/u45JCIKfmZUUguwspOnyv3/lkteF3vqan3kdBvOV0W/zysWImIWMCtP8++UpMaIqC/kPHPJ7S9tXe2Ylb7cPeF1sadCrY98nYz1FQtmZiUiX0HvKxbMzEpEXrpuSviKhYJ2FeWB21+eqnW5O+N1saeCrI+8nIw1M7PS4U/GmplVOAe9mVmFq4igl+Rv4Syial3/1brctneluF2URdBL+oak5yQ9IOk2SddJmivpBkl/Aa6VNFXSU5IWSLpV0oD0tSskjUzv10uam97/lqRfSHpQUpOkq/Yy/6GSGiTNT6c/rRDLnSuSzpL0+yxeX9T1n47/lXTaz0jq8S899Uaxl7vct7tcynYbzqVibxfp+D3aH0runacjSfXAh4ETSNo7H5iXPj0iIs6UNBBoAqZGxFJJ/wl8HrhpH5M/FjgVGAI8JekPEdHZ9f6vAx+MiO3pP+kxJd/dk5Mz2ZL6RsTOXEwr10ph/Ut6H3ARyaerX5V0QPZLtnelsNzkebvLpVLehnOpFLaL3uwP5VDRnw7cHRGvRcTLwL3tnvt1ens48GJELE0fzwbO6Ma0M9PdBDxE8h09nRFwg6RngTkkP0oxursLUAoVADBc0l2SFkv6saTu/u9LYf2fC/wsIl4FiIgt3Wx7NkphubPa7nKpzLfhXCqF7aLH+0PJV/R0/nUKGTu6Mc5Odr+hDezwXMfKqKtK6XKgFjgpIt6StKKTaXWqFCqA1MnAUSTfxXIf8CHgju4swl6eK9T6116ey5dSWO5eb3e5VAHbcC6VwnbR4/2hHCr6R4ALJQ2UNBTo7PuBnwMmSJqUPv4E8Jf0/grgpPT+hzu8blo63QOBs0g+0duZGmBjurOdDRzSg/aXQgUA8ERELI+IVuC2tF3dUQrr/37g05IGAxSi64bSWO5strtcKvdtOJdKYbvo8f5Q8kEfEU+SfH3CM8CdQCOwrcM4rwNXAL+RtABoA36cPv2vwExJfwVaO0z+CeAPwGPAv+2lkvglUC+pkaTKeq4Hi1AKFUBPx909Ugms/4i4L21Do6Sngeu60/ZslMJyk912l0tlvQ3nUilsF73aHyKi5P+Aoent4HTFnpiDaX4LuK4AbX8nyaHuQGAoydcxXwfMBerTcQYCq4BJ6eOfA9em9+cA70vv/29gbrv2P52+9sD09WO7aMNZwGvAoSQ73J+BD1fD+q/W7S7H66Hst+Fq3y7KoY8eYJako0g2iNkRMb/YDequiHhSUqYCWEkXFYCkTAXQl+SQrX0F8FNJXwMe7zD5TAUwnr1XhgCPAjcCU4CHgbt6sBhlu/6zVK3LvYcK2YZzqey2C3/XTTuSpgC/6DD4jYg4JcvpDo2IV9I+tYeBGdluHJK+BbwSEd/NZjqlJF/rv9SVw3J7Gy68XG4X5VLRF0RELACOz8Oky64CKIY8rv+SVibL7W24wHK5XbiiryDlUBma7Y234fxw0JuZVbiSv7zSzMyy46A3M6twDnozswrnoDczq3D/H80M1Yx0Ov1FAAAAAElFTkSuQmCC", - "text/plain": [ - "
" - ] - }, - "metadata": { - "needs_background": "light" - }, - "output_type": "display_data" - } - ], + "outputs": [], "source": [ "# Create a visualization with Modin df\n", "# Example modified from https://matplotlib.org/stable/tutorials/introductory/pyplot.html#sphx-glr-tutorials-introductory-pyplot-py\n", @@ -158,22 +97,9 @@ }, { "cell_type": "code", - "execution_count": 5, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "data": { - "image/png": "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", - "text/plain": [ - "
" - ] - }, - "metadata": { - "needs_background": "light" - }, - "output_type": "display_data" - } - ], + "outputs": [], "source": [ "# Create a visualization with pandas df\n", "# Example modified from https://matplotlib.org/stable/tutorials/introductory/pyplot.html#sphx-glr-tutorials-introductory-pyplot-py\n", @@ -196,29 +122,9 @@ }, { "cell_type": "code", - "execution_count": 6, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "UserWarning: Distributing object. This may take some time.\n" - ] - }, - { - "data": { - "image/png": "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", - "text/plain": [ - "
" - ] - }, - "metadata": { - "needs_background": "light" - }, - "output_type": "display_data" - } - ], + "outputs": [], "source": [ "# Create a visualization with Modin df\n", "# Example modified from https://matplotlib.org/stable/tutorials/introductory/pyplot.html#sphx-glr-tutorials-introductory-pyplot-py\n", @@ -242,22 +148,9 @@ }, { "cell_type": "code", - "execution_count": 7, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "data": { - "image/png": "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", - "text/plain": [ - "
" - ] - }, - "metadata": { - "needs_background": "light" - }, - "output_type": "display_data" - } - ], + "outputs": [], "source": [ "# Create a visualization with pandas df\n", "# Example modified from https://matplotlib.org/stable/tutorials/introductory/pyplot.html#sphx-glr-tutorials-introductory-pyplot-py\n", @@ -279,22 +172,9 @@ }, { "cell_type": "code", - "execution_count": 8, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "data": { - "image/png": "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", - "text/plain": [ - "
" - ] - }, - "metadata": { - "needs_background": "light" - }, - "output_type": "display_data" - } - ], + "outputs": [], "source": [ "plt.figure(figsize=(9, 3))\n", "\n", @@ -306,29 +186,9 @@ }, { "cell_type": "code", - "execution_count": 9, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "UserWarning: Distributing object. This may take some time.\n" - ] - }, - { - "data": { - "image/png": "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", - "text/plain": [ - "
" - ] - }, - "metadata": { - "needs_background": "light" - }, - "output_type": "display_data" - } - ], + "outputs": [], "source": [ "# Create a visualization with Modin df\n", "# Example modified from https://matplotlib.org/stable/tutorials/introductory/pyplot.html#sphx-glr-tutorials-introductory-pyplot-py\n", @@ -352,22 +212,9 @@ }, { "cell_type": "code", - "execution_count": 10, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "data": { - "image/png": "iVBORw0KGgoAAAANSUhEUgAAAXoAAADYCAYAAADlAyjBAAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjQuMywgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy/MnkTPAAAACXBIWXMAAAsTAAALEwEAmpwYAAAd00lEQVR4nO3deZgcdb3v8feH7OsEyCQkE0IIiaxhHQEF2QL3qIDBBURQIyJxA/Gcw/VBHz167rki91yvl6CiJ1fRHK8XRQQBF4QMRETZJmHJBpkQskzWSUISCGtmvvePqk4mw0wyM713f17PM093V1dX/aqm6tPf+lV1tyICMzOrXPsVuwFmZpZfDnozswrnoDczq3AOejOzCuegNzOrcA56M7MK56A3s32S9IqkiVlO4+eS/nsvXneWpOZs5t1heuPT5emTq2mWOge9WYFIukxSYxoy6yT9SdLp3XxtSJqU7zZ2JSKGRsTyfE1f0qcktabrZrukpyVd0IvpvO3NRNIKSedmHkfEqnR5WnPR9nLgoDcrAEn/BNwE3ACMBsYDtwDTitisfZLUt4CzezQihgIjgJ8Ct0s6oIDzr1gOerM8k1QD/DfgixFxZ0TsiIi3IuLeiPiv6TgnS3pU0ta02v+BpP7pcw+nk3omrXg/mg6/IK18t0r6u6Rj283zRElPSXpZ0m8k/bp9pSvpKknLJG2RdI+kse2eC0lflNQENLUbNim9P0jS/5K0UtI2SY9IGpQ+9xtJ69PhD0s6uqfrKyLagFuBQcDbuoskHSlpbrrciyR9IB0+A7gc+Eq6nu6V9AuSN9V702FfkTQhXZ6+6evmSvo3SX9L19f9kka2m98n02XdLOkbHY8QyoGD3iz/3gUMBO7ayzitwD8CI9PxpwJfAIiIM9Jxjku7HH4t6USSMPwscCDwH8A9kgakbxB3AT8HDgBuAz6YmZGkc4DvAJcAY4CVwK86tOci4BTgqE7a+l3gJODd6fS/ArSlz/0JmAyMAuYDv9zLMncqDeDPAK+QvtG0e64fcC9wfzqPa4BfSjo8Imal8/v3dD1dGBGfAFYBF6bD/r2L2V4GXJFOsz9wXTq/o0iOvC4nWVc1QF1Pl6nYHPRm+XcgsCkidnY1QkTMi4jHImJnRKwgCe4z9zLNq4D/iIjHI6I1ImYDbwCnpn99gZvTI4c7gSfavfZy4NaImB8RbwBfBd4laUK7cb4TEVsi4rX2M5W0H/Bp4NqIWJPO++/pdIiIWyPi5fTxt4Dj0iOa7jhV0lZgPfAx4IMRsa3jOMBQ4MaIeDMiHgR+n46fjZ9FxNJ0eW8Hjk+HfwS4NyIeiYg3gX8Byu4LwgrZ/2ZWrTYDIyX17SrsJb0D+B5QDwwm2Tfn7WWahwDTJV3Tblh/YCxJEK2JPb+xcHW7+2NJqm0AIuIVSZtJKtUVnYzf3kiSo5MXOlmGPsC3gYuBWnZX+SOBjoHdmcciYl8np8cCq9PunYyVZF9lr293/1WSN5Nd88s8ERGvpuuqrLiiN8u/R4HXSbpDuvIj4DlgckQMB74GaC/jrwa+HREj2v0NjojbgHVAnaT2rz+43f21JG8UAEgaQnLUsabdOF1VrZvSZTmsk+cuIzm5fC5JF8eEzCz2shw9tRY4OD2yyBjP7rZ31u5sKvB1wLjMg/RcxIFZTK8oHPRmeZZ2P/wL8ENJF0kaLKmfpPdJyvQZDwO2A69IOgL4fIfJbGDPE5P/B/icpFOUGCLpfEnDSN5YWoGrJfWVNA04ud1r/x9whaTjJQ0guRLo8bTLaF/LkjlR+j1JYyX1kfSudDrDSLqPNpMcldzQ/bXUbY8DO0hOuPaTdBZwIbvPMXRcT10N6647gAslvTs99/Gv5PaNqyAc9GYFEBHfA/4J+DrQQlKRXw38Lh3lOpKK+GWSEP91h0l8C5idXmlySUQ0kvTT/wB4CVgGfCqd15vAh4Arga3Ax0n6sTP96A3AN4DfklSshwGX9mBxrgMWAE8CW4D/QZIl/0nSjbIGWAw81oNpdku6bB8A3kdydHEL8MmIeC4d5afAUel6+l067DvA19Nh1/VwfotITvj+imRdvQxsJF2X5UL+4RGzyifpceDHEfGzYrelnEkaSvLmOTkiXixyc7rNFb1ZBZJ0pqSD0q6b6cCxwH3Fblc5knRh2t02hOTS0gXsPmldFhz0ZpXpcOAZkqtd/hn4SESsK26TytY0kpPAa0k+I3BplFlXiLtuzMwqnCt6M7MK56A3M6twDnozswrnoDczq3AOejOzCuegNzOrcA56M7MKVxJfUzxy5MiYMGFCsZthFWLevHmbIqK20PP1dmy5kuttuCSCfsKECTQ2Nha7GVYhJK0sxny9HVuu5HobdteNmVmFc9CbmVU4B71VBUm3StooaWG7YQdIekBSU3q7f7vnvippmaTnJf1DcVptlhv7DHrvIFYhfg68t8Ow64GGiJgMNKSPkXQUyQ9xHJ2+5pb091DNylJ3Kvqf4x3EylxEPEzya0jtTQNmp/dns/s3XacBv4qIN9Ifl1jGnj/FZ5aVN3a28o3fLeTFTTsKMr99Br13EKtgozPf0Z7ejkqH15H81F9GczrsbSTNkNQoqbGlpSWvjbXKcfuTq/nFYytZ89JrBZlfby+v3GMHkdR+B2n/O5F73UGAGQDjx4/vZTMs3yZc/4diN6FTK248P5+T7+zHnzv94YaImAXMAqivr/ePO9g+vbGzlVvmvkD9Iftz2qQDCzLPXJ+M7dEOEhH1EVFfW1vwz7aYAWyQNAYgvd2YDm8GDm433jiSXxcyy9rtjc2s2/Y61547GamzyMy93ga9dxCrBPcA09P704G72w2/VNIASYeS/HzcE0Von1WYN3a28qOHlnHSIftz+qSRBZtvb4PeO4iVFUm3AY8Ch0tqlnQlcCNwnqQm4Lz0MRGxCLgdWEzyg9pfjIjW4rTcKslvGptZu+11rp1auGoeutFHn+4gZwEjJTUD3yTZIW5Pd5ZVwMWQ7CCSMjvITryDWImIiI918dTULsb/NvDt/LXIqs2bO9u45aFlnDh+BO+ZXLhqHroR9N5BzMyy95t5q1m77XW+8+FjC1rNgz8Za2aWd0k1/wInjB/BGQWu5sFBb2aWd3fMa2bN1tcK3jef4aA3M8ujN3e28cOHlnH8wSM48x3FuZTcQW9mlke/nZ9W8wW8br4jB72ZWZ5kqvnjDh7BWUWq5sFBb2aWN3fOb6b5pdf4cpH65jMc9GZmefBWaxs/eGgZx42r4azDi/s1Lw56M7M8yFTzxeybz3DQm5nl2FutbXz/wWUcO66Gsw8fte8X5JmD3swsx+6avyap5ovcN5/hoDczy6G3Wtv4/kNNTKmr4Zwjil/Ng4PezCyn7npqDau3lE41Dw56M7Oceau1jR88uIxj6oYz9cjSqObBQW9mljO/e2oNq7a8ypenvqNkqnlw0JuZ5cTO9Lr5UqvmwUFvZpYTv3t6LSs3v8q1JVbNg4PezCxrO1vb+P6DTRw9djjnllg1Dw56M7Os3b2rmi+dK23ac9CbmWUhU80fNWY45x01utjN6ZSD3swsC/c8s5YVm18tie+06YqD3qqapH+UtEjSQkm3SRoo6QBJD0hqSm/3L3Y7rTTtTL/T5sgxw/kvJVrNg4PeqpikOuBLQH1EHAP0AS4FrgcaImIy0JA+Nnube59dy4ubdpRs33xGVkHvasgqQF9gkKS+wGBgLTANmJ0+Pxu4qDhNs1LW2hZ8v2EZRxw0rKSrecgi6F0NWbmLiDXAd4FVwDpgW0TcD4yOiHXpOOuALq+XkzRDUqOkxpaWlkI020rEvc+sZfmmHXz53Mnst1/pVvOQfdeNqyErW+nR5jTgUGAsMETSx3syjYiYFRH1EVFfW1vcXxGywmltC25+sCmt5g8qdnP2qddBn2015ErISsC5wIsR0RIRbwF3Au8GNkgaA5DebixiG60E/f7ZtSxvSfrmS72ah+y6brKqhlwJWQlYBZwqabCSM2lTgSXAPcD0dJzpwN1Fap+VoNa2YGZDE4ePHsY/HF361TwkXS+9tasaApC0RzUUEetcDVkpi4jHJd0BzAd2Ak8Bs4ChwO2SriR5M7i4eK20UpOp5m+5/MSyqOYhu6DfVQ0Br5FUQ43ADpIq6EZcDVmJi4hvAt/sMPgNku3ZbA+tbcHNaTX/3jKp5iGLoHc1ZGbV5vfPruWFlh388LLyqeYhu4re1ZCZVY3WtuD7Dy7jHaOH8r5jyqeaB38y1sysW/6wYB3LNr7Cl8rkSpv2HPRmZvuQfAq2icmjhvL+Y8YUuzk95qA3M9uHPy5YR1OZVvPgoDcz26u29EqbyaOG8v4p5VfNg4PezGyv/rgwqeavmTqZPmVYzYOD3sysS5lqftKooZxfptU8OOjNzLr0p4XrWbrhFa45Z1LZVvPgoDcz61Smmj+sdggXHDu22M3JioPezKwT9y1az/MbXuZLZdw3n+GgNzPrIFPNT6yAah4c9GZmb/PnRet5bv3LfOmc8q/mwUFvZraHtvT75ieOHMKFx5V/NQ8OejOzPdy/OKnmr5la3lfatOegNzNLJdX8sqSar4C++QwHvZlZ6v7FG1iybjtXnzOJvn0qJx4rZ0nMzLKQ6Zs/dOQQPlAhffMZDnozM+CBJWk1f3ZlVfPgoDczIyKYOaeJCQcOZtrxlVXNg4PezIwHFm9g8brtXHPO5Iqr5sFBb4akEZLukPScpCWS3iXpAEkPSGpKb/cvdjstPyKSvvlKrebBQW8GMBO4LyKOAI4DlgDXAw0RMRloSB9bBZqzZCOL1m7n6gqt5sFBb1VO0nDgDOCnABHxZkRsBaYBs9PRZgMXFaN9ll8RwU1zlnLIgYO5qEKrecgy6H3IaxVgItAC/EzSU5J+ImkIMDoi1gGkt6M6e7GkGZIaJTW2tLQUrtWWEw2Zar4Cr7RpL9sl8yGvlbu+wInAjyLiBGAHPdhmI2JWRNRHRH1tbW2+2mh5EBHc1LCU8QcM5oMn1BW7OXnV66D3Ia9ViGagOSIeTx/fQRL8GySNAUhvNxapfZYnDz63kYVrKu9TsJ3JZul8yGtlLyLWA6slHZ4OmgosBu4BpqfDpgN3F6F5lidJ33wTBx8wqOKrecgu6H3Ia5XiGuCXkp4FjgduAG4EzpPUBJyXPrYK8dDzG1mwZhvXnD2ZfhVezUMS1r3V2SHv9aSHvBGxzoe8Vg4i4mmgvpOnpha4KVYAe1TzJ1Z+NQ9ZVPQ+5DWzcjT3+Raebd7G1WdPqopqHrKr6GH3IW9/YDlwBcmbx+2SrgRWARdnOQ8zs5zIXDc/bv9BfOjEccVuTsFkFfQ+5DWzcjJ3aQvPNG/jxg9NqZpqHvzJWDOrEpm++boR1VXNg4PezKrEX5a28MzqrVx9ziT6962u6KuupTWzqtS+mv9wlVXz4KA3syrwl6UtPL16K188u/qqeXDQm1mFy3zffN2IQXzkpOqr5sFBb2YV7uGmTTy1aitfOPuwqqzmwUFvZhUs+S3YpYytGcjFJx1c7OYUjYPezCrWX5s2MX/VVr5QpX3zGdW75GZW0TJ982NrBnJxfXX2zWc46M2sIj2ybBPzVr7E58+exIC+fYrdnKJy0JtZxUn65psYUzOQS6q8mgcHvZlVoL8t20zjypf4wlmHVX01Dw56M6swSd/8Ug4aPpBL3lm9V9q056A3s4ry9xc28+SKl/jC2a7mMxz0ZlYxMn3zBw0fyCX1ruYzHPRmVjEefWEzT6zYwufPOoyB/VzNZzjozawiRAQ3NTQxevgAPuq++T046M2sIjy6fDNPvLiFz5/par4jB71VPUl9JD0l6ffp4wMkPSCpKb3dv9httH2bOaeJUcMGcOnJ44vdlJLjoDeDa4El7R5fDzRExGSgIX1sJezRFzbz+Ivum++Kg96qmqRxwPnAT9oNngbMTu/PBi4qcLOsh2Y2LGXUsAF8zNV8pxz0Vu1uAr4CtLUbNjoi1gGkt6O6erGkGZIaJTW2tLTktaHWuUdf2Mxjy7fwOffNdynroHf/ppUrSRcAGyNiXm+nERGzIqI+Iupra2tz2DrrrpkNS6kdNoDLTnE135VcVPTu37RydRrwAUkrgF8B50j6v8AGSWMA0tuNxWui7c1jy5Nq3lfa7F1WQe/+TStnEfHViBgXEROAS4EHI+LjwD3A9HS06cDdRWqi7cPMOU2u5rsh24r+JnrZv+m+TSthNwLnSWoCzksfW4l5fPlmHl2+2X3z3dDroM+2f9N9m1ZKImJuRFyQ3t8cEVMjYnJ6u6XY7bO3m9nQxMihA7jc1fw+9c3itZn+zfcDA4Hh7fs3I2Kd+zfNLB+eeHELf39hM18//0hX893Q64re/ZtmViwzG5am1fwhxW5KWcjHdfTu3zSzvHlyxRb+tmwznztzIoP6u5rvjmy6bnaJiLnA3PT+ZmBqLqZrZtbRzDlNjBza39V8D/iTsWZWNhpXbOGRZZv47BmHuZrvAQe9mZWNmQ1NHDikP5ef6ittesJBb2ZlYd7KLfy1aROfPXMig/vnpNe5ajjozaws3DQnqeY/fqr75nvKQW9mJW/eypf4a9MmZpzhar43HPRmVvJmNjRxwJD+fOJdruZ7w0FvZiVt/qqXeHhpi6v5LDjozaykzZyTVvPum+81B72ZlaynVr3EX5a2cNV7JjJkgKv53nLQm1nJmtnQxP6D+/FJ981nxUFvZiXp6dVbmft8C1ed4Wo+Ww56MytJM+csTav5CcVuStlz0JtZyXl69VYeer6Fz7xnIkNdzWfNQW9mJefmhiZGDO7H9HdPKHZTKoKD3sxKyjOrt/Lgcxu5ytV8zjjozaykZKp5X2mTOw56MysZzzZvpeG5jXzm9EMZNrBfsZtTMRz0VtUkHSzpIUlLJC2SdG06/ABJD0hqSm/3L3Zbq8HNDU3UDHLffK456K3a7QT+OSKOBE4FvijpKOB6oCEiJgMN6WPLowXN25izxNV8PjjorapFxLqImJ/efxlYAtQB04DZ6WizgYuK0sAqMjNTzZ82odhNqTgOerOUpAnACcDjwOiIWAfJmwEwqovXzJDUKKmxpaWlYG2tNAvXbGPOkg1cefqhDHc1n3MOejNA0lDgt8CXI2J7d18XEbMioj4i6mtra/PXwAo3s6GJ4QP78ilX83nR66D3SSyrFJL6kYT8LyPiznTwBklj0ufHABuL1b5Kt3DNNh5YvIErT5/oaj5PsqnofRLLyp4kAT8FlkTE99o9dQ8wPb0/Hbi70G2rFje7ms+7Xge9T2JZhTgN+ARwjqSn07/3AzcC50lqAs5LH1uOLVq7jfsXb+DTpx9KzSBX8/mSk88X7+0klqQuT2IBMwDGjx+fi2aY9VhEPAKoi6enFrIt1ejmhiaGDezLFacdWuymVLSsT8b6JJaZ9cbitdv586INfPo0V/P5llXQ+ySWmfVWppr/9Omu5vMtm6tufBLLzHpl8drt3LdoPVe4mi+IbProMyexFkh6Oh32NZKTVrdLuhJYBVycVQvNrOLc3NDEsAF9udJ98wXR66D3SSwz640l65Jq/ktTJ1Mz2NV8IfiTsWZWUK7mC89Bb2YF89z67fxp4XquOG2Cq/kCctCbWcHc3NDE0AG+0qbQ/IOMZpY3O1vbaNr4CgvWbOPZ5q38ccF6rjlnEiMG9y9206qKg97McmJnaxvLWl5hQfM2Fq7ZxrNrtrFk3XZef6sNgCH9+3DOEaP4zOkTi9zS6uOgN7Me29naxgstO1iwJg315q0s7hDqR9fVcPkphzClroYp42o49MAh7LdfVxfqWT456M1sr1rbghfSSn3BmuRv8drtvPZWKwCD+/fhmLE1XHbyIUwZN5wpdSOYONKhXkoc9Ga2S2tbsLwl06eeVOuL2oX6oH59OKZuOJeefDBT6mo4dlwNh44cSh+Heklz0JtVqda24MVNbw/1V9/cHepHjx3OR9+5O9Qn1jrUy5GDPs8mXP+HYjehUytuPL/YTbACamsLlm/akfanZ0J9GzvSUB/Ybz+OHlvDJfUHc0wa6oc51CuGg96swrS1BS9u3h3qmT71V97YCcCAvvtx9NjhfOSkcUwZN4IpdTUcVjuEvn38sZpK5aA3K2NtbcGKzcnVL5mTpYs6hPpRY4fzoRPrdl39Mql2qEO9yjjozcpEW1uwcsuraahvTUJ9zXZebhfqR44ZzgdPqGPKuBqm1NUweZRD3Rz0ZiUpIli5+dVdlzMuaN7GwrXbePn1JNT7p6E+7YSxHFs3gmPqapg8eij9HOrWCQe9WZFFBKt2Veq7r1XfFep99uPIMcP4wHFjOXZcDcfU1fCO0cMc6tZtDnqzAooIVm95Lbmkcc1WFqbhvr1dqB+RhvqUut2h3r+vQ916z0Fv1gVJ7wVmAn2An0TEjT2dxobtrzNv5Uu7LmlcsGYb2157C4B+fcQRBw3ngjTUpzjULU8c9GadkNQH+CFwHtAMPCnpnohY3JPp3DGvmf/55+fp10ccftAw3j9lzO5QP2goA/r2yUfzzfbgoDfr3MnAsohYDiDpV8A0oEdBf9EJdbxn8kgOP2iYQ92KxkFv1rk6YHW7x83AKR1HkjQDmAEwfvz4t09kxCDqRgzKUxPNusedgWad6+yz//G2ARGzIqI+Iupra2sL0CyznnPQm3WuGTi43eNxwNoitcUsK3kLeknvlfS8pGWSrs/XfMzy5ElgsqRDJfUHLgXuKXKbzHolL330ubpiAfztj1YcEbFT0tXAn0kur7w1IhYVuVlmvZKvk7E5uWLBrJgi4o/AH4vdDrNsKeJt55eyn6j0EeC9EfGZ9PEngFMi4up24+y6WgE4HHg+5w15u5HApgLMJ1/c/u45JCIKfmZUUguwspOnyv3/lkteF3vqan3kdBvOV0W/zysWImIWMCtP8++UpMaIqC/kPHPJ7S9tXe2Ylb7cPeF1sadCrY98nYz1FQtmZiUiX0HvKxbMzEpEXrpuSviKhYJ2FeWB21+eqnW5O+N1saeCrI+8nIw1M7PS4U/GmplVOAe9mVmFq4igl+Rv4Syial3/1brctneluF2URdBL+oak5yQ9IOk2SddJmivpBkl/Aa6VNFXSU5IWSLpV0oD0tSskjUzv10uam97/lqRfSHpQUpOkq/Yy/6GSGiTNT6c/rRDLnSuSzpL0+yxeX9T1n47/lXTaz0jq8S899Uaxl7vct7tcynYbzqVibxfp+D3aH0runacjSfXAh4ETSNo7H5iXPj0iIs6UNBBoAqZGxFJJ/wl8HrhpH5M/FjgVGAI8JekPEdHZ9f6vAx+MiO3pP+kxJd/dk5Mz2ZL6RsTOXEwr10ph/Ut6H3ARyaerX5V0QPZLtnelsNzkebvLpVLehnOpFLaL3uwP5VDRnw7cHRGvRcTLwL3tnvt1ens48GJELE0fzwbO6Ma0M9PdBDxE8h09nRFwg6RngTkkP0oxursLUAoVADBc0l2SFkv6saTu/u9LYf2fC/wsIl4FiIgt3Wx7NkphubPa7nKpzLfhXCqF7aLH+0PJV/R0/nUKGTu6Mc5Odr+hDezwXMfKqKtK6XKgFjgpIt6StKKTaXWqFCqA1MnAUSTfxXIf8CHgju4swl6eK9T6116ey5dSWO5eb3e5VAHbcC6VwnbR4/2hHCr6R4ALJQ2UNBTo7PuBnwMmSJqUPv4E8Jf0/grgpPT+hzu8blo63QOBs0g+0duZGmBjurOdDRzSg/aXQgUA8ERELI+IVuC2tF3dUQrr/37g05IGAxSi64bSWO5strtcKvdtOJdKYbvo8f5Q8kEfEU+SfH3CM8CdQCOwrcM4rwNXAL+RtABoA36cPv2vwExJfwVaO0z+CeAPwGPAv+2lkvglUC+pkaTKeq4Hi1AKFUBPx909Ugms/4i4L21Do6Sngeu60/ZslMJyk912l0tlvQ3nUilsF73aHyKi5P+Aoent4HTFnpiDaX4LuK4AbX8nyaHuQGAoydcxXwfMBerTcQYCq4BJ6eOfA9em9+cA70vv/29gbrv2P52+9sD09WO7aMNZwGvAoSQ73J+BD1fD+q/W7S7H66Hst+Fq3y7KoY8eYJako0g2iNkRMb/YDequiHhSUqYCWEkXFYCkTAXQl+SQrX0F8FNJXwMe7zD5TAUwnr1XhgCPAjcCU4CHgbt6sBhlu/6zVK3LvYcK2YZzqey2C3/XTTuSpgC/6DD4jYg4JcvpDo2IV9I+tYeBGdluHJK+BbwSEd/NZjqlJF/rv9SVw3J7Gy68XG4X5VLRF0RELACOz8Oky64CKIY8rv+SVibL7W24wHK5XbiiryDlUBma7Y234fxw0JuZVbiSv7zSzMyy46A3M6twDnozswrnoDczq3D/H80M1Yx0Ov1FAAAAAElFTkSuQmCC", - "text/plain": [ - "
" - ] - }, - "metadata": { - "needs_background": "light" - }, - "output_type": "display_data" - } - ], + "outputs": [], "source": [ "# Create a visualization with pandas df\n", "# Example modified from https://matplotlib.org/stable/tutorials/introductory/pyplot.html#sphx-glr-tutorials-introductory-pyplot-py\n", diff --git a/examples/jupyter/integrations/plotly.ipynb b/examples/jupyter/integrations/plotly.ipynb index 6fd3b5ae185..ea37e589c29 100644 --- a/examples/jupyter/integrations/plotly.ipynb +++ b/examples/jupyter/integrations/plotly.ipynb @@ -16,7 +16,7 @@ }, { "cell_type": "code", - "execution_count": 1, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ @@ -30,30 +30,9 @@ }, { "cell_type": "code", - "execution_count": 2, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "/Users/labanyamukhopadhyay/opt/anaconda3/lib/python3.9/site-packages/modin/error_message.py:108: UserWarning:\n", - "\n", - "Ray execution environment not yet initialized. Initializing...\n", - "To remove this warning, run the following python code before doing dataframe operations:\n", - "\n", - " import ray\n", - " ray.init(runtime_env={'env_vars': {'__MODIN_AUTOIMPORT_PANDAS__': '1'}})\n", - "\n", - "\n", - "2023-04-06 11:28:25,243\tINFO worker.py:1553 -- Started a local Ray instance.\n", - "/Users/labanyamukhopadhyay/opt/anaconda3/lib/python3.9/site-packages/modin/pandas/dataframe.py:170: UserWarning:\n", - "\n", - "Distributing object. This may take some time.\n", - "\n" - ] - } - ], + "outputs": [], "source": [ "modin_df = pd.DataFrame(dict(a=[1,3,2,4], b=[3,2,1,0]))\n", "pandas_df = pandas.DataFrame(dict(a=[1,3,2,4], b=[3,2,1,0]))" @@ -61,129 +40,9 @@ }, { "cell_type": "code", - "execution_count": 3, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "data": { - "text/html": [ - " \n", - " " - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "text/html": [ - "
" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], + "outputs": [], "source": [ "# Create a visualization with Modin df\n", "fig2 = px.bar(modin_df)\n", @@ -193,43 +52,9 @@ }, { "cell_type": "code", - "execution_count": 4, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "data": { - "text/html": [ - "
" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], + "outputs": [], "source": [ "# Create a visualization with pandas df\n", "fig2 = px.bar(pandas_df)\n", @@ -238,43 +63,9 @@ }, { "cell_type": "code", - "execution_count": 5, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "data": { - "text/html": [ - "
" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], + "outputs": [], "source": [ "# Create a visualization with Modin df\n", "fig = px.line(modin_df)\n", @@ -283,43 +74,9 @@ }, { "cell_type": "code", - "execution_count": 6, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "data": { - "text/html": [ - "
" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], + "outputs": [], "source": [ "# Create a visualization with pandas df\n", "fig = px.line(pandas_df)\n", @@ -328,43 +85,9 @@ }, { "cell_type": "code", - "execution_count": 7, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "data": { - "text/html": [ - "
" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], + "outputs": [], "source": [ "# Create a visualization with Modin df\n", "fig = px.area(modin_df)\n", @@ -373,43 +96,9 @@ }, { "cell_type": "code", - "execution_count": 8, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "data": { - "text/html": [ - "
" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], + "outputs": [], "source": [ "# Create a visualization with pandas df\n", "fig = px.area(pandas_df)\n", @@ -418,43 +107,9 @@ }, { "cell_type": "code", - "execution_count": 9, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "data": { - "text/html": [ - "
" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], + "outputs": [], "source": [ "# Create a visualization with Modin df\n", "fig = px.area(modin_df)\n", @@ -463,43 +118,9 @@ }, { "cell_type": "code", - "execution_count": 10, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "data": { - "text/html": [ - "
" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], + "outputs": [], "source": [ "# Create a visualization with pandas df\n", "fig = px.area(pandas_df)\n", @@ -508,43 +129,9 @@ }, { "cell_type": "code", - "execution_count": 11, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "data": { - "text/html": [ - "
" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], + "outputs": [], "source": [ "# Create a visualization with Modin df\n", "fig = px.violin(modin_df)\n", @@ -553,43 +140,9 @@ }, { "cell_type": "code", - "execution_count": 12, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "data": { - "text/html": [ - "
" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], + "outputs": [], "source": [ "# Create a visualization with pandas df\n", "fig = px.violin(pandas_df)\n", @@ -598,43 +151,9 @@ }, { "cell_type": "code", - "execution_count": 13, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "data": { - "text/html": [ - "
" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], + "outputs": [], "source": [ "# Create a visualization with Modin df\n", "fig = px.box(modin_df)\n", @@ -643,43 +162,9 @@ }, { "cell_type": "code", - "execution_count": 14, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "data": { - "text/html": [ - "
" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], + "outputs": [], "source": [ "# Create a visualization with pandas df\n", "fig = px.box(pandas_df)\n", @@ -688,43 +173,9 @@ }, { "cell_type": "code", - "execution_count": 15, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "data": { - "text/html": [ - "
" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], + "outputs": [], "source": [ "# Create a visualization with Modin df\n", "fig = px.histogram(modin_df, opacity=0.5, orientation='h', nbins=5)\n", @@ -733,43 +184,9 @@ }, { "cell_type": "code", - "execution_count": 16, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "data": { - "text/html": [ - "
" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], + "outputs": [], "source": [ "# Create a visualization with pandas df\n", "fig = px.histogram(pandas_df, opacity=0.5, orientation='h', nbins=5)\n", @@ -778,25 +195,9 @@ }, { "cell_type": "code", - "execution_count": 19, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "ename": "ValueError", - "evalue": "Value of 'locations' is not the name of a column in 'data_frame'. Expected one of [0, 1] but received: fips", - "output_type": "error", - "traceback": [ - "\u001b[0;31m---------------------------------------------------------------------------\u001b[0m", - "\u001b[0;31mValueError\u001b[0m Traceback (most recent call last)", - "\u001b[0;32m/var/folders/qj/jybppsbd2jl75s8y2q8s2xx80000gn/T/ipykernel_5361/4179859770.py\u001b[0m in \u001b[0;36m\u001b[0;34m\u001b[0m\n\u001b[1;32m 8\u001b[0m modin_df = pd.read_csv(\"https://raw.githubusercontent.com/plotly/datasets/master/fips-unemp-16.csv\",\n\u001b[1;32m 9\u001b[0m dtype={\"fips\": str})\n\u001b[0;32m---> 10\u001b[0;31m fig = px.choropleth(modin_df, geojson=counties, locations='fips', color='unemp',\n\u001b[0m\u001b[1;32m 11\u001b[0m \u001b[0mcolor_continuous_scale\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0;34m\"Viridis\"\u001b[0m\u001b[0;34m,\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 12\u001b[0m \u001b[0mrange_color\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;36m0\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0;36m12\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m,\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n", - "\u001b[0;32m~/opt/anaconda3/lib/python3.9/site-packages/plotly/express/_chart_types.py\u001b[0m in \u001b[0;36mchoropleth\u001b[0;34m(data_frame, lat, lon, locations, locationmode, geojson, featureidkey, color, facet_row, facet_col, facet_col_wrap, facet_row_spacing, facet_col_spacing, hover_name, hover_data, custom_data, animation_frame, animation_group, category_orders, labels, color_discrete_sequence, color_discrete_map, color_continuous_scale, range_color, color_continuous_midpoint, projection, scope, center, fitbounds, basemap_visible, title, template, width, height)\u001b[0m\n\u001b[1;32m 1075\u001b[0m \u001b[0mcolored\u001b[0m \u001b[0mregion\u001b[0m \u001b[0mmark\u001b[0m \u001b[0mon\u001b[0m \u001b[0ma\u001b[0m \u001b[0mmap\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 1076\u001b[0m \"\"\"\n\u001b[0;32m-> 1077\u001b[0;31m return make_figure(\n\u001b[0m\u001b[1;32m 1078\u001b[0m \u001b[0margs\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0mlocals\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m,\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 1079\u001b[0m \u001b[0mconstructor\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0mgo\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mChoropleth\u001b[0m\u001b[0;34m,\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n", - "\u001b[0;32m~/opt/anaconda3/lib/python3.9/site-packages/plotly/express/_core.py\u001b[0m in \u001b[0;36mmake_figure\u001b[0;34m(args, constructor, trace_patch, layout_patch)\u001b[0m\n\u001b[1;32m 1943\u001b[0m \u001b[0mapply_default_cascade\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0margs\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 1944\u001b[0m \u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m-> 1945\u001b[0;31m \u001b[0margs\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mbuild_dataframe\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0margs\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mconstructor\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m\u001b[1;32m 1946\u001b[0m \u001b[0;32mif\u001b[0m \u001b[0mconstructor\u001b[0m \u001b[0;32min\u001b[0m \u001b[0;34m[\u001b[0m\u001b[0mgo\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mTreemap\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mgo\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mSunburst\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mgo\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mIcicle\u001b[0m\u001b[0;34m]\u001b[0m \u001b[0;32mand\u001b[0m \u001b[0margs\u001b[0m\u001b[0;34m[\u001b[0m\u001b[0;34m\"path\"\u001b[0m\u001b[0;34m]\u001b[0m \u001b[0;32mis\u001b[0m \u001b[0;32mnot\u001b[0m \u001b[0;32mNone\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 1947\u001b[0m \u001b[0margs\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mprocess_dataframe_hierarchy\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0margs\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n", - "\u001b[0;32m~/opt/anaconda3/lib/python3.9/site-packages/plotly/express/_core.py\u001b[0m in \u001b[0;36mbuild_dataframe\u001b[0;34m(args, constructor)\u001b[0m\n\u001b[1;32m 1403\u001b[0m \u001b[0;31m# now that things have been prepped, we do the systematic rewriting of `args`\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 1404\u001b[0m \u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m-> 1405\u001b[0;31m df_output, wide_id_vars = process_args_into_dataframe(\n\u001b[0m\u001b[1;32m 1406\u001b[0m \u001b[0margs\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mwide_mode\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mvar_name\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mvalue_name\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 1407\u001b[0m )\n", - "\u001b[0;32m~/opt/anaconda3/lib/python3.9/site-packages/plotly/express/_core.py\u001b[0m in \u001b[0;36mprocess_args_into_dataframe\u001b[0;34m(args, wide_mode, var_name, value_name)\u001b[0m\n\u001b[1;32m 1205\u001b[0m \u001b[0;32mif\u001b[0m \u001b[0margument\u001b[0m \u001b[0;34m==\u001b[0m \u001b[0;34m\"index\"\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 1206\u001b[0m \u001b[0merr_msg\u001b[0m \u001b[0;34m+=\u001b[0m \u001b[0;34m\"\\n To use the index, pass it in directly as `df.index`.\"\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m-> 1207\u001b[0;31m \u001b[0;32mraise\u001b[0m \u001b[0mValueError\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0merr_msg\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m\u001b[1;32m 1208\u001b[0m \u001b[0;32melif\u001b[0m \u001b[0mlength\u001b[0m \u001b[0;32mand\u001b[0m \u001b[0mlen\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mdf_input\u001b[0m\u001b[0;34m[\u001b[0m\u001b[0margument\u001b[0m\u001b[0;34m]\u001b[0m\u001b[0;34m)\u001b[0m \u001b[0;34m!=\u001b[0m \u001b[0mlength\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 1209\u001b[0m raise ValueError(\n", - "\u001b[0;31mValueError\u001b[0m: Value of 'locations' is not the name of a column in 'data_frame'. Expected one of [0, 1] but received: fips" - ] - } - ], + "outputs": [], "source": [ "# Create a visualization with Modin df\n", "# Example from https://plotly.com/python/mapbox-county-choropleth/#choropleth-map-using-plotlyexpress-and-carto-base-map-no-token-needed\n", @@ -819,43 +220,9 @@ }, { "cell_type": "code", - "execution_count": 20, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "data": { - "text/html": [ - "
" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], + "outputs": [], "source": [ "# Create a visualization with pandas df\n", "# Example from https://plotly.com/python/mapbox-county-choropleth/#choropleth-map-using-plotlyexpress-and-carto-base-map-no-token-needed\n", diff --git a/examples/jupyter/integrations/sklearn.ipynb b/examples/jupyter/integrations/sklearn.ipynb index 41b305e5c85..7088c92ebb1 100644 --- a/examples/jupyter/integrations/sklearn.ipynb +++ b/examples/jupyter/integrations/sklearn.ipynb @@ -16,7 +16,7 @@ }, { "cell_type": "code", - "execution_count": 1, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ @@ -26,23 +26,9 @@ }, { "cell_type": "code", - "execution_count": 2, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "UserWarning: Ray execution environment not yet initialized. Initializing...\n", - "To remove this warning, run the following python code before doing dataframe operations:\n", - "\n", - " import ray\n", - " ray.init(runtime_env={'env_vars': {'__MODIN_AUTOIMPORT_PANDAS__': '1'}})\n", - "\n", - "2023-01-03 11:03:39,350\tINFO worker.py:1529 -- Started a local Ray instance. View the dashboard at \u001b[1m\u001b[32m127.0.0.1:8266 \u001b[39m\u001b[22m\n" - ] - } - ], + "outputs": [], "source": [ "# From https://www.ritchieng.com/pandas-scikit-learn/\n", "\n", @@ -52,159 +38,16 @@ }, { "cell_type": "code", - "execution_count": 3, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "data": { - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
PassengerIdSurvivedPclassNameSexAgeSibSpParchTicketFareCabinEmbarked
0103Braund, Mr. Owen Harrismale22.010A/5 211717.2500NaNS
1211Cumings, Mrs. John Bradley (Florence Briggs Th...female38.010PC 1759971.2833C85C
2313Heikkinen, Miss. Lainafemale26.000STON/O2. 31012827.9250NaNS
3411Futrelle, Mrs. Jacques Heath (Lily May Peel)female35.01011380353.1000C123S
4503Allen, Mr. William Henrymale35.0003734508.0500NaNS
\n", - "
" - ], - "text/plain": [ - " PassengerId Survived Pclass \\\n", - "0 1 0 3 \n", - "1 2 1 1 \n", - "2 3 1 3 \n", - "3 4 1 1 \n", - "4 5 0 3 \n", - "\n", - " Name Sex Age SibSp \\\n", - "0 Braund, Mr. Owen Harris male 22.0 1 \n", - "1 Cumings, Mrs. John Bradley (Florence Briggs Th... female 38.0 1 \n", - "2 Heikkinen, Miss. Laina female 26.0 0 \n", - "3 Futrelle, Mrs. Jacques Heath (Lily May Peel) female 35.0 1 \n", - "4 Allen, Mr. William Henry male 35.0 0 \n", - "\n", - " Parch Ticket Fare Cabin Embarked \n", - "0 0 A/5 21171 7.2500 NaN S \n", - "1 0 PC 17599 71.2833 C85 C \n", - "2 0 STON/O2. 3101282 7.9250 NaN S \n", - "3 0 113803 53.1000 C123 S \n", - "4 0 373450 8.0500 NaN S " - ] - }, - "execution_count": 3, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "train.head()" ] }, { "cell_type": "code", - "execution_count": 4, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ @@ -215,7 +58,7 @@ }, { "cell_type": "code", - "execution_count": 5, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ @@ -225,7 +68,7 @@ }, { "cell_type": "code", - "execution_count": 6, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ @@ -235,20 +78,9 @@ }, { "cell_type": "code", - "execution_count": 7, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "LogisticRegression()" - ] - }, - "execution_count": 7, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "# 1. import\n", "from sklearn.linear_model import LogisticRegression\n", @@ -262,7 +94,7 @@ }, { "cell_type": "code", - "execution_count": 8, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ @@ -272,139 +104,9 @@ }, { "cell_type": "code", - "execution_count": 9, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "data": { - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
PassengerIdPclassNameSexAgeSibSpParchTicketFareCabinEmbarked
08923Kelly, Mr. Jamesmale34.5003309117.8292NaNQ
18933Wilkes, Mrs. James (Ellen Needs)female47.0103632727.0000NaNS
28942Myles, Mr. Thomas Francismale62.0002402769.6875NaNQ
38953Wirz, Mr. Albertmale27.0003151548.6625NaNS
48963Hirvonen, Mrs. Alexander (Helga E Lindqvist)female22.011310129812.2875NaNS
\n", - "
" - ], - "text/plain": [ - " PassengerId Pclass Name Sex \\\n", - "0 892 3 Kelly, Mr. James male \n", - "1 893 3 Wilkes, Mrs. James (Ellen Needs) female \n", - "2 894 2 Myles, Mr. Thomas Francis male \n", - "3 895 3 Wirz, Mr. Albert male \n", - "4 896 3 Hirvonen, Mrs. Alexander (Helga E Lindqvist) female \n", - "\n", - " Age SibSp Parch Ticket Fare Cabin Embarked \n", - "0 34.5 0 0 330911 7.8292 NaN Q \n", - "1 47.0 1 0 363272 7.0000 NaN S \n", - "2 62.0 0 0 240276 9.6875 NaN Q \n", - "3 27.0 0 0 315154 8.6625 NaN S \n", - "4 22.0 1 1 3101298 12.2875 NaN S " - ] - }, - "execution_count": 9, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "# missing Survived column because we are predicting\n", "test.head()" @@ -412,7 +114,7 @@ }, { "cell_type": "code", - "execution_count": 10, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ @@ -421,7 +123,7 @@ }, { "cell_type": "code", - "execution_count": 11, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ @@ -431,17 +133,9 @@ }, { "cell_type": "code", - "execution_count": 12, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "UserWarning: Distributing object. This may take some time.\n" - ] - } - ], + "outputs": [], "source": [ "# kaggle wants 2 columns\n", "# new_pred_class\n", @@ -455,18 +149,9 @@ }, { "cell_type": "code", - "execution_count": 13, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "UserWarning: `to_pickle` is not currently supported by PandasOnRay, defaulting to pandas implementation.\n", - "Please refer to https://modin.readthedocs.io/en/stable/supported_apis/defaulting_to_pandas.html for explanation.\n" - ] - } - ], + "outputs": [], "source": [ "# save train data to disk using pickle\n", "train.to_pickle('train.pkl')" @@ -474,270 +159,9 @@ }, { "cell_type": "code", - "execution_count": 14, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "UserWarning: `read_pickle` is not currently supported by PandasOnRay, defaulting to pandas implementation.\n" - ] - }, - { - "data": { - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
PassengerIdSurvivedPclassNameSexAgeSibSpParchTicketFareCabinEmbarked
0103Braund, Mr. Owen Harrismale22.010A/5 211717.2500NaNS
1211Cumings, Mrs. John Bradley (Florence Briggs Th...female38.010PC 1759971.2833C85C
2313Heikkinen, Miss. Lainafemale26.000STON/O2. 31012827.9250NaNS
3411Futrelle, Mrs. Jacques Heath (Lily May Peel)female35.01011380353.1000C123S
4503Allen, Mr. William Henrymale35.0003734508.0500NaNS
.......................................
88688702Montvila, Rev. Juozasmale27.00021153613.0000NaNS
88788811Graham, Miss. Margaret Edithfemale19.00011205330.0000B42S
88888903Johnston, Miss. Catherine Helen \"Carrie\"femaleNaN12W./C. 660723.4500NaNS
88989011Behr, Mr. Karl Howellmale26.00011136930.0000C148C
89089103Dooley, Mr. Patrickmale32.0003703767.7500NaNQ
\n", - "

891 rows x 12 columns

\n", - "
" - ], - "text/plain": [ - " PassengerId Survived Pclass \\\n", - "0 1 0 3 \n", - "1 2 1 1 \n", - "2 3 1 3 \n", - "3 4 1 1 \n", - "4 5 0 3 \n", - ".. ... ... ... \n", - "886 887 0 2 \n", - "887 888 1 1 \n", - "888 889 0 3 \n", - "889 890 1 1 \n", - "890 891 0 3 \n", - "\n", - " Name Sex Age SibSp \\\n", - "0 Braund, Mr. Owen Harris male 22.0 1 \n", - "1 Cumings, Mrs. John Bradley (Florence Briggs Th... female 38.0 1 \n", - "2 Heikkinen, Miss. Laina female 26.0 0 \n", - "3 Futrelle, Mrs. Jacques Heath (Lily May Peel) female 35.0 1 \n", - "4 Allen, Mr. William Henry male 35.0 0 \n", - ".. ... ... ... ... \n", - "886 Montvila, Rev. Juozas male 27.0 0 \n", - "887 Graham, Miss. Margaret Edith female 19.0 0 \n", - "888 Johnston, Miss. Catherine Helen \"Carrie\" female NaN 1 \n", - "889 Behr, Mr. Karl Howell male 26.0 0 \n", - "890 Dooley, Mr. Patrick male 32.0 0 \n", - "\n", - " Parch Ticket Fare Cabin Embarked \n", - "0 0 A/5 21171 7.2500 NaN S \n", - "1 0 PC 17599 71.2833 C85 C \n", - "2 0 STON/O2. 3101282 7.9250 NaN S \n", - "3 0 113803 53.1000 C123 S \n", - "4 0 373450 8.0500 NaN S \n", - ".. ... ... ... ... ... \n", - "886 0 211536 13.0000 NaN S \n", - "887 0 112053 30.0000 B42 S \n", - "888 2 W./C. 6607 23.4500 NaN S \n", - "889 0 111369 30.0000 C148 C \n", - "890 0 370376 7.7500 NaN Q \n", - "\n", - "[891 rows x 12 columns]" - ] - }, - "execution_count": 14, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "# read data\n", "pd.read_pickle('train.pkl')" @@ -745,28 +169,9 @@ }, { "cell_type": "code", - "execution_count": 15, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "UserWarning: Distributing object. This may take some time.\n" - ] - }, - { - "data": { - "text/plain": [ - "array([[0. , 1. , 0.5, 0.5],\n", - " [0.5, 0.5, 0. , 1. ]])" - ] - }, - "execution_count": 15, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "# From https://scikit-learn.org/stable/modules/generated/sklearn.compose.ColumnTransformer.html\n", "\n", @@ -786,17 +191,9 @@ }, { "cell_type": "code", - "execution_count": 16, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "UserWarning: Distributing object. This may take some time.\n" - ] - } - ], + "outputs": [], "source": [ "from sklearn.feature_extraction import FeatureHasher\n", "from sklearn.preprocessing import MinMaxScaler\n", @@ -812,27 +209,9 @@ }, { "cell_type": "code", - "execution_count": 17, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "[[ 7. 2. 3. ]\n", - " [ 4. 3.5 6. ]\n", - " [10. 3.5 9. ]]\n" - ] - }, - { - "name": "stderr", - "output_type": "stream", - "text": [ - "UserWarning: Distributing object. This may take some time.\n", - "UserWarning: Distributing object. This may take some time.\n" - ] - } - ], + "outputs": [], "source": [ "# From https://scikit-learn.org/stable/modules/generated/sklearn.impute.SimpleImputer.html\n", "\n", @@ -847,28 +226,9 @@ }, { "cell_type": "code", - "execution_count": 18, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "UserWarning: Distributing object. This may take some time.\n", - "UserWarning: Distributing object. This may take some time.\n" - ] - }, - { - "data": { - "text/plain": [ - "[0, 1, 2, 3, 4]" - ] - }, - "execution_count": 18, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "# From https://scikit-learn.org/stable/modules/generated/sklearn.model_selection.train_test_split.html\n", "\n", @@ -881,7 +241,7 @@ }, { "cell_type": "code", - "execution_count": 19, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ @@ -891,46 +251,18 @@ }, { "cell_type": "code", - "execution_count": 20, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "modin.pandas.dataframe.DataFrame" - ] - }, - "execution_count": 20, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "type(X_train)" ] }, { "cell_type": "code", - "execution_count": 22, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "[0 0\n", - " 1 1\n", - " 2 2\n", - " dtype: int64,\n", - " 3 3\n", - " 4 4\n", - " dtype: int64]" - ] - }, - "execution_count": 22, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "train_test_split(y, shuffle=False)" ] @@ -944,7 +276,7 @@ }, { "cell_type": "code", - "execution_count": 23, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ @@ -953,17 +285,9 @@ }, { "cell_type": "code", - "execution_count": 24, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "UserWarning: Distributing object. This may take some time.\n" - ] - } - ], + "outputs": [], "source": [ "tips = sns.load_dataset(\"tips\")\n", "tips = pd.DataFrame(tips)" @@ -971,221 +295,16 @@ }, { "cell_type": "code", - "execution_count": 25, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "data": { - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
total_billtipsizesex_Femalesmoker_Noday_Friday_Satday_Suntime_Dinner
016.991.012110011
110.341.663010011
221.013.503010011
323.683.312010011
424.593.614110011
..............................
23929.035.923010101
24027.182.002100101
24122.672.002000101
24217.821.752010101
24318.783.002110001
\n", - "

244 rows x 9 columns

\n", - "
" - ], - "text/plain": [ - " total_bill tip size sex_Female smoker_No day_Fri day_Sat day_Sun \\\n", - "0 16.99 1.01 2 1 1 0 0 1 \n", - "1 10.34 1.66 3 0 1 0 0 1 \n", - "2 21.01 3.50 3 0 1 0 0 1 \n", - "3 23.68 3.31 2 0 1 0 0 1 \n", - "4 24.59 3.61 4 1 1 0 0 1 \n", - ".. ... ... ... ... ... ... ... ... \n", - "239 29.03 5.92 3 0 1 0 1 0 \n", - "240 27.18 2.00 2 1 0 0 1 0 \n", - "241 22.67 2.00 2 0 0 0 1 0 \n", - "242 17.82 1.75 2 0 1 0 1 0 \n", - "243 18.78 3.00 2 1 1 0 0 0 \n", - "\n", - " time_Dinner \n", - "0 1 \n", - "1 1 \n", - "2 1 \n", - "3 1 \n", - "4 1 \n", - ".. ... \n", - "239 1 \n", - "240 1 \n", - "241 1 \n", - "242 1 \n", - "243 1 \n", - "\n", - "[244 rows x 9 columns]" - ] - }, - "execution_count": 25, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "pd.get_dummies(tips, drop_first=True)" ] }, { "cell_type": "code", - "execution_count": 26, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ @@ -1194,7 +313,7 @@ }, { "cell_type": "code", - "execution_count": 27, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ @@ -1204,20 +323,9 @@ }, { "cell_type": "code", - "execution_count": 28, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "LinearRegression()" - ] - }, - "execution_count": 28, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "# 2. fit the model object\n", "lr.fit(X=tips[[\"total_bill\", \"size\"]], y=tips[\"tip\"])" @@ -1225,20 +333,9 @@ }, { "cell_type": "code", - "execution_count": 29, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "array([0.09271334, 0.19259779])" - ] - }, - "execution_count": 29, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "# look at the coefficients\n", "lr.coef_" @@ -1246,20 +343,9 @@ }, { "cell_type": "code", - "execution_count": 30, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "0.6689447408125027" - ] - }, - "execution_count": 30, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "# look at the intercept\n", "lr.intercept_" @@ -1267,129 +353,9 @@ }, { "cell_type": "code", - "execution_count": 31, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "data": { - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
tiptotal_billsmoker_No
01.0116.991
11.6610.341
23.5021.011
33.3123.681
43.6124.591
............
2395.9229.031
2402.0027.180
2412.0022.670
2421.7517.821
2433.0018.781
\n", - "

244 rows x 3 columns

\n", - "
" - ], - "text/plain": [ - " tip total_bill smoker_No\n", - "0 1.01 16.99 1\n", - "1 1.66 10.34 1\n", - "2 3.50 21.01 1\n", - "3 3.31 23.68 1\n", - "4 3.61 24.59 1\n", - ".. ... ... ...\n", - "239 5.92 29.03 1\n", - "240 2.00 27.18 0\n", - "241 2.00 22.67 0\n", - "242 1.75 17.82 1\n", - "243 3.00 18.78 1\n", - "\n", - "[244 rows x 3 columns]" - ] - }, - "execution_count": 31, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "tips_dummy = pd.get_dummies(tips, drop_first=True)[[\"tip\", \"total_bill\", \"smoker_No\"]]\n", "tips_dummy" @@ -1397,20 +363,9 @@ }, { "cell_type": "code", - "execution_count": 32, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "LinearRegression()" - ] - }, - "execution_count": 32, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "lr2 = linear_model.LinearRegression()\n", "lr2.fit(X=tips_dummy.iloc[:, 1:], y=tips_dummy[\"tip\"])" @@ -1418,98 +373,18 @@ }, { "cell_type": "code", - "execution_count": 33, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "(array([0.10572239, 0.14892431]), 0.8142993000217928)" - ] - }, - "execution_count": 33, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "lr2.coef_, lr2.intercept_" ] }, { "cell_type": "code", - "execution_count": 34, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "data": { - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
total_billsmoker_No
23929.031
24027.180
24122.670
24217.821
24318.781
\n", - "
" - ], - "text/plain": [ - " total_bill smoker_No\n", - "239 29.03 1\n", - "240 27.18 0\n", - "241 22.67 0\n", - "242 17.82 1\n", - "243 18.78 1" - ] - }, - "execution_count": 34, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "new_data = tips_dummy[[\"total_bill\", \"smoker_No\"]].tail() # not really new data\n", "new_data" @@ -1517,7 +392,7 @@ }, { "cell_type": "code", - "execution_count": 35, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ @@ -1527,104 +402,18 @@ }, { "cell_type": "code", - "execution_count": 36, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "data": { - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
total_billsmoker_Nopredicted_tips
23929.0314.032345
24027.1803.687834
24122.6703.211026
24217.8212.847197
24318.7812.948690
\n", - "
" - ], - "text/plain": [ - " total_bill smoker_No predicted_tips\n", - "239 29.03 1 4.032345\n", - "240 27.18 0 3.687834\n", - "241 22.67 0 3.211026\n", - "242 17.82 1 2.847197\n", - "243 18.78 1 2.948690" - ] - }, - "execution_count": 36, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "new_data" ] }, { "cell_type": "code", - "execution_count": 38, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "modin.pandas.dataframe.DataFrame" - ] - }, - "execution_count": 38, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "type(new_data)" ] diff --git a/examples/jupyter/integrations/statsmodels.ipynb b/examples/jupyter/integrations/statsmodels.ipynb index 27d4038e59d..51bf90136a5 100644 --- a/examples/jupyter/integrations/statsmodels.ipynb +++ b/examples/jupyter/integrations/statsmodels.ipynb @@ -10,20 +10,9 @@ }, { "cell_type": "code", - "execution_count": 1, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "/Users/labanyamukhopadhyay/opt/anaconda3/lib/python3.9/site-packages/statsmodels/tsa/base/tsa_model.py:7: FutureWarning: pandas.Int64Index is deprecated and will be removed from pandas in a future version. Use pandas.Index with the appropriate dtype instead.\n", - " from pandas import (to_datetime, Int64Index, DatetimeIndex, Period,\n", - "/Users/labanyamukhopadhyay/opt/anaconda3/lib/python3.9/site-packages/statsmodels/tsa/base/tsa_model.py:7: FutureWarning: pandas.Float64Index is deprecated and will be removed from pandas in a future version. Use pandas.Index with the appropriate dtype instead.\n", - " from pandas import (to_datetime, Int64Index, DatetimeIndex, Period,\n" - ] - } - ], + "outputs": [], "source": [ "import statsmodels.api as sm\n", "import pandas\n", @@ -40,24 +29,9 @@ }, { "cell_type": "code", - "execution_count": 2, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "UserWarning: Ray execution environment not yet initialized. Initializing...\n", - "To remove this warning, run the following python code before doing dataframe operations:\n", - "\n", - " import ray\n", - " ray.init(runtime_env={'env_vars': {'__MODIN_AUTOIMPORT_PANDAS__': '1'}})\n", - "\n", - "2023-04-06 11:48:00,894\tINFO worker.py:1553 -- Started a local Ray instance.\n", - "UserWarning: Distributing object. This may take some time.\n" - ] - } - ], + "outputs": [], "source": [ "df = sm.datasets.get_rdataset(\"Guerry\", \"HistData\").data\n", "modin_df = pd.DataFrame(df)" @@ -65,96 +39,9 @@ }, { "cell_type": "code", - "execution_count": 3, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "data": { - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
DepartmentLotteryLiteracyWealthRegion
81Vienne402568W
82Haute-Vienne551367C
83Vosges146282E
84Yonne514730C
85Corse834937NaN
\n", - "
" - ], - "text/plain": [ - " Department Lottery Literacy Wealth Region\n", - "81 Vienne 40 25 68 W\n", - "82 Haute-Vienne 55 13 67 C\n", - "83 Vosges 14 62 82 E\n", - "84 Yonne 51 47 30 C\n", - "85 Corse 83 49 37 NaN" - ] - }, - "execution_count": 3, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "vars = ['Department', 'Lottery', 'Literacy', 'Wealth', 'Region']\n", "\n", @@ -165,96 +52,9 @@ }, { "cell_type": "code", - "execution_count": 4, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "data": { - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
DepartmentLotteryLiteracyWealthRegion
80Vendee682856W
81Vienne402568W
82Haute-Vienne551367C
83Vosges146282E
84Yonne514730C
\n", - "
" - ], - "text/plain": [ - " Department Lottery Literacy Wealth Region\n", - "80 Vendee 68 28 56 W\n", - "81 Vienne 40 25 68 W\n", - "82 Haute-Vienne 55 13 67 C\n", - "83 Vosges 14 62 82 E\n", - "84 Yonne 51 47 30 C" - ] - }, - "execution_count": 4, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "modin_df = modin_df.dropna()\n", "\n", @@ -263,7 +63,7 @@ }, { "cell_type": "code", - "execution_count": 5, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ @@ -272,17 +72,9 @@ }, { "cell_type": "code", - "execution_count": 6, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "UserWarning: Distributing object. This may take some time.\n" - ] - } - ], + "outputs": [], "source": [ "y = pd.DataFrame(y)\n", "X = pd.DataFrame(X)" @@ -290,50 +82,18 @@ }, { "cell_type": "code", - "execution_count": 7, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "ename": "ValueError", - "evalue": "unrecognized data structures: / ", - "output_type": "error", - "traceback": [ - "\u001b[0;31m---------------------------------------------------------------------------\u001b[0m", - "\u001b[0;31mValueError\u001b[0m Traceback (most recent call last)", - "\u001b[0;32m/var/folders/qj/jybppsbd2jl75s8y2q8s2xx80000gn/T/ipykernel_5691/1699330070.py\u001b[0m in \u001b[0;36m\u001b[0;34m\u001b[0m\n\u001b[0;32m----> 1\u001b[0;31m \u001b[0mmod\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0msm\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mOLS\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0my\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mX\u001b[0m\u001b[0;34m)\u001b[0m \u001b[0;31m# Describe model\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m", - "\u001b[0;32m~/opt/anaconda3/lib/python3.9/site-packages/statsmodels/regression/linear_model.py\u001b[0m in \u001b[0;36m__init__\u001b[0;34m(self, endog, exog, missing, hasconst, **kwargs)\u001b[0m\n\u001b[1;32m 870\u001b[0m def __init__(self, endog, exog=None, missing='none', hasconst=None,\n\u001b[1;32m 871\u001b[0m **kwargs):\n\u001b[0;32m--> 872\u001b[0;31m super(OLS, self).__init__(endog, exog, missing=missing,\n\u001b[0m\u001b[1;32m 873\u001b[0m hasconst=hasconst, **kwargs)\n\u001b[1;32m 874\u001b[0m \u001b[0;32mif\u001b[0m \u001b[0;34m\"weights\"\u001b[0m \u001b[0;32min\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0m_init_keys\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n", - "\u001b[0;32m~/opt/anaconda3/lib/python3.9/site-packages/statsmodels/regression/linear_model.py\u001b[0m in \u001b[0;36m__init__\u001b[0;34m(self, endog, exog, weights, missing, hasconst, **kwargs)\u001b[0m\n\u001b[1;32m 701\u001b[0m \u001b[0;32melse\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 702\u001b[0m \u001b[0mweights\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mweights\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0msqueeze\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m--> 703\u001b[0;31m super(WLS, self).__init__(endog, exog, missing=missing,\n\u001b[0m\u001b[1;32m 704\u001b[0m weights=weights, hasconst=hasconst, **kwargs)\n\u001b[1;32m 705\u001b[0m \u001b[0mnobs\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mexog\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mshape\u001b[0m\u001b[0;34m[\u001b[0m\u001b[0;36m0\u001b[0m\u001b[0;34m]\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n", - "\u001b[0;32m~/opt/anaconda3/lib/python3.9/site-packages/statsmodels/regression/linear_model.py\u001b[0m in \u001b[0;36m__init__\u001b[0;34m(self, endog, exog, **kwargs)\u001b[0m\n\u001b[1;32m 188\u001b[0m \"\"\"\n\u001b[1;32m 189\u001b[0m \u001b[0;32mdef\u001b[0m \u001b[0m__init__\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mself\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mendog\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mexog\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0;34m**\u001b[0m\u001b[0mkwargs\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m--> 190\u001b[0;31m \u001b[0msuper\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mRegressionModel\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0m__init__\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mendog\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mexog\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0;34m**\u001b[0m\u001b[0mkwargs\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m\u001b[1;32m 191\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0m_data_attr\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mextend\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m[\u001b[0m\u001b[0;34m'pinv_wexog'\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0;34m'weights'\u001b[0m\u001b[0;34m]\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 192\u001b[0m \u001b[0;34m\u001b[0m\u001b[0m\n", - "\u001b[0;32m~/opt/anaconda3/lib/python3.9/site-packages/statsmodels/base/model.py\u001b[0m in \u001b[0;36m__init__\u001b[0;34m(self, endog, exog, **kwargs)\u001b[0m\n\u001b[1;32m 235\u001b[0m \u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 236\u001b[0m \u001b[0;32mdef\u001b[0m \u001b[0m__init__\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mself\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mendog\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mexog\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0;32mNone\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0;34m**\u001b[0m\u001b[0mkwargs\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m--> 237\u001b[0;31m \u001b[0msuper\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mLikelihoodModel\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0m__init__\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mendog\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mexog\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0;34m**\u001b[0m\u001b[0mkwargs\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m\u001b[1;32m 238\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0minitialize\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 239\u001b[0m \u001b[0;34m\u001b[0m\u001b[0m\n", - "\u001b[0;32m~/opt/anaconda3/lib/python3.9/site-packages/statsmodels/base/model.py\u001b[0m in \u001b[0;36m__init__\u001b[0;34m(self, endog, exog, **kwargs)\u001b[0m\n\u001b[1;32m 75\u001b[0m \u001b[0mmissing\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mkwargs\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mpop\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m'missing'\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0;34m'none'\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 76\u001b[0m \u001b[0mhasconst\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mkwargs\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mpop\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m'hasconst'\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0;32mNone\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m---> 77\u001b[0;31m self.data = self._handle_data(endog, exog, missing, hasconst,\n\u001b[0m\u001b[1;32m 78\u001b[0m **kwargs)\n\u001b[1;32m 79\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mk_constant\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mdata\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mk_constant\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n", - "\u001b[0;32m~/opt/anaconda3/lib/python3.9/site-packages/statsmodels/base/model.py\u001b[0m in \u001b[0;36m_handle_data\u001b[0;34m(self, endog, exog, missing, hasconst, **kwargs)\u001b[0m\n\u001b[1;32m 99\u001b[0m \u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 100\u001b[0m \u001b[0;32mdef\u001b[0m \u001b[0m_handle_data\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mself\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mendog\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mexog\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mmissing\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mhasconst\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0;34m**\u001b[0m\u001b[0mkwargs\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m--> 101\u001b[0;31m \u001b[0mdata\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mhandle_data\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mendog\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mexog\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mmissing\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mhasconst\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0;34m**\u001b[0m\u001b[0mkwargs\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m\u001b[1;32m 102\u001b[0m \u001b[0;31m# kwargs arrays could have changed, easier to just attach here\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 103\u001b[0m \u001b[0;32mfor\u001b[0m \u001b[0mkey\u001b[0m \u001b[0;32min\u001b[0m \u001b[0mkwargs\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n", - "\u001b[0;32m~/opt/anaconda3/lib/python3.9/site-packages/statsmodels/base/data.py\u001b[0m in \u001b[0;36mhandle_data\u001b[0;34m(endog, exog, missing, hasconst, **kwargs)\u001b[0m\n\u001b[1;32m 669\u001b[0m \u001b[0mexog\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mnp\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0masarray\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mexog\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 670\u001b[0m \u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m--> 671\u001b[0;31m \u001b[0mklass\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mhandle_data_class_factory\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mendog\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mexog\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m\u001b[1;32m 672\u001b[0m return klass(endog, exog=exog, missing=missing, hasconst=hasconst,\n\u001b[1;32m 673\u001b[0m **kwargs)\n", - "\u001b[0;32m~/opt/anaconda3/lib/python3.9/site-packages/statsmodels/base/data.py\u001b[0m in \u001b[0;36mhandle_data_class_factory\u001b[0;34m(endog, exog)\u001b[0m\n\u001b[1;32m 657\u001b[0m \u001b[0mklass\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mModelData\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 658\u001b[0m \u001b[0;32melse\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m--> 659\u001b[0;31m raise ValueError('unrecognized data structures: %s / %s' %\n\u001b[0m\u001b[1;32m 660\u001b[0m (type(endog), type(exog)))\n\u001b[1;32m 661\u001b[0m \u001b[0;32mreturn\u001b[0m \u001b[0mklass\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n", - "\u001b[0;31mValueError\u001b[0m: unrecognized data structures: / " - ] - } - ], + "outputs": [], "source": [ "mod = sm.OLS(y, X) # Describe model" ] }, { "cell_type": "code", - "execution_count": 8, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "ename": "NameError", - "evalue": "name 'mod' is not defined", - "output_type": "error", - "traceback": [ - "\u001b[0;31m---------------------------------------------------------------------------\u001b[0m", - "\u001b[0;31mNameError\u001b[0m Traceback (most recent call last)", - "\u001b[0;32m/var/folders/qj/jybppsbd2jl75s8y2q8s2xx80000gn/T/ipykernel_5691/3877149832.py\u001b[0m in \u001b[0;36m\u001b[0;34m\u001b[0m\n\u001b[0;32m----> 1\u001b[0;31m \u001b[0mres\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mmod\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mfit\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m)\u001b[0m \u001b[0;31m# Fit model\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m\u001b[1;32m 2\u001b[0m \u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 3\u001b[0m \u001b[0mprint\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mres\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0msummary\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n", - "\u001b[0;31mNameError\u001b[0m: name 'mod' is not defined" - ] - } - ], + "outputs": [], "source": [ "res = mod.fit() # Fit model\n", "\n", @@ -356,37 +116,18 @@ }, { "cell_type": "code", - "execution_count": 9, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "UserWarning: Distributing object. This may take some time.\n" - ] - } - ], + "outputs": [], "source": [ "modin_df = pd.DataFrame({\"A\": [10,20,30,40,50], \"B\": [20, 30, 10, 40, 50], \"C\": [32, 234, 23, 23, 42523]})" ] }, { "cell_type": "code", - "execution_count": 10, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Intercept 14.952480\n", - "B 0.401182\n", - "C 0.000352\n", - "dtype: float64\n" - ] - } - ], + "outputs": [], "source": [ "import statsmodels.formula.api as sm\n", "result = sm.ols(formula=\"A ~ B + C\", data=modin_df).fit()\n", @@ -395,51 +136,9 @@ }, { "cell_type": "code", - "execution_count": 11, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - " OLS Regression Results \n", - "==============================================================================\n", - "Dep. Variable: A R-squared: 0.579\n", - "Model: OLS Adj. R-squared: 0.158\n", - "Method: Least Squares F-statistic: 1.375\n", - "Date: Thu, 06 Apr 2023 Prob (F-statistic): 0.421\n", - "Time: 11:48:10 Log-Likelihood: -18.178\n", - "No. Observations: 5 AIC: 42.36\n", - "Df Residuals: 2 BIC: 41.19\n", - "Df Model: 2 \n", - "Covariance Type: nonrobust \n", - "==============================================================================\n", - " coef std err t P>|t| [0.025 0.975]\n", - "------------------------------------------------------------------------------\n", - "Intercept 14.9525 17.764 0.842 0.489 -61.481 91.386\n", - "B 0.4012 0.650 0.617 0.600 -2.394 3.197\n", - "C 0.0004 0.001 0.650 0.583 -0.002 0.003\n", - "==============================================================================\n", - "Omnibus: nan Durbin-Watson: 1.061\n", - "Prob(Omnibus): nan Jarque-Bera (JB): 0.498\n", - "Skew: -0.123 Prob(JB): 0.780\n", - "Kurtosis: 1.474 Cond. No. 5.21e+04\n", - "==============================================================================\n", - "\n", - "Notes:\n", - "[1] Standard Errors assume that the covariance matrix of the errors is correctly specified.\n", - "[2] The condition number is large, 5.21e+04. This might indicate that there are\n", - "strong multicollinearity or other numerical problems.\n" - ] - }, - { - "name": "stderr", - "output_type": "stream", - "text": [ - "ValueWarning: omni_normtest is not valid with less than 8 observations; 5 samples were given.\n" - ] - } - ], + "outputs": [], "source": [ "print(result.summary())" ] @@ -453,7 +152,7 @@ }, { "cell_type": "code", - "execution_count": 13, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ @@ -465,7 +164,7 @@ }, { "cell_type": "code", - "execution_count": 14, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ @@ -476,7 +175,7 @@ }, { "cell_type": "code", - "execution_count": 15, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ @@ -485,7 +184,7 @@ }, { "cell_type": "code", - "execution_count": 16, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ @@ -494,7 +193,7 @@ }, { "cell_type": "code", - "execution_count": 17, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ @@ -504,7 +203,7 @@ }, { "cell_type": "code", - "execution_count": 18, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ @@ -513,46 +212,9 @@ }, { "cell_type": "code", - "execution_count": 19, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - " OLS Regression Results \n", - "==============================================================================\n", - "Dep. Variable: Lottery R-squared: 0.338\n", - "Model: OLS Adj. R-squared: 0.287\n", - "Method: Least Squares F-statistic: 6.636\n", - "Date: Thu, 06 Apr 2023 Prob (F-statistic): 1.07e-05\n", - "Time: 11:48:36 Log-Likelihood: -375.30\n", - "No. Observations: 85 AIC: 764.6\n", - "Df Residuals: 78 BIC: 781.7\n", - "Df Model: 6 \n", - "Covariance Type: nonrobust \n", - "===============================================================================\n", - " coef std err t P>|t| [0.025 0.975]\n", - "-------------------------------------------------------------------------------\n", - "Intercept 38.6517 9.456 4.087 0.000 19.826 57.478\n", - "Region[T.E] -15.4278 9.727 -1.586 0.117 -34.793 3.938\n", - "Region[T.N] -10.0170 9.260 -1.082 0.283 -28.453 8.419\n", - "Region[T.S] -4.5483 7.279 -0.625 0.534 -19.039 9.943\n", - "Region[T.W] -10.0913 7.196 -1.402 0.165 -24.418 4.235\n", - "Literacy -0.1858 0.210 -0.886 0.378 -0.603 0.232\n", - "Wealth 0.4515 0.103 4.390 0.000 0.247 0.656\n", - "==============================================================================\n", - "Omnibus: 3.049 Durbin-Watson: 1.785\n", - "Prob(Omnibus): 0.218 Jarque-Bera (JB): 2.694\n", - "Skew: -0.340 Prob(JB): 0.260\n", - "Kurtosis: 2.454 Cond. No. 371.\n", - "==============================================================================\n", - "\n", - "Notes:\n", - "[1] Standard Errors assume that the covariance matrix of the errors is correctly specified.\n" - ] - } - ], + "outputs": [], "source": [ "res = mod.fit() # Fit model\n", "\n", @@ -568,37 +230,18 @@ }, { "cell_type": "code", - "execution_count": 20, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "UserWarning: Distributing object. This may take some time.\n" - ] - } - ], + "outputs": [], "source": [ "pandas_df = pd.DataFrame({\"A\": [10,20,30,40,50], \"B\": [20, 30, 10, 40, 50], \"C\": [32, 234, 23, 23, 42523]})" ] }, { "cell_type": "code", - "execution_count": 21, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Intercept 14.952480\n", - "B 0.401182\n", - "C 0.000352\n", - "dtype: float64\n" - ] - } - ], + "outputs": [], "source": [ "import statsmodels.formula.api as sm\n", "result = sm.ols(formula=\"A ~ B + C\", data=pandas_df).fit()\n", @@ -607,51 +250,9 @@ }, { "cell_type": "code", - "execution_count": 22, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - " OLS Regression Results \n", - "==============================================================================\n", - "Dep. Variable: A R-squared: 0.579\n", - "Model: OLS Adj. R-squared: 0.158\n", - "Method: Least Squares F-statistic: 1.375\n", - "Date: Thu, 06 Apr 2023 Prob (F-statistic): 0.421\n", - "Time: 11:48:58 Log-Likelihood: -18.178\n", - "No. Observations: 5 AIC: 42.36\n", - "Df Residuals: 2 BIC: 41.19\n", - "Df Model: 2 \n", - "Covariance Type: nonrobust \n", - "==============================================================================\n", - " coef std err t P>|t| [0.025 0.975]\n", - "------------------------------------------------------------------------------\n", - "Intercept 14.9525 17.764 0.842 0.489 -61.481 91.386\n", - "B 0.4012 0.650 0.617 0.600 -2.394 3.197\n", - "C 0.0004 0.001 0.650 0.583 -0.002 0.003\n", - "==============================================================================\n", - "Omnibus: nan Durbin-Watson: 1.061\n", - "Prob(Omnibus): nan Jarque-Bera (JB): 0.498\n", - "Skew: -0.123 Prob(JB): 0.780\n", - "Kurtosis: 1.474 Cond. No. 5.21e+04\n", - "==============================================================================\n", - "\n", - "Notes:\n", - "[1] Standard Errors assume that the covariance matrix of the errors is correctly specified.\n", - "[2] The condition number is large, 5.21e+04. This might indicate that there are\n", - "strong multicollinearity or other numerical problems.\n" - ] - }, - { - "name": "stderr", - "output_type": "stream", - "text": [ - "ValueWarning: omni_normtest is not valid with less than 8 observations; 5 samples were given.\n" - ] - } - ], + "outputs": [], "source": [ "print(result.summary())" ] diff --git a/examples/jupyter/integrations/tensorflow.ipynb b/examples/jupyter/integrations/tensorflow.ipynb index dee3f9c0dc6..2702149e604 100644 --- a/examples/jupyter/integrations/tensorflow.ipynb +++ b/examples/jupyter/integrations/tensorflow.ipynb @@ -10,7 +10,7 @@ }, { "cell_type": "code", - "execution_count": 1, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ @@ -21,170 +21,9 @@ }, { "cell_type": "code", - "execution_count": 2, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "UserWarning: Ray execution environment not yet initialized. Initializing...\n", - "To remove this warning, run the following python code before doing dataframe operations:\n", - "\n", - " import ray\n", - " ray.init(runtime_env={'env_vars': {'__MODIN_AUTOIMPORT_PANDAS__': '1'}})\n", - "\n", - "2023-04-06 11:54:12,027\tINFO worker.py:1553 -- Started a local Ray instance.\n" - ] - }, - { - "data": { - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
agesexcptrestbpscholfbsrestecgthalachexangoldpeakslopecathaltarget
063111452331215002.330fixed0
167141602860210811.523normal1
267141202290212912.622reversible0
337131302500018703.530normal0
441021302040217201.410normal0
\n", - "
" - ], - "text/plain": [ - " age sex cp trestbps chol fbs restecg thalach exang oldpeak slope \\\n", - "0 63 1 1 145 233 1 2 150 0 2.3 3 \n", - "1 67 1 4 160 286 0 2 108 1 1.5 2 \n", - "2 67 1 4 120 229 0 2 129 1 2.6 2 \n", - "3 37 1 3 130 250 0 0 187 0 3.5 3 \n", - "4 41 0 2 130 204 0 2 172 0 1.4 1 \n", - "\n", - " ca thal target \n", - "0 0 fixed 0 \n", - "1 3 normal 1 \n", - "2 2 reversible 0 \n", - "3 0 normal 0 \n", - "4 0 normal 0 " - ] - }, - "execution_count": 2, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "SHUFFLE_BUFFER = 500\n", "BATCH_SIZE = 2\n", @@ -197,7 +36,7 @@ }, { "cell_type": "code", - "execution_count": 3, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ @@ -206,96 +45,9 @@ }, { "cell_type": "code", - "execution_count": 4, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "data": { - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
agethalachtrestbpschololdpeak
0631501452332.3
1671081602861.5
2671291202292.6
3371871302503.5
4411721302041.4
\n", - "
" - ], - "text/plain": [ - " age thalach trestbps chol oldpeak\n", - "0 63 150 145 233 2.3\n", - "1 67 108 160 286 1.5\n", - "2 67 129 120 229 2.6\n", - "3 37 187 130 250 3.5\n", - "4 41 172 130 204 1.4" - ] - }, - "execution_count": 4, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "numeric_feature_names = ['age', 'thalach', 'trestbps', 'chol', 'oldpeak']\n", "numeric_features = modin_df[numeric_feature_names]\n", @@ -304,60 +56,18 @@ }, { "cell_type": "code", - "execution_count": 5, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "2023-04-06 11:54:16.000875: I tensorflow/core/platform/cpu_feature_guard.cc:151] This TensorFlow binary is optimized with oneAPI Deep Neural Network Library (oneDNN) to use the following CPU instructions in performance-critical operations: AVX2 FMA\n", - "To enable them in other operations, rebuild TensorFlow with the appropriate compiler flags.\n" - ] - }, - { - "data": { - "text/plain": [ - "" - ] - }, - "execution_count": 5, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "tf.convert_to_tensor(numeric_features)" ] }, { "cell_type": "code", - "execution_count": 6, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "ename": "ValueError", - "evalue": "Failed to find data adapter that can handle input: , ", - "output_type": "error", - "traceback": [ - "\u001b[0;31m---------------------------------------------------------------------------\u001b[0m", - "\u001b[0;31mValueError\u001b[0m Traceback (most recent call last)", - "\u001b[0;32m/var/folders/qj/jybppsbd2jl75s8y2q8s2xx80000gn/T/ipykernel_5722/2210982900.py\u001b[0m in \u001b[0;36m\u001b[0;34m\u001b[0m\n\u001b[1;32m 1\u001b[0m \u001b[0mnormalizer\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mtf\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mkeras\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mlayers\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mNormalization\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0maxis\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0;34m-\u001b[0m\u001b[0;36m1\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m----> 2\u001b[0;31m \u001b[0mnormalizer\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0madapt\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mnumeric_features\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m", - "\u001b[0;32m~/opt/anaconda3/lib/python3.9/site-packages/keras/layers/preprocessing/normalization.py\u001b[0m in \u001b[0;36madapt\u001b[0;34m(self, data, batch_size, steps)\u001b[0m\n\u001b[1;32m 240\u001b[0m \u001b[0margument\u001b[0m \u001b[0;32mis\u001b[0m \u001b[0;32mnot\u001b[0m \u001b[0msupported\u001b[0m \u001b[0;32mwith\u001b[0m \u001b[0marray\u001b[0m \u001b[0minputs\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 241\u001b[0m \"\"\"\n\u001b[0;32m--> 242\u001b[0;31m \u001b[0msuper\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0madapt\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mdata\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mbatch_size\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0mbatch_size\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0msteps\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0msteps\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m\u001b[1;32m 243\u001b[0m \u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 244\u001b[0m \u001b[0;32mdef\u001b[0m \u001b[0mupdate_state\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mself\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mdata\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n", - "\u001b[0;32m~/opt/anaconda3/lib/python3.9/site-packages/keras/engine/base_preprocessing_layer.py\u001b[0m in \u001b[0;36madapt\u001b[0;34m(self, data, batch_size, steps)\u001b[0m\n\u001b[1;32m 236\u001b[0m \u001b[0;32mif\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mbuilt\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 237\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mreset_state\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m--> 238\u001b[0;31m data_handler = data_adapter.DataHandler(\n\u001b[0m\u001b[1;32m 239\u001b[0m \u001b[0mdata\u001b[0m\u001b[0;34m,\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 240\u001b[0m \u001b[0mbatch_size\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0mbatch_size\u001b[0m\u001b[0;34m,\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n", - "\u001b[0;32m~/opt/anaconda3/lib/python3.9/site-packages/keras/engine/data_adapter.py\u001b[0m in \u001b[0;36m__init__\u001b[0;34m(self, x, y, sample_weight, batch_size, steps_per_epoch, initial_epoch, epochs, shuffle, class_weight, max_queue_size, workers, use_multiprocessing, model, steps_per_execution, distribute)\u001b[0m\n\u001b[1;32m 1146\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0m_steps_per_execution\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0msteps_per_execution\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 1147\u001b[0m \u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m-> 1148\u001b[0;31m \u001b[0madapter_cls\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mselect_data_adapter\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mx\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0my\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m\u001b[1;32m 1149\u001b[0m self._adapter = adapter_cls(\n\u001b[1;32m 1150\u001b[0m \u001b[0mx\u001b[0m\u001b[0;34m,\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n", - "\u001b[0;32m~/opt/anaconda3/lib/python3.9/site-packages/keras/engine/data_adapter.py\u001b[0m in \u001b[0;36mselect_data_adapter\u001b[0;34m(x, y)\u001b[0m\n\u001b[1;32m 982\u001b[0m \u001b[0;32mif\u001b[0m \u001b[0;32mnot\u001b[0m \u001b[0madapter_cls\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 983\u001b[0m \u001b[0;31m# TODO(scottzhu): This should be a less implementation-specific error.\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m--> 984\u001b[0;31m raise ValueError(\n\u001b[0m\u001b[1;32m 985\u001b[0m \u001b[0;34m\"Failed to find data adapter that can handle \"\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 986\u001b[0m \"input: {}, {}\".format(\n", - "\u001b[0;31mValueError\u001b[0m: Failed to find data adapter that can handle input: , " - ] - } - ], + "outputs": [], "source": [ "normalizer = tf.keras.layers.Normalization(axis=-1)\n", "normalizer.adapt(numeric_features)" @@ -372,157 +82,9 @@ }, { "cell_type": "code", - "execution_count": 7, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "data": { - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
agesexcptrestbpscholfbsrestecgthalachexangoldpeakslopecathaltarget
063111452331215002.330fixed0
167141602860210811.523normal1
267141202290212912.622reversible0
337131302500018703.530normal0
441021302040217201.410normal0
\n", - "
" - ], - "text/plain": [ - " age sex cp trestbps chol fbs restecg thalach exang oldpeak slope \\\n", - "0 63 1 1 145 233 1 2 150 0 2.3 3 \n", - "1 67 1 4 160 286 0 2 108 1 1.5 2 \n", - "2 67 1 4 120 229 0 2 129 1 2.6 2 \n", - "3 37 1 3 130 250 0 0 187 0 3.5 3 \n", - "4 41 0 2 130 204 0 2 172 0 1.4 1 \n", - "\n", - " ca thal target \n", - "0 0 fixed 0 \n", - "1 3 normal 1 \n", - "2 2 reversible 0 \n", - "3 0 normal 0 \n", - "4 0 normal 0 " - ] - }, - "execution_count": 7, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "SHUFFLE_BUFFER = 500\n", "BATCH_SIZE = 2\n", @@ -535,7 +97,7 @@ }, { "cell_type": "code", - "execution_count": 8, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ @@ -544,96 +106,9 @@ }, { "cell_type": "code", - "execution_count": 9, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "data": { - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
agethalachtrestbpschololdpeak
0631501452332.3
1671081602861.5
2671291202292.6
3371871302503.5
4411721302041.4
\n", - "
" - ], - "text/plain": [ - " age thalach trestbps chol oldpeak\n", - "0 63 150 145 233 2.3\n", - "1 67 108 160 286 1.5\n", - "2 67 129 120 229 2.6\n", - "3 37 187 130 250 3.5\n", - "4 41 172 130 204 1.4" - ] - }, - "execution_count": 9, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "numeric_feature_names = ['age', 'thalach', 'trestbps', 'chol', 'oldpeak']\n", "numeric_features = pandas_df[numeric_feature_names]\n", @@ -642,34 +117,16 @@ }, { "cell_type": "code", - "execution_count": 10, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "" - ] - }, - "execution_count": 10, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "tf.convert_to_tensor(numeric_features)" ] }, { "cell_type": "code", - "execution_count": 11, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ diff --git a/examples/jupyter/integrations/xgboost.ipynb b/examples/jupyter/integrations/xgboost.ipynb index dda5e774240..10f452e7d23 100644 --- a/examples/jupyter/integrations/xgboost.ipynb +++ b/examples/jupyter/integrations/xgboost.ipynb @@ -16,7 +16,7 @@ }, { "cell_type": "code", - "execution_count": 1, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ @@ -27,25 +27,9 @@ }, { "cell_type": "code", - "execution_count": 2, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "UserWarning: Ray execution environment not yet initialized. Initializing...\n", - "To remove this warning, run the following python code before doing dataframe operations:\n", - "\n", - " import ray\n", - " ray.init(runtime_env={'env_vars': {'__MODIN_AUTOIMPORT_PANDAS__': '1'}})\n", - "\n", - "2023-01-03 12:19:34,877\tINFO worker.py:1529 -- Started a local Ray instance. View the dashboard at \u001b[1m\u001b[32m127.0.0.1:8269 \u001b[39m\u001b[22m\n", - "UserWarning: Distributing object. This may take some time.\n", - "UserWarning: Distributing object. This may take some time.\n" - ] - } - ], + "outputs": [], "source": [ "data_train = pd.DataFrame(np.arange(36).reshape((12,3)), columns=['a', 'b', 'c'])\n", "label_train = pd.DataFrame(np.random.randint(2, size=12))\n", @@ -54,17 +38,9 @@ }, { "cell_type": "code", - "execution_count": 3, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "UserWarning: Distributing object. This may take some time.\n" - ] - } - ], + "outputs": [], "source": [ "data_test = pd.DataFrame(np.arange(12).reshape((4,3)), columns=['a', 'b', 'c'])\n", "label_test = pd.DataFrame(np.random.randint(2, size=4))\n", @@ -73,7 +49,7 @@ }, { "cell_type": "code", - "execution_count": 4, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ @@ -84,33 +60,9 @@ }, { "cell_type": "code", - "execution_count": 5, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "[0]\ttrain-auc:0.85714\teval-auc:0.50000\n", - "[1]\ttrain-auc:0.82857\teval-auc:0.50000\n", - "[2]\ttrain-auc:0.82857\teval-auc:0.50000\n", - "[3]\ttrain-auc:0.85714\teval-auc:0.50000\n", - "[4]\ttrain-auc:0.85714\teval-auc:0.50000\n", - "[5]\ttrain-auc:0.85714\teval-auc:0.50000\n", - "[6]\ttrain-auc:0.85714\teval-auc:0.50000\n", - "[7]\ttrain-auc:0.85714\teval-auc:0.50000\n", - "[8]\ttrain-auc:0.85714\teval-auc:0.50000\n", - "[9]\ttrain-auc:0.85714\teval-auc:0.50000\n" - ] - }, - { - "name": "stderr", - "output_type": "stream", - "text": [ - "FutureWarning: Pass `evals` as keyword args.\n" - ] - } - ], + "outputs": [], "source": [ "evallist = [(dtrain, 'train'), (dtest, 'eval')]\n", "num_round = 10\n", @@ -119,7 +71,7 @@ }, { "cell_type": "code", - "execution_count": 6, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ diff --git a/examples/quickstart.ipynb b/examples/quickstart.ipynb index 395e7cd9f7f..0fe229f2cea 100644 --- a/examples/quickstart.ipynb +++ b/examples/quickstart.ipynb @@ -70,6 +70,7 @@ "#############################################\n", "import time\n", "import ray\n", + "# Look at the Ray documentation with respect to the Ray configuration suited to you most.\n", "ray.init()\n", "from IPython.display import Markdown, display\n", "def printmd(string):\n", diff --git a/modin/core/execution/dask/common/utils.py b/modin/core/execution/dask/common/utils.py index 3eda2a50375..ce9a20048f5 100644 --- a/modin/core/execution/dask/common/utils.py +++ b/modin/core/execution/dask/common/utils.py @@ -24,7 +24,6 @@ NPartitions, ) from modin.core.execution.utils import set_env -from modin.error_message import ErrorMessage def initialize_dask(): @@ -44,15 +43,6 @@ def _disable_warnings(): except ValueError: from distributed import Client - # The indentation here is intentional, we want the code to be indented. - ErrorMessage.not_initialized( - "Dask", - """ - from distributed import Client - - client = Client() -""", - ) num_cpus = CpuCount.get() memory_limit = Memory.get() worker_memory_limit = memory_limit // num_cpus if memory_limit else "auto" diff --git a/modin/core/execution/ray/common/utils.py b/modin/core/execution/ray/common/utils.py index f24be8fe2cf..3d954b578de 100644 --- a/modin/core/execution/ray/common/utils.py +++ b/modin/core/execution/ray/common/utils.py @@ -118,15 +118,6 @@ def initialize_ray( **extra_init_kw, ) else: - # This string is intentionally formatted this way. We want it indented in - # the warning message. - ErrorMessage.not_initialized( - "Ray", - f""" - import ray - ray.init({', '.join([f'{k}={v}' for k,v in extra_init_kw.items()])}) -""", - ) object_store_memory = _get_object_store_memory() ray_init_kwargs = { "num_cpus": CpuCount.get(), diff --git a/modin/core/execution/unidist/common/utils.py b/modin/core/execution/unidist/common/utils.py index 30d735945e5..5aa31698b6a 100644 --- a/modin/core/execution/unidist/common/utils.py +++ b/modin/core/execution/unidist/common/utils.py @@ -17,7 +17,6 @@ import unidist.config as unidist_cfg import modin.config as modin_cfg -from modin.error_message import ErrorMessage from .engine_wrapper import UnidistWrapper @@ -36,15 +35,6 @@ def initialize_unidist(): modin_cfg.CpuCount.subscribe( lambda cpu_count: unidist_cfg.CpuCount.put(cpu_count.get()) ) - # This string is intentionally formatted this way. We want it indented in - # the warning message. - ErrorMessage.not_initialized( - "unidist", - """ - import unidist - unidist.init() - """, - ) unidist_cfg.MpiRuntimeEnv.put( {"env_vars": {"PYTHONWARNINGS": "ignore::FutureWarning"}} ) From 2cdb5346ffa1d3f16471dc4c4a7439dc47450f36 Mon Sep 17 00:00:00 2001 From: Iaroslav Igoshev Date: Tue, 6 Feb 2024 17:51:39 +0100 Subject: [PATCH 167/201] FEAT-#6914: Add a config for setting a number of threads per Dask worker (#6915) Signed-off-by: Igoshev, Iaroslav --- modin/config/__init__.py | 3 +++ modin/config/envvars.py | 7 +++++++ modin/core/execution/dask/common/utils.py | 8 +++++++- 3 files changed, 17 insertions(+), 1 deletion(-) diff --git a/modin/config/__init__.py b/modin/config/__init__.py index 26d759324fd..5a2aa1a68cb 100644 --- a/modin/config/__init__.py +++ b/modin/config/__init__.py @@ -21,6 +21,7 @@ CIAWSAccessKeyID, CIAWSSecretAccessKey, CpuCount, + DaskThreadsPerWorker, DoUseCalcite, Engine, EnvironmentVariable, @@ -73,6 +74,8 @@ "RayRedisPassword", "TestRayClient", "LazyExecution", + # Dask specific + "DaskThreadsPerWorker", # Partitioning "NPartitions", "MinPartitionSize", diff --git a/modin/config/envvars.py b/modin/config/envvars.py index c865e07a358..589e6e2a439 100644 --- a/modin/config/envvars.py +++ b/modin/config/envvars.py @@ -830,6 +830,13 @@ class LazyExecution(EnvironmentVariable, type=bool): default = False +class DaskThreadsPerWorker(EnvironmentVariable, type=int): + """Number of threads per Dask worker.""" + + varname = "MODIN_DASK_THREADS_PER_WORKER" + default = None + + def _check_vars() -> None: """ Check validity of environment variables. diff --git a/modin/core/execution/dask/common/utils.py b/modin/core/execution/dask/common/utils.py index ce9a20048f5..067a94fcdf0 100644 --- a/modin/core/execution/dask/common/utils.py +++ b/modin/core/execution/dask/common/utils.py @@ -19,6 +19,7 @@ CIAWSAccessKeyID, CIAWSSecretAccessKey, CpuCount, + DaskThreadsPerWorker, GithubCI, Memory, NPartitions, @@ -44,12 +45,17 @@ def _disable_warnings(): from distributed import Client num_cpus = CpuCount.get() + threads_per_worker = DaskThreadsPerWorker.get() memory_limit = Memory.get() worker_memory_limit = memory_limit // num_cpus if memory_limit else "auto" # when the client is initialized, environment variables are inherited with set_env(PYTHONWARNINGS="ignore::FutureWarning"): - client = Client(n_workers=num_cpus, memory_limit=worker_memory_limit) + client = Client( + n_workers=num_cpus, + threads_per_worker=threads_per_worker, + memory_limit=worker_memory_limit, + ) if GithubCI.get(): # set these keys to run tests that write to the mock s3 service. this seems From e55e6a0cc2a570437326b3165ecb1a9800fca29e Mon Sep 17 00:00:00 2001 From: Iaroslav Igoshev Date: Tue, 6 Feb 2024 18:07:03 +0100 Subject: [PATCH 168/201] TEST-#6920: Remove testing for Ray client (#6921) Signed-off-by: Igoshev, Iaroslav --- .github/workflows/push-to-master.yml | 66 ---------------------------- modin/config/__init__.py | 2 - modin/config/envvars.py | 7 --- modin/conftest.py | 28 ------------ 4 files changed, 103 deletions(-) diff --git a/.github/workflows/push-to-master.yml b/.github/workflows/push-to-master.yml index 377849e6d2f..35e41bcec81 100644 --- a/.github/workflows/push-to-master.yml +++ b/.github/workflows/push-to-master.yml @@ -83,69 +83,3 @@ jobs: - run: sudo apt update && sudo apt install -y libhdf5-dev - name: Docstring URL validity check run: python -m pytest modin/test/test_docstring_urls.py - - test-ray-client: - runs-on: ubuntu-latest - defaults: - run: - shell: bash -l {0} - services: - moto: - image: motoserver/moto - ports: - - 5000:5000 - env: - AWS_ACCESS_KEY_ID: foobar_key - AWS_SECRET_ACCESS_KEY: foobar_secret - strategy: - matrix: - python-version: ["3.9"] - test-task: - - modin/pandas/test/dataframe/test_binary.py - - modin/pandas/test/dataframe/test_default.py - - modin/pandas/test/dataframe/test_indexing.py - - modin/pandas/test/dataframe/test_iter.py - - modin/pandas/test/dataframe/test_join_sort.py - - modin/pandas/test/dataframe/test_map_metadata.py - - modin/pandas/test/dataframe/test_reduce.py - - modin/pandas/test/dataframe/test_udf.py - - modin/pandas/test/dataframe/test_window.py - - modin/pandas/test/dataframe/test_pickle.py - - modin/pandas/test/test_series.py - - modin/numpy/test/test_array.py - - modin/numpy/test/test_array_creation.py - - modin/numpy/test/test_array_arithmetic.py - - modin/numpy/test/test_array_axis_functions.py - - modin/numpy/test/test_array_logic.py - - modin/numpy/test/test_array_linalg.py - - modin/numpy/test/test_array_indexing.py - - modin/numpy/test/test_array_math.py - - modin/numpy/test/test_array_shaping.py - - modin/pandas/test/test_rolling.py - - modin/pandas/test/test_expanding.py - - modin/pandas/test/test_concat.py - - modin/pandas/test/test_groupby.py - - modin/pandas/test/test_reshape.py - - modin/pandas/test/test_general.py - - modin/pandas/test/test_io.py - env: - MODIN_ENGINE: ray - MODIN_MEMORY: 1000000000 - MODIN_TEST_RAY_CLIENT: "True" - name: "test-ray-client" - steps: - - uses: actions/checkout@v3 - with: - fetch-depth: 1 - - uses: conda-incubator/setup-miniconda@v2 - with: - activate-environment: modin - python-version: ${{matrix.python-version}} - channel-priority: strict - # we set use-only-tar-bz2 to false in order for conda to properly find new packages to be installed - # for more info see https://github.com/conda-incubator/setup-miniconda/issues/264 - use-only-tar-bz2: false - - run: pip install -r requirements-dev.txt - - name: Install HDF5 - run: sudo apt update && sudo apt install -y libhdf5-dev - - run: python -m pytest ${{matrix.test-task}} diff --git a/modin/config/__init__.py b/modin/config/__init__.py index 5a2aa1a68cb..c0107f814d6 100644 --- a/modin/config/__init__.py +++ b/modin/config/__init__.py @@ -50,7 +50,6 @@ ReadSqlEngine, StorageFormat, TestDatasetSize, - TestRayClient, TestReadFromPostgres, TestReadFromSqlServer, TrackFileLeaks, @@ -72,7 +71,6 @@ "IsRayCluster", "RayRedisAddress", "RayRedisPassword", - "TestRayClient", "LazyExecution", # Dask specific "DaskThreadsPerWorker", diff --git a/modin/config/envvars.py b/modin/config/envvars.py index 589e6e2a439..85bcf61e79d 100644 --- a/modin/config/envvars.py +++ b/modin/config/envvars.py @@ -390,13 +390,6 @@ class TestDatasetSize(EnvironmentVariable, type=str): choices = ("Small", "Normal", "Big") -class TestRayClient(EnvironmentVariable, type=bool): - """Set to true to start and connect Ray client before a testing session starts.""" - - varname = "MODIN_TEST_RAY_CLIENT" - default = False - - class TrackFileLeaks(EnvironmentVariable, type=bool): """Whether to track for open file handles leakage during testing.""" diff --git a/modin/conftest.py b/modin/conftest.py index 392be2d5146..66500cae54a 100644 --- a/modin/conftest.py +++ b/modin/conftest.py @@ -57,13 +57,10 @@ def _saving_make_api_url(token, _make_api_url=modin.utils._make_api_url): from modin.config import ( # noqa: E402 AsyncReadMode, BenchmarkMode, - CIAWSAccessKeyID, - CIAWSSecretAccessKey, GithubCI, IsExperimental, MinPartitionSize, NPartitions, - TestRayClient, ) from modin.core.execution.dispatching.factories import factories # noqa: E402 from modin.core.execution.python.implementations.pandas_on_python.io import ( # noqa: E402 @@ -496,31 +493,6 @@ def set_min_partition_size(request): ray_client_server = None -def pytest_sessionstart(session): - if TestRayClient.get(): - import ray - import ray.util.client.server.server as ray_server - - addr = "localhost:50051" - global ray_client_server - ray_client_server = ray_server.serve(addr) - env_vars = { - "AWS_ACCESS_KEY_ID": CIAWSAccessKeyID.get(), - "AWS_SECRET_ACCESS_KEY": CIAWSSecretAccessKey.get(), - } - extra_init_kw = {"runtime_env": {"env_vars": env_vars}} - ray.util.connect(addr, ray_init_kwargs=extra_init_kw) - - -def pytest_sessionfinish(session, exitstatus): - if TestRayClient.get(): - import ray - - ray.util.disconnect() - if ray_client_server: - ray_client_server.stop(0) - - @pytest.fixture def s3_storage_options(worker_id): # # copied from pandas conftest.py: From 3f81d69a51ff2b758b8391bb080e00f565935c85 Mon Sep 17 00:00:00 2001 From: Iaroslav Igoshev Date: Tue, 6 Feb 2024 23:12:22 +0100 Subject: [PATCH 169/201] PERF-#6922: Set DaskThreadsPerWorker to 1 (#6923) Signed-off-by: Igoshev, Iaroslav --- modin/config/envvars.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modin/config/envvars.py b/modin/config/envvars.py index 85bcf61e79d..e892e34f2d1 100644 --- a/modin/config/envvars.py +++ b/modin/config/envvars.py @@ -827,7 +827,7 @@ class DaskThreadsPerWorker(EnvironmentVariable, type=int): """Number of threads per Dask worker.""" varname = "MODIN_DASK_THREADS_PER_WORKER" - default = None + default = 1 def _check_vars() -> None: From 97251310ab46871976f7c0178a9770d4018373fe Mon Sep 17 00:00:00 2001 From: Andrey Pavlenko Date: Wed, 7 Feb 2024 10:23:48 +0100 Subject: [PATCH 170/201] FEAT-#6918: Add auto mode to the lazy execution. (#6919) Signed-off-by: Andrey Pavlenko Co-authored-by: Dmitry Chigarev Co-authored-by: Iaroslav Igoshev --- modin/config/envvars.py | 14 ++++-- .../pandas_on_ray/partitioning/partition.py | 46 ++++++++++++++++-- .../storage_formats/pandas/test_internals.py | 47 +++++++++++++++++++ 3 files changed, 101 insertions(+), 6 deletions(-) diff --git a/modin/config/envvars.py b/modin/config/envvars.py index e892e34f2d1..56d821baf3b 100644 --- a/modin/config/envvars.py +++ b/modin/config/envvars.py @@ -816,11 +816,19 @@ class ReadSqlEngine(EnvironmentVariable, type=str): choices = ("Pandas", "Connectorx") -class LazyExecution(EnvironmentVariable, type=bool): - """Prefer the lazy execution, when it's possible.""" +class LazyExecution(EnvironmentVariable, type=str): + """ + Lazy execution mode. + + Supported values: + `Auto` - the execution mode is chosen by the engine for each operation (default value). + `On` - the lazy execution is performed wherever it's possible. + `Off` - the lazy execution is disabled. + """ varname = "MODIN_LAZY_EXECUTION" - default = False + choices = ("Auto", "On", "Off") + default = "Auto" class DaskThreadsPerWorker(EnvironmentVariable, type=int): diff --git a/modin/core/execution/ray/implementations/pandas_on_ray/partitioning/partition.py b/modin/core/execution/ray/implementations/pandas_on_ray/partitioning/partition.py index 7c6bb38b2a0..fc3f0b96452 100644 --- a/modin/core/execution/ray/implementations/pandas_on_ray/partitioning/partition.py +++ b/modin/core/execution/ray/implementations/pandas_on_ray/partitioning/partition.py @@ -125,11 +125,9 @@ def apply(self, func: Callable, *args, **kwargs): If ``LazyExecution`` is enabled, the function is not applied immediately, but is added to the execution tree. """ - de = DeferredExecution(self._data_ref, func, args, kwargs) - if LazyExecution.get(): - return self.__constructor__(de) log = get_logger() self._is_debug(log) and log.debug(f"ENTER::Partition.apply::{self._identity}") + de = DeferredExecution(self._data_ref, func, args, kwargs) data, meta, meta_offset = de.exec() self._is_debug(log) and log.debug(f"EXIT::Partition.apply::{self._identity}") return self.__constructor__(data, meta=meta, meta_offset=meta_offset) @@ -377,3 +375,45 @@ def _get_index_and_columns(df): # pragma: no cover The number of columns. """ return len(df.index), len(df.columns) + + +PandasOnRayDataframePartition._eager_exec_func = PandasOnRayDataframePartition.apply +PandasOnRayDataframePartition._lazy_exec_func = ( + PandasOnRayDataframePartition.add_to_apply_calls +) + + +def _configure_lazy_exec(cls: LazyExecution): + """Configure lazy execution mode for PandasOnRayDataframePartition.""" + mode = cls.get() + get_logger().debug(f"Ray lazy execution mode: {mode}") + if mode == "Auto": + PandasOnRayDataframePartition.apply = ( + PandasOnRayDataframePartition._eager_exec_func + ) + PandasOnRayDataframePartition.add_to_apply_calls = ( + PandasOnRayDataframePartition._lazy_exec_func + ) + elif mode == "On": + + def lazy_exec(self, func, *args, **kwargs): + return self._lazy_exec_func(func, *args, length=None, width=None, **kwargs) + + PandasOnRayDataframePartition.apply = lazy_exec + PandasOnRayDataframePartition.add_to_apply_calls = ( + PandasOnRayDataframePartition._lazy_exec_func + ) + elif mode == "Off": + + def eager_exec(self, func, *args, length=None, width=None, **kwargs): + return self._eager_exec_func(func, *args, **kwargs) + + PandasOnRayDataframePartition.apply = ( + PandasOnRayDataframePartition._eager_exec_func + ) + PandasOnRayDataframePartition.add_to_apply_calls = eager_exec + else: + raise ValueError(f"Invalid lazy execution mode: {mode}") + + +LazyExecution.subscribe(_configure_lazy_exec) diff --git a/modin/test/storage_formats/pandas/test_internals.py b/modin/test/storage_formats/pandas/test_internals.py index 1b7bf242962..fe96a7c80bb 100644 --- a/modin/test/storage_formats/pandas/test_internals.py +++ b/modin/test/storage_formats/pandas/test_internals.py @@ -2421,3 +2421,50 @@ def test_groupby_index_dtype(self): assert res_dtypes._known_dtypes["a"] == np.dtype("int64") patch.assert_not_called() + + +@pytest.mark.skipif(Engine.get() != "Ray", reason="Ray specific") +@pytest.mark.parametrize("mode", [None, "Auto", "On", "Off"]) +def test_ray_lazy_exec_mode(mode): + import ray + + from modin.config import LazyExecution + from modin.core.execution.ray.common.deferred_execution import DeferredExecution + from modin.core.execution.ray.common.utils import ObjectIDType + from modin.core.execution.ray.implementations.pandas_on_ray.partitioning import ( + PandasOnRayDataframePartition, + ) + + orig_mode = LazyExecution.get() + try: + if mode is None: + mode = LazyExecution.get() + else: + LazyExecution.put(mode) + assert mode == LazyExecution.get() + + df = pandas.DataFrame({"A": [1, 2, 3]}) + part = PandasOnRayDataframePartition(ray.put(df)) + + def func(df): + return len(df) + + ray_func = ray.put(func) + + if mode == "Auto": + assert isinstance(part.apply(ray_func)._data_ref, ObjectIDType) + assert isinstance( + part.add_to_apply_calls(ray_func)._data_ref, DeferredExecution + ) + elif mode == "On": + assert isinstance(part.apply(ray_func)._data_ref, DeferredExecution) + assert isinstance( + part.add_to_apply_calls(ray_func)._data_ref, DeferredExecution + ) + elif mode == "Off": + assert isinstance(part.apply(ray_func)._data_ref, ObjectIDType) + assert isinstance(part.add_to_apply_calls(ray_func)._data_ref, ObjectIDType) + else: + pytest.fail(f"Invalid value: {mode}") + finally: + LazyExecution.put(orig_mode) From 1db3e46f235b91ab2c8b61802cbaa7a8ed0e1718 Mon Sep 17 00:00:00 2001 From: Andrey Pavlenko Date: Wed, 7 Feb 2024 13:43:39 +0100 Subject: [PATCH 171/201] FEAT-#6838: Prefer lazy execution for binary operations with scalar. (#6839) Co-authored-by: Iaroslav Igoshev Signed-off-by: Andrey Pavlenko --- modin/core/dataframe/algebra/binary.py | 1 + modin/core/dataframe/pandas/dataframe/dataframe.py | 10 ++++++++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/modin/core/dataframe/algebra/binary.py b/modin/core/dataframe/algebra/binary.py index af0c6ee7e8e..8d9a94f40e5 100644 --- a/modin/core/dataframe/algebra/binary.py +++ b/modin/core/dataframe/algebra/binary.py @@ -419,6 +419,7 @@ def caller( func_args=(other, *args), func_kwargs=kwargs, dtypes=dtypes, + lazy=True, ) return query_compiler.__constructor__( new_modin_frame, shape_hint=shape_hint diff --git a/modin/core/dataframe/pandas/dataframe/dataframe.py b/modin/core/dataframe/pandas/dataframe/dataframe.py index 259d8b42414..920b9b18583 100644 --- a/modin/core/dataframe/pandas/dataframe/dataframe.py +++ b/modin/core/dataframe/pandas/dataframe/dataframe.py @@ -2132,6 +2132,7 @@ def map( new_columns: Optional[pandas.Index] = None, func_args=None, func_kwargs=None, + lazy=False, ) -> "PandasDataframe": """ Perform a function that maps across the entire dataset. @@ -2151,15 +2152,20 @@ def map( Positional arguments for the 'func' callable. func_kwargs : dict, optional Keyword arguments for the 'func' callable. + lazy : bool, default: False + Whether to prefer lazy execution or not. Returns ------- PandasDataframe A new dataframe. """ - new_partitions = self._partition_mgr_cls.map_partitions( - self._partitions, func, func_args, func_kwargs + map_fn = ( + self._partition_mgr_cls.lazy_map_partitions + if lazy + else self._partition_mgr_cls.map_partitions ) + new_partitions = map_fn(self._partitions, func, func_args, func_kwargs) if new_columns is not None and self.has_materialized_columns: assert len(new_columns) == len( self.columns From 2bc1b5e57a75ca02714a17874f6070db2f26efb6 Mon Sep 17 00:00:00 2001 From: Andrey Pavlenko Date: Wed, 7 Feb 2024 14:49:12 +0100 Subject: [PATCH 172/201] REFACTOR-#6918: Docstring and type hints fixes (#6925) Signed-off-by: Andrey Pavlenko --- .../pandas_on_ray/partitioning/partition.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/modin/core/execution/ray/implementations/pandas_on_ray/partitioning/partition.py b/modin/core/execution/ray/implementations/pandas_on_ray/partitioning/partition.py index fc3f0b96452..2e0ded45428 100644 --- a/modin/core/execution/ray/implementations/pandas_on_ray/partitioning/partition.py +++ b/modin/core/execution/ray/implementations/pandas_on_ray/partitioning/partition.py @@ -99,7 +99,7 @@ def __del__(self): if isinstance(self._data_ref, DeferredExecution): self._data_ref.unsubscribe() - def apply(self, func: Callable, *args, **kwargs): + def apply(self, func: Union[Callable, ray.ObjectRef], *args, **kwargs): """ Apply a function to the object wrapped by this partition. @@ -121,9 +121,6 @@ def apply(self, func: Callable, *args, **kwargs): ----- It does not matter if `func` is callable or an ``ray.ObjectRef``. Ray will handle it correctly either way. The keyword arguments are sent as a dictionary. - - If ``LazyExecution`` is enabled, the function is not applied immediately, - but is added to the execution tree. """ log = get_logger() self._is_debug(log) and log.debug(f"ENTER::Partition.apply::{self._identity}") @@ -133,7 +130,14 @@ def apply(self, func: Callable, *args, **kwargs): return self.__constructor__(data, meta=meta, meta_offset=meta_offset) @_inherit_docstrings(PandasDataframePartition.add_to_apply_calls) - def add_to_apply_calls(self, func, *args, length=None, width=None, **kwargs): + def add_to_apply_calls( + self, + func: Union[Callable, ray.ObjectRef], + *args, + length=None, + width=None, + **kwargs, + ): return self.__constructor__( data=DeferredExecution(self._data_ref, func, args, kwargs), length=length, From fb3e90d33e062278db8212539ac52e8fdd58522c Mon Sep 17 00:00:00 2001 From: Andrey Pavlenko Date: Wed, 7 Feb 2024 17:15:41 +0100 Subject: [PATCH 173/201] FIX-#6924: HDK: Use JoinNode instead of MaskNode for non-range row_position (#6926) Co-authored-by: Iaroslav Igoshev Signed-off-by: Andrey Pavlenko --- .../hdk_on_native/dataframe/dataframe.py | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/modin/experimental/core/execution/native/implementations/hdk_on_native/dataframe/dataframe.py b/modin/experimental/core/execution/native/implementations/hdk_on_native/dataframe/dataframe.py index 0167a66501b..72aeef63484 100644 --- a/modin/experimental/core/execution/native/implementations/hdk_on_native/dataframe/dataframe.py +++ b/modin/experimental/core/execution/native/implementations/hdk_on_native/dataframe/dataframe.py @@ -439,6 +439,36 @@ def take_2d_labels_or_positional( return base row_positions = maybe_range(row_positions) + + # If row_positions is not a range, then MaskNode will generate a filter, + # containing enumeration of all the positions. Filtering rows in this + # way is not efficient and, in case of too many values in row_positions, + # may result in a huge JSON query. To workaround this issue, creating an + # empty frame with row_positions index and inner joining with this one. + # If row_positions has less than 10 values, MaskNode is used. + if ( + not is_range_like(row_positions) + and is_list_like(row_positions) + and len(row_positions) > 10 + ): + lhs = base._maybe_materialize_rowid() + if len(lhs._index_cols) == 1 and is_integer_dtype(lhs._dtypes[0]): + pdf = pd.DataFrame(index=row_positions) + rhs = self.from_pandas(pdf) + exprs = lhs._index_exprs() + for col in lhs.columns: + exprs[col] = lhs.ref(col) + condition = lhs._build_equi_join_condition( + rhs, lhs._index_cols, rhs._index_cols + ) + op = JoinNode( + lhs, + rhs, + exprs=exprs, + condition=condition, + ) + return lhs.copy(op=op, index=pdf.index, partitions=None) + base = base._maybe_materialize_rowid() op = MaskNode(base, row_labels=row_labels, row_positions=row_positions) base = self.__constructor__( From c555f590ce22db2349dcc2faab41e81973ebabb2 Mon Sep 17 00:00:00 2001 From: Anatoly Myachev Date: Fri, 9 Feb 2024 22:31:15 +0100 Subject: [PATCH 174/201] FEAT-#6906: Update to pandas 2.2.* (#6907) Signed-off-by: Anatoly Myachev --- docs/supported_apis/utilities_supported.rst | 2 - environment-dev.yml | 29 +++-- .../execution/pandas_on_dask/requirements.txt | 2 +- .../execution/pandas_on_ray/requirements.txt | 2 +- .../pandas_on_unidist/jupyter_unidist_env.yml | 2 +- .../algebra/default2pandas/__init__.py | 4 + .../dataframe/algebra/default2pandas/list.py | 35 ++++++ .../algebra/default2pandas/struct.py | 35 ++++++ modin/core/io/io.py | 4 + .../storage_formats/base/query_compiler.py | 91 ++++++++++++++++ .../implementations/hdk_on_native/io/io.py | 15 ++- .../hdk_on_native/test/test_dataframe.py | 4 + modin/experimental/core/io/sql/utils.py | 6 +- modin/experimental/pandas/io.py | 6 +- modin/pandas/__init__.py | 2 +- modin/pandas/base.py | 56 +++++++--- modin/pandas/dataframe.py | 26 ++++- modin/pandas/general.py | 14 +-- modin/pandas/groupby.py | 42 +++++--- modin/pandas/io.py | 14 +-- modin/pandas/resample.py | 3 +- modin/pandas/series.py | 28 ++++- modin/pandas/series_utils.py | 57 +++++++++- modin/pandas/test/dataframe/test_default.py | 29 +++-- modin/pandas/test/dataframe/test_window.py | 2 +- modin/pandas/test/test_api.py | 8 +- modin/pandas/test/test_groupby.py | 18 ++-- modin/pandas/test/test_rolling.py | 10 +- modin/pandas/test/test_series.py | 101 ++++++++++++++++-- modin/pandas/test/utils.py | 2 +- requirements-dev.txt | 25 +++-- requirements/env_hdk.yml | 20 ++-- requirements/env_unidist_linux.yml | 26 ++--- requirements/env_unidist_win.yml | 26 ++--- requirements/requirements-no-engine.yml | 22 ++-- setup.py | 4 +- 36 files changed, 590 insertions(+), 182 deletions(-) create mode 100644 modin/core/dataframe/algebra/default2pandas/list.py create mode 100644 modin/core/dataframe/algebra/default2pandas/struct.py diff --git a/docs/supported_apis/utilities_supported.rst b/docs/supported_apis/utilities_supported.rst index dcc1f11adf7..6fe3dd442bc 100644 --- a/docs/supported_apis/utilities_supported.rst +++ b/docs/supported_apis/utilities_supported.rst @@ -98,8 +98,6 @@ contributing a distributed version of any of these objects, feel free to open a * DateOffset * ExcelWriter * SparseArray -* SparseSeries -* SparseDataFrame .. _open an issue: https://github.com/modin-project/modin/issues .. _pull request: https://github.com/modin-project/modin/pulls diff --git a/environment-dev.yml b/environment-dev.yml index 69be3f5d47e..fd185dd6d83 100644 --- a/environment-dev.yml +++ b/environment-dev.yml @@ -5,9 +5,9 @@ dependencies: - pip # required dependencies - - pandas>=2.1,<2.2 + - pandas>=2.2,<2.3 - numpy>=1.22.4 - - fsspec>=2022.05.0 + - fsspec>=2022.11.0 - packaging>=21.0 - psutil>=5.8.0 @@ -20,21 +20,21 @@ dependencies: - grpcio!=1.46.* - dask>=2.22.0 - distributed>=2.22.0 - - xarray>=2022.03.0 + - xarray>=2022.12.0 - jinja2>=3.1.2 - - scipy>=1.8.1 - - s3fs>=2022.05.0 - - lxml>=4.8.0 - - openpyxl>=3.0.10 + - scipy>=1.10.0 + - s3fs>=2022.11.0 + - lxml>=4.9.2 + - openpyxl>=3.1.0 - xlrd>=2.0.1 - - matplotlib>=3.6.1 - - sqlalchemy>=1.4.0,<1.4.46 - - pandas-gbq>=0.15.0 - - pytables>=3.7.0 + - matplotlib>=3.6.3 + - sqlalchemy>=2.0.0 + - pandas-gbq>=0.19.0 + - pytables>=3.8.0 # pymssql==2.2.8 broken: https://github.com/modin-project/modin/issues/6429 - pymssql>=2.1.5,!=2.2.8 - - psycopg2>=2.9.3 - - fastparquet>=0.8.1 + - psycopg2>=2.9.6 + - fastparquet>=2022.12.0 - tqdm>=4.60.0 # pandas isn't compatible with numexpr=2.8.5: https://github.com/modin-project/modin/issues/6469 - numexpr<2.8.5 @@ -64,8 +64,7 @@ dependencies: - asv==0.5.1 # no conda package for windows so we install it with pip - connectorx>=0.2.6a4 - # experimental version of fuzzydata requires at least 0.0.6 to successfully resolve all dependencies - - fuzzydata>=0.0.6 + - fuzzydata>=0.0.11 # Fixes breaking ipywidgets changes, but didn't release yet. - git+https://github.com/modin-project/modin-spreadsheet.git@49ffd89f683f54c311867d602c55443fb11bf2a5 # The `numpydoc` version should match the version installed in the `lint-pydocstyle` job of the CI. diff --git a/examples/tutorial/jupyter/execution/pandas_on_dask/requirements.txt b/examples/tutorial/jupyter/execution/pandas_on_dask/requirements.txt index f97dfb0210f..c89d89faa49 100644 --- a/examples/tutorial/jupyter/execution/pandas_on_dask/requirements.txt +++ b/examples/tutorial/jupyter/execution/pandas_on_dask/requirements.txt @@ -1,4 +1,4 @@ -fsspec>=2022.05.0 +fsspec>=2022.11.0 jupyterlab ipywidgets modin[dask] diff --git a/examples/tutorial/jupyter/execution/pandas_on_ray/requirements.txt b/examples/tutorial/jupyter/execution/pandas_on_ray/requirements.txt index f6aa7dec4d3..2b0a343956c 100644 --- a/examples/tutorial/jupyter/execution/pandas_on_ray/requirements.txt +++ b/examples/tutorial/jupyter/execution/pandas_on_ray/requirements.txt @@ -1,4 +1,4 @@ -fsspec>=2022.05.0 +fsspec>=2022.11.0 jupyterlab ipywidgets tqdm>=4.60.0 diff --git a/examples/tutorial/jupyter/execution/pandas_on_unidist/jupyter_unidist_env.yml b/examples/tutorial/jupyter/execution/pandas_on_unidist/jupyter_unidist_env.yml index 4141c870fbd..b70c2285ed4 100644 --- a/examples/tutorial/jupyter/execution/pandas_on_unidist/jupyter_unidist_env.yml +++ b/examples/tutorial/jupyter/execution/pandas_on_unidist/jupyter_unidist_env.yml @@ -3,7 +3,7 @@ channels: - conda-forge dependencies: - pip - - fsspec>=2022.05.0 + - fsspec>=2022.11.0 - jupyterlab - ipywidgets - modin-mpi diff --git a/modin/core/dataframe/algebra/default2pandas/__init__.py b/modin/core/dataframe/algebra/default2pandas/__init__.py index 1d45dab0436..df1494cebda 100644 --- a/modin/core/dataframe/algebra/default2pandas/__init__.py +++ b/modin/core/dataframe/algebra/default2pandas/__init__.py @@ -19,10 +19,12 @@ from .datetime import DateTimeDefault from .default import DefaultMethod from .groupby import GroupByDefault, SeriesGroupByDefault +from .list import ListDefault from .resample import ResampleDefault from .rolling import ExpandingDefault, RollingDefault from .series import SeriesDefault from .str import StrDefault +from .struct import StructDefault __all__ = [ "DataFrameDefault", @@ -37,4 +39,6 @@ "CatDefault", "GroupByDefault", "SeriesGroupByDefault", + "ListDefault", + "StructDefault", ] diff --git a/modin/core/dataframe/algebra/default2pandas/list.py b/modin/core/dataframe/algebra/default2pandas/list.py new file mode 100644 index 00000000000..b79331e73a4 --- /dev/null +++ b/modin/core/dataframe/algebra/default2pandas/list.py @@ -0,0 +1,35 @@ +# Licensed to Modin Development Team under one or more contributor license agreements. +# See the NOTICE file distributed with this work for additional information regarding +# copyright ownership. The Modin Development Team licenses this file to you under the +# Apache License, Version 2.0 (the "License"); you may not use this file except in +# compliance with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software distributed under +# the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF +# ANY KIND, either express or implied. See the License for the specific language +# governing permissions and limitations under the License. + +"""Module houses default applied-on-list accessor functions builder class.""" + +from .series import SeriesDefault + + +class ListDefault(SeriesDefault): + """Builder for default-to-pandas methods which is executed under list accessor.""" + + @classmethod + def frame_wrapper(cls, df): + """ + Get list accessor of the passed frame. + + Parameters + ---------- + df : pandas.DataFrame + + Returns + ------- + pandas.core.arrays.arrow.ListAccessor + """ + return df.squeeze(axis=1).list diff --git a/modin/core/dataframe/algebra/default2pandas/struct.py b/modin/core/dataframe/algebra/default2pandas/struct.py new file mode 100644 index 00000000000..52ed7c2cd6f --- /dev/null +++ b/modin/core/dataframe/algebra/default2pandas/struct.py @@ -0,0 +1,35 @@ +# Licensed to Modin Development Team under one or more contributor license agreements. +# See the NOTICE file distributed with this work for additional information regarding +# copyright ownership. The Modin Development Team licenses this file to you under the +# Apache License, Version 2.0 (the "License"); you may not use this file except in +# compliance with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software distributed under +# the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF +# ANY KIND, either express or implied. See the License for the specific language +# governing permissions and limitations under the License. + +"""Module houses default applied-on-struct accessor functions builder class.""" + +from .series import SeriesDefault + + +class StructDefault(SeriesDefault): + """Builder for default-to-pandas methods which is executed under struct accessor.""" + + @classmethod + def frame_wrapper(cls, df): + """ + Get struct accessor of the passed frame. + + Parameters + ---------- + df : pandas.DataFrame + + Returns + ------- + pandas.core.arrays.arrow.StructAccessor + """ + return df.squeeze(axis=1).struct diff --git a/modin/core/io/io.py b/modin/core/io/io.py index d3c2b3f8b3f..1f9d2857b44 100644 --- a/modin/core/io/io.py +++ b/modin/core/io/io.py @@ -478,6 +478,8 @@ def read_fwf( widths=None, infer_nrows=100, dtype_backend=no_default, + iterator=False, + chunksize=None, **kwds, ): # noqa: PR01 ErrorMessage.default_to_pandas("`read_fwf`") @@ -487,6 +489,8 @@ def read_fwf( widths=widths, infer_nrows=infer_nrows, dtype_backend=dtype_backend, + iterator=iterator, + chunksize=chunksize, **kwds, ) if isinstance(pd_obj, pandas.DataFrame): diff --git a/modin/core/storage_formats/base/query_compiler.py b/modin/core/storage_formats/base/query_compiler.py index b4f6a1e1a1f..b92dd7d4270 100644 --- a/modin/core/storage_formats/base/query_compiler.py +++ b/modin/core/storage_formats/base/query_compiler.py @@ -35,11 +35,13 @@ DateTimeDefault, ExpandingDefault, GroupByDefault, + ListDefault, ResampleDefault, RollingDefault, SeriesDefault, SeriesGroupByDefault, StrDefault, + StructDefault, ) from modin.error_message import ErrorMessage from modin.logging import ClassLogger @@ -6563,6 +6565,88 @@ def cat_codes(self): # End of Categories methods + # List accessor's methods + + @doc_utils.add_one_column_warning + @doc_utils.add_refer_to("Series.list.flatten") + def list_flatten(self): + """ + Flatten list values. + + Returns + ------- + BaseQueryCompiler + """ + return ListDefault.register(pandas.Series.list.flatten)(self) + + @doc_utils.add_one_column_warning + @doc_utils.add_refer_to("Series.list.len") + def list_len(self): + """ + Return the length of each list in the Series. + + Returns + ------- + BaseQueryCompiler + """ + return ListDefault.register(pandas.Series.list.len)(self) + + @doc_utils.add_one_column_warning + @doc_utils.add_refer_to("Series.list.__getitem__") + def list__getitem__(self, key): # noqa: PR01 + """ + Index or slice lists in the Series. + + Returns + ------- + BaseQueryCompiler + """ + return ListDefault.register(pandas.Series.list.__getitem__)(self, key=key) + + # End of List accessor's methods + + # Struct accessor's methods + + @doc_utils.add_one_column_warning + @doc_utils.add_refer_to("Series.struct.dtypes") + def struct_dtypes(self): + """ + Return the dtype object of each child field of the struct. + + Returns + ------- + BaseQueryCompiler + """ + return StructDefault.register(pandas.Series.struct.dtypes)(self) + + @doc_utils.add_one_column_warning + @doc_utils.add_refer_to("Series.struct.field") + def struct_field(self, name_or_index): # noqa: PR01 + """ + Extract a child field of a struct as a Series. + + Returns + ------- + BaseQueryCompiler + """ + return StructDefault.register(pandas.Series.struct.field)( + self, name_or_index=name_or_index + ) + + @doc_utils.add_one_column_warning + @doc_utils.add_refer_to("Series.struct.explode") + def struct_explode(self): + """ + Extract all child fields of a struct as a DataFrame. + + Returns + ------- + BaseQueryCompiler + """ + return StructDefault.register(pandas.Series.struct.explode)(self) + + # End of Struct accessor's methods + # DataFrame methods def invert(self): @@ -6617,6 +6701,13 @@ def compare(self, other, align_axis, keep_shape, keep_equal, result_names): result_names=result_names, ) + @doc_utils.add_refer_to("Series.case_when") + def case_when(self, caselist): # noqa: PR01, RT01, D200 + """ + Replace values where the conditions are True. + """ + return SeriesDefault.register(pandas.Series.case_when)(self, caselist=caselist) + def repartition(self, axis=None): """ Repartitioning QueryCompiler objects to get ideal partitions inside. diff --git a/modin/experimental/core/execution/native/implementations/hdk_on_native/io/io.py b/modin/experimental/core/execution/native/implementations/hdk_on_native/io/io.py index 654082b859b..14eb2f0f3f7 100644 --- a/modin/experimental/core/execution/native/implementations/hdk_on_native/io/io.py +++ b/modin/experimental/core/execution/native/implementations/hdk_on_native/io/io.py @@ -142,7 +142,10 @@ def get_col_names(): kwargs["filepath_or_buffer"], nrows=0, engine="c" ).columns.tolist() - if dtype := kwargs["dtype"]: + dtype = kwargs["dtype"] + # For details: https://github.com/pandas-dev/pandas/issues/57024 + entire_dataframe_dtype = dtype is not None and not isinstance(dtype, dict) + if dtype: if isinstance(dtype, dict): column_types = {c: cls._dtype_to_arrow(t) for c, t in dtype.items()} else: @@ -151,7 +154,9 @@ def get_col_names(): else: column_types = {} - if parse_dates := kwargs["parse_dates"]: + if parse_dates := ( + None if entire_dataframe_dtype else kwargs["parse_dates"] + ): # Either list of column names or list of column indices is supported. if isinstance(parse_dates, list) and ( all(isinstance(col, str) for col in parse_dates) @@ -185,7 +190,7 @@ def get_col_names(): usecols_md = cls._prepare_pyarrow_usecols(kwargs) po = ParseOptions( - delimiter="\\s+" if kwargs["delim_whitespace"] else delimiter, + delimiter="\\s+" if kwargs["delim_whitespace"] is True else delimiter, quote_char=kwargs["quotechar"], double_quote=kwargs["doublequote"], escape_char=kwargs["escapechar"], @@ -426,7 +431,7 @@ def _read_csv_check_support( False, f"read_csv with 'arrow' engine doesn't support {arg} parameter", ) - if delimiter is not None and read_csv_kwargs["delim_whitespace"]: + if delimiter is not None and read_csv_kwargs["delim_whitespace"] is True: raise ValueError( "Specified a delimiter with both sep and delim_whitespace=True; you can only specify one." ) @@ -541,7 +546,7 @@ def _validate_read_csv_kwargs( if delimiter is None: delimiter = sep - if delim_whitespace and (delimiter is not lib.no_default): + if delim_whitespace is True and (delimiter is not lib.no_default): raise ValueError( "Specified a delimiter with both sep and " + "delim_whitespace=True; you can only specify one." diff --git a/modin/experimental/core/execution/native/implementations/hdk_on_native/test/test_dataframe.py b/modin/experimental/core/execution/native/implementations/hdk_on_native/test/test_dataframe.py index c2e963bf7cb..b71d808a8cc 100644 --- a/modin/experimental/core/execution/native/implementations/hdk_on_native/test/test_dataframe.py +++ b/modin/experimental/core/execution/native/implementations/hdk_on_native/test/test_dataframe.py @@ -1440,6 +1440,10 @@ def applier(df, **kwargs): # TODO: make sure we can ignore this warning or "Frame contain columns with unsupported data-types" in message + # Looks like the warning comes from pyarrow, more details: + # https://github.com/pandas-dev/pandas/pull/52419 + or "Passing a BlockManager to DataFrame is deprecated" + in message ): continue assert ( diff --git a/modin/experimental/core/io/sql/utils.py b/modin/experimental/core/io/sql/utils.py index 530f300df3e..dcf58fd1981 100644 --- a/modin/experimental/core/io/sql/utils.py +++ b/modin/experimental/core/io/sql/utils.py @@ -15,7 +15,7 @@ import pandas import pandas._libs.lib as lib -from sqlalchemy import MetaData, Table, create_engine, inspect +from sqlalchemy import MetaData, Table, create_engine, inspect, text from modin.core.storage_formats.pandas.parsers import _split_result_for_readers @@ -167,9 +167,9 @@ def get_query_columns(engine, query): Dictionary with columns names and python types. """ con = engine.connect() - result = con.execute(query).fetchone() - values = list(result) + result = con.execute(text(query)) cols_names = list(result.keys()) + values = list(result.first()) cols = dict() for i in range(len(cols_names)): cols[cols_names[i]] = type(values[i]).__name__ diff --git a/modin/experimental/pandas/io.py b/modin/experimental/pandas/io.py index a305d993bcd..770f46d9d26 100644 --- a/modin/experimental/pandas/io.py +++ b/modin/experimental/pandas/io.py @@ -201,11 +201,11 @@ def parser_func( na_values=None, keep_default_na=True, na_filter=True, - verbose=False, + verbose=lib.no_default, skip_blank_lines=True, parse_dates=None, infer_datetime_format=lib.no_default, - keep_date_col=False, + keep_date_col=lib.no_default, date_parser=lib.no_default, date_format=None, dayfirst=False, @@ -225,7 +225,7 @@ def parser_func( dialect=None, on_bad_lines="error", doublequote=True, - delim_whitespace=False, + delim_whitespace=lib.no_default, low_memory=True, memory_map=False, float_precision=None, diff --git a/modin/pandas/__init__.py b/modin/pandas/__init__.py index a45cb3116e0..1d1b53b035c 100644 --- a/modin/pandas/__init__.py +++ b/modin/pandas/__init__.py @@ -16,7 +16,7 @@ import pandas from packaging import version -__pandas_version__ = "2.1" +__pandas_version__ = "2.2" if ( version.parse(pandas.__version__).release[:2] diff --git a/modin/pandas/base.py b/modin/pandas/base.py index 595cd063730..7b1dbe69928 100644 --- a/modin/pandas/base.py +++ b/modin/pandas/base.py @@ -471,7 +471,7 @@ def _binary_op(self, op, other, **kwargs): new_query_compiler = getattr(self._query_compiler, op)(other, **kwargs) return self._create_or_update_from_compiler(new_query_compiler) - def _default_to_pandas(self, op, *args, **kwargs): + def _default_to_pandas(self, op, *args, reason: str = None, **kwargs): """ Convert dataset to pandas type and call a pandas function on it. @@ -481,6 +481,7 @@ def _default_to_pandas(self, op, *args, **kwargs): Name of pandas function. *args : list Additional positional arguments to be passed to `op`. + reason : str, optional **kwargs : dict Additional keywords arguments to be passed to `op`. @@ -495,7 +496,8 @@ def _default_to_pandas(self, op, *args, **kwargs): type(self).__name__, op if isinstance(op, str) else op.__name__, empty_self_str, - ) + ), + reason=reason, ) args = try_cast_to_pandas(args) @@ -520,15 +522,7 @@ def _default_to_pandas(self, op, *args, **kwargs): failure_condition=True, extra_log="{} is an unsupported operation".format(op), ) - # SparseDataFrames cannot be serialized by arrow and cause problems for Modin. - # For now we will use pandas. - if isinstance(result, type(self)) and not isinstance( - result, (pandas.SparseDataFrame, pandas.SparseSeries) - ): - return self._create_or_update_from_compiler( - result, inplace=kwargs.get("inplace", False) - ) - elif isinstance(result, pandas.DataFrame): + if isinstance(result, pandas.DataFrame): from .dataframe import DataFrame return DataFrame(result) @@ -1106,11 +1100,27 @@ def _deprecate_downcast(self, downcast, method_name: str): return downcast def bfill( - self, *, axis=None, inplace=False, limit=None, downcast=lib.no_default + self, + *, + axis=None, + inplace=False, + limit=None, + limit_area=None, + downcast=lib.no_default, ): # noqa: PR01, RT01, D200 """ Synonym for `DataFrame.fillna` with ``method='bfill'``. """ + if limit_area is not None: + return self._default_to_pandas( + "bfill", + reason="'limit_area' parameter isn't supported", + axis=axis, + inplace=inplace, + limit=limit, + limit_area=limit_area, + downcast=downcast, + ) downcast = self._deprecate_downcast(downcast, "bfill") with warnings.catch_warnings(): warnings.filterwarnings( @@ -1599,11 +1609,27 @@ def expanding( ) def ffill( - self, *, axis=None, inplace=False, limit=None, downcast=lib.no_default + self, + *, + axis=None, + inplace=False, + limit=None, + limit_area=None, + downcast=lib.no_default, ): # noqa: PR01, RT01, D200 """ Synonym for `DataFrame.fillna` with ``method='ffill'``. """ + if limit_area is not None: + return self._default_to_pandas( + "ffill", + reason="'limit_area' parameter isn't supported", + axis=axis, + inplace=inplace, + limit=limit, + limit_area=limit_area, + downcast=downcast, + ) downcast = self._deprecate_downcast(downcast, "ffill") with warnings.catch_warnings(): warnings.filterwarnings( @@ -2489,8 +2515,8 @@ def resample( axis: Axis = lib.no_default, closed: Optional[str] = None, label: Optional[str] = None, - convention: str = "start", - kind: Optional[str] = None, + convention: str = lib.no_default, + kind: Optional[str] = lib.no_default, on: Level = None, level: Level = None, origin: Union[str, TimestampConvertibleTypes] = "start_day", diff --git a/modin/pandas/dataframe.py b/modin/pandas/dataframe.py index 0c2a2e1539b..690d4f15423 100644 --- a/modin/pandas/dataframe.py +++ b/modin/pandas/dataframe.py @@ -394,12 +394,14 @@ def apply( result_type=None, args=(), by_row="compat", + engine="python", + engine_kwargs=None, **kwargs, ): # noqa: PR01, RT01, D200 """ Apply a function along an axis of the ``DataFrame``. """ - if by_row != "compat": + if by_row != "compat" or engine != "python" or engine_kwargs: # TODO: add test return self._default_to_pandas( pandas.DataFrame.apply, @@ -409,6 +411,8 @@ def apply( result_type=result_type, args=args, by_row=by_row, + engine=engine, + engine_kwargs=engine_kwargs, **kwargs, ) @@ -1446,7 +1450,7 @@ def pivot_table( margins=False, dropna=True, margins_name="All", - observed=False, + observed=lib.no_default, sort=True, ): # noqa: PR01, RT01, D200 """ @@ -1631,7 +1635,23 @@ def _get_axis_resolvers(self, axis: str) -> dict: d[axis] = dindex return d - _get_cleaned_column_resolvers = pandas.DataFrame._get_cleaned_column_resolvers + def _get_cleaned_column_resolvers(self) -> dict[Hashable, Series]: # noqa: RT01 + """ + Return the special character free column resolvers of a dataframe. + + Column names with special characters are 'cleaned up' so that they can + be referred to by backtick quoting. + Used in `DataFrame.eval`. + + Notes + ----- + Copied from pandas. + """ + from pandas.core.computation.parsing import clean_column_name + + return { + clean_column_name(k): v for k, v in self.items() if not isinstance(k, int) + } def query(self, expr, inplace=False, **kwargs): # noqa: PR01, RT01, D200 """ diff --git a/modin/pandas/general.py b/modin/pandas/general.py index d4d0ba6f538..73d5a7d001a 100644 --- a/modin/pandas/general.py +++ b/modin/pandas/general.py @@ -230,7 +230,7 @@ def pivot_table( margins=False, dropna=True, margins_name="All", - observed=False, + observed=no_default, sort=True, ): if not isinstance(data, DataFrame): @@ -247,6 +247,7 @@ def pivot_table( margins=margins, dropna=dropna, margins_name=margins_name, + observed=observed, sort=sort, ) @@ -492,18 +493,11 @@ def concat( raise ValueError( "Only can inner (intersect) or outer (union) join the other axis" ) - # We have the weird Series and axis check because, when concatenating a - # dataframe to a series on axis=0, pandas ignores the name of the series, - # and this check aims to mirror that (possibly buggy) functionality list_of_objs = [ ( obj._query_compiler if isinstance(obj, DataFrame) - else ( - DataFrame(obj.rename())._query_compiler - if isinstance(obj, (pandas.Series, Series)) and axis == 0 - else DataFrame(obj)._query_compiler - ) + else DataFrame(obj)._query_compiler ) for obj in list_of_objs ] @@ -627,7 +621,7 @@ def get_dummies( """ if sparse: raise NotImplementedError( - "SparseDataFrame is not implemented. " + "SparseArray is not implemented. " + "To contribute to Modin, please visit " + "github.com/modin-project/modin." ) diff --git a/modin/pandas/groupby.py b/modin/pandas/groupby.py index 4790e95a1d3..bfa425913f3 100644 --- a/modin/pandas/groupby.py +++ b/modin/pandas/groupby.py @@ -645,7 +645,17 @@ def cummax(self, axis=lib.no_default, numeric_only=False, **kwargs): numeric_only=numeric_only, ) - def apply(self, func, *args, **kwargs): + def apply(self, func, *args, include_groups=True, **kwargs): + if not include_groups: + return self._default_to_pandas( + lambda df: df.apply( + func, + *args, + include_groups=include_groups, + **kwargs, + ) + ) + func = cast_function_modin2pandas(func) if not isinstance(func, BuiltinFunctionType): func = wrap_udf_function(func) @@ -1172,8 +1182,10 @@ def nunique(self, dropna=True): ) ) - def resample(self, rule, *args, **kwargs): - return self._default_to_pandas(lambda df: df.resample(rule, *args, **kwargs)) + def resample(self, rule, *args, include_groups=True, **kwargs): + return self._default_to_pandas( + lambda df: df.resample(rule, *args, include_groups=include_groups, **kwargs) + ) def median(self, numeric_only=False): return self._check_index( @@ -1253,13 +1265,12 @@ def fillna( if axis is not lib.no_default: self._deprecate_axis(axis, "fillna") - if method is not None: - warnings.warn( - f"{type(self).__name__}.fillna with 'method' is deprecated and " - + "will raise in a future version. Use obj.ffill() or obj.bfill() " - + "instead.", - FutureWarning, - ) + warnings.warn( + f"{type(self).__name__}.fillna is deprecated and will be removed " + + "in a future version. Use obj.ffill(), obj.bfill(), " + + "or obj.nearest() instead.", + FutureWarning, + ) # default behaviour for aggregations; for the reference see # `_op_via_apply` func in pandas==2.0.2 @@ -1829,8 +1840,15 @@ def cov(self, other, min_periods=None, ddof=1): agg_kwargs=dict(other=other, min_periods=min_periods, ddof=ddof), ) - def describe(self, **kwargs): - return self._default_to_pandas(lambda df: df.describe(**kwargs)) + def describe(self, percentiles=None, include=None, exclude=None): + return self._default_to_pandas( + lambda df: df.describe( + percentiles=percentiles, include=include, exclude=exclude + ) + ) + + def apply(self, func, *args, **kwargs): + return super().apply(func, *args, **kwargs) def idxmax(self, axis=lib.no_default, skipna=True): if axis is not lib.no_default: diff --git a/modin/pandas/io.py b/modin/pandas/io.py index 0088eb9df5d..1c67fa7604d 100644 --- a/modin/pandas/io.py +++ b/modin/pandas/io.py @@ -181,12 +181,12 @@ def read_csv( na_values=None, keep_default_na: bool = True, na_filter: bool = True, - verbose: bool = False, + verbose: bool = no_default, skip_blank_lines: bool = True, # Datetime Handling parse_dates=None, infer_datetime_format: bool = no_default, - keep_date_col: bool = False, + keep_date_col: bool = no_default, date_parser=no_default, date_format=None, dayfirst: bool = False, @@ -210,7 +210,7 @@ def read_csv( # Error Handling on_bad_lines="error", # Internal - delim_whitespace: bool = False, + delim_whitespace: bool = no_default, low_memory=_c_parser_defaults["low_memory"], memory_map: bool = False, float_precision: Literal["high", "legacy"] | None = None, @@ -253,12 +253,12 @@ def read_table( na_values=None, keep_default_na: bool = True, na_filter: bool = True, - verbose: bool = False, + verbose: bool = no_default, skip_blank_lines: bool = True, # Datetime Handling parse_dates=False, infer_datetime_format: bool = no_default, - keep_date_col: bool = False, + keep_date_col: bool = no_default, date_parser=no_default, date_format: str = None, dayfirst: bool = False, @@ -282,7 +282,7 @@ def read_table( # Error Handling on_bad_lines="error", # Internal - delim_whitespace=False, + delim_whitespace: bool = no_default, low_memory=_c_parser_defaults["low_memory"], memory_map: bool = False, float_precision: str | None = None, @@ -656,6 +656,8 @@ def read_fwf( widths=None, infer_nrows=100, dtype_backend: Union[DtypeBackend, NoDefault] = no_default, + iterator: bool = False, + chunksize: Optional[int] = None, **kwds, ): # noqa: PR01, RT01, D200 """ diff --git a/modin/pandas/resample.py b/modin/pandas/resample.py index 72884e0e420..806261e9e2e 100644 --- a/modin/pandas/resample.py +++ b/modin/pandas/resample.py @@ -71,13 +71,14 @@ def _get_groups(self): Groups as specified by resampling arguments. """ df = self._dataframe if self.axis == 0 else self._dataframe.T + convention = self.resample_kwargs["convention"] groups = df.groupby( pandas.Grouper( key=self.resample_kwargs["on"], freq=self.resample_kwargs["rule"], closed=self.resample_kwargs["closed"], label=self.resample_kwargs["label"], - convention=self.resample_kwargs["convention"], + convention=convention if convention is not lib.no_default else "start", level=self.resample_kwargs["level"], origin=self.resample_kwargs["origin"], offset=self.resample_kwargs["offset"], diff --git a/modin/pandas/series.py b/modin/pandas/series.py index f38ea375292..0469f39cdd9 100644 --- a/modin/pandas/series.py +++ b/modin/pandas/series.py @@ -38,7 +38,13 @@ from .accessor import CachedAccessor, SparseAccessor from .base import _ATTRS_NO_LOOKUP, BasePandasDataset from .iterator import PartitionIterator -from .series_utils import CategoryMethods, DatetimeProperties, StringMethods +from .series_utils import ( + CategoryMethods, + DatetimeProperties, + ListAccessor, + StringMethods, + StructAccessor, +) from .utils import _doc_binary_op, cast_function_modin2pandas, is_scalar if TYPE_CHECKING: @@ -88,7 +94,7 @@ def __init__( dtype=None, name=None, copy=None, - fastpath=False, + fastpath=lib.no_default, query_compiler=None, ): from modin.numpy import array @@ -1003,6 +1009,14 @@ def factorize(self, sort=False, use_na_sentinel=True): # noqa: PR01, RT01, D200 use_na_sentinel=use_na_sentinel, ) + def case_when(self, caselist): # noqa: PR01, RT01, D200 + """ + Replace values where the conditions are True. + """ + return self.__constructor__( + query_compiler=self._query_compiler.case_when(caselist=caselist) + ) + def fillna( self, value=None, @@ -1800,6 +1814,8 @@ def sort_values( sparse = CachedAccessor("sparse", SparseAccessor) str = CachedAccessor("str", StringMethods) dt = CachedAccessor("dt", DatetimeProperties) + list = CachedAccessor("list", ListAccessor) + struct = CachedAccessor("struct", StructAccessor) def squeeze(self, axis=None): # noqa: PR01, RT01, D200 """ @@ -2521,7 +2537,13 @@ def _inflate_light(cls, query_compiler, name, source_pid): New Series based on the `query_compiler`. """ if os.getpid() != source_pid: - return query_compiler.to_pandas() + res = query_compiler.to_pandas() + # at the query compiler layer, `to_pandas` always returns a DataFrame, + # even if it stores a Series, as a single-column DataFrame + if res.columns == [MODIN_UNNAMED_SERIES_LABEL]: + res = res.squeeze(axis=1) + res.name = None + return res # The current logic does not involve creating Modin objects # and manipulation with them in worker processes return cls(query_compiler=query_compiler, name=name) diff --git a/modin/pandas/series_utils.py b/modin/pandas/series_utils.py index 0042bc3bcca..7b11c24324f 100644 --- a/modin/pandas/series_utils.py +++ b/modin/pandas/series_utils.py @@ -31,6 +31,61 @@ from pandas._typing import npt +@_inherit_docstrings(pandas.core.arrays.arrow.ListAccessor) +class ListAccessor(ClassLogger): + def __init__(self, data=None): + self._series = data + self._query_compiler = data._query_compiler + + @pandas.util.cache_readonly + def _Series(self): # noqa: GL08 + # to avoid cyclic import + from .series import Series + + return Series + + def flatten(self): + return self._Series(query_compiler=self._query_compiler.list_flatten()) + + def len(self): + return self._Series(query_compiler=self._query_compiler.list_len()) + + def __getitem__(self, key): + return self._Series( + query_compiler=self._query_compiler.list__getitem__(key=key) + ) + + +@_inherit_docstrings(pandas.core.arrays.arrow.StructAccessor) +class StructAccessor(ClassLogger): + def __init__(self, data=None): + self._series = data + self._query_compiler = data._query_compiler + + @pandas.util.cache_readonly + def _Series(self): # noqa: GL08 + # to avoid cyclic import + from modin.pandas.series import Series + + return Series + + @property + def dtypes(self): + return self._Series(query_compiler=self._query_compiler.struct_dtypes()) + + def field(self, name_or_index): + return self._Series( + query_compiler=self._query_compiler.struct_field( + name_or_index=name_or_index + ) + ) + + def explode(self): + from modin.pandas.dataframe import DataFrame + + return DataFrame(query_compiler=self._query_compiler.struct_explode()) + + @_inherit_docstrings(pandas.core.arrays.categorical.CategoricalAccessor) class CategoryMethods(ClassLogger): def __init__(self, data): @@ -40,7 +95,7 @@ def __init__(self, data): @pandas.util.cache_readonly def _Series(self): # noqa: GL08 # to avoid cyclic import - from .series import Series + from modin.pandas.series import Series return Series diff --git a/modin/pandas/test/dataframe/test_default.py b/modin/pandas/test/dataframe/test_default.py index 59b9f907e4f..e1c61c475db 100644 --- a/modin/pandas/test/dataframe/test_default.py +++ b/modin/pandas/test/dataframe/test_default.py @@ -132,7 +132,7 @@ def test_partition_to_numpy(data): def test_asfreq(): - index = pd.date_range("1/1/2000", periods=4, freq="T") + index = pd.date_range("1/1/2000", periods=4, freq="min") series = pd.Series([0.0, None, 2.0, 3.0], index=index) df = pd.DataFrame({"s": series}) with warns_that_defaulting_to_pandas(): @@ -197,6 +197,15 @@ def test_bfill(data): df_equals(modin_df.bfill(), pandas_df.bfill()) +@pytest.mark.parametrize("limit_area", [None, "inside", "outside"]) +@pytest.mark.parametrize("method", ["ffill", "bfill"]) +def test_ffill_bfill_limit_area(method, limit_area): + modin_df, pandas_df = create_test_dfs([1, None, 2, None]) + eval_general( + modin_df, pandas_df, lambda df: getattr(df, method)(limit_area=limit_area) + ) + + @pytest.mark.parametrize("data", test_data_values, ids=test_data_keys) def test_bool(data): modin_df = pd.DataFrame(data) @@ -789,7 +798,7 @@ def test_replace(): df_equals(modin_df, pandas_df) -@pytest.mark.parametrize("rule", ["5T", pandas.offsets.Hour()]) +@pytest.mark.parametrize("rule", ["5min", pandas.offsets.Hour()]) @pytest.mark.parametrize("axis", [0]) def test_resampler(rule, axis): data, index = ( @@ -808,7 +817,7 @@ def test_resampler(rule, axis): ) -@pytest.mark.parametrize("rule", ["5T"]) +@pytest.mark.parametrize("rule", ["5min"]) @pytest.mark.parametrize("axis", ["index", "columns"]) @pytest.mark.parametrize( "method", @@ -833,7 +842,7 @@ def test_resampler_functions(rule, axis, method): ) -@pytest.mark.parametrize("rule", ["5T"]) +@pytest.mark.parametrize("rule", ["5min"]) @pytest.mark.parametrize("axis", ["index", "columns"]) @pytest.mark.parametrize( "method_arg", @@ -861,7 +870,7 @@ def test_resampler_functions_with_arg(rule, axis, method_arg): ) -@pytest.mark.parametrize("rule", ["5T"]) +@pytest.mark.parametrize("rule", ["5min"]) @pytest.mark.parametrize("closed", ["left", "right"]) @pytest.mark.parametrize("label", ["right", "left"]) @pytest.mark.parametrize( @@ -889,7 +898,7 @@ def test_resample_specific(rule, closed, label, on, level): if on is None and level is not None: index = pandas.MultiIndex.from_product( - [["a", "b", "c"], pandas.date_range("31/12/2000", periods=4, freq="H")] + [["a", "b", "c"], pandas.date_range("31/12/2000", periods=4, freq="h")] ) pandas_df.index = index modin_df.index = index @@ -897,8 +906,8 @@ def test_resample_specific(rule, closed, label, on, level): level = None if on is not None: - pandas_df[on] = pandas.date_range("22/06/1941", periods=12, freq="T") - modin_df[on] = pandas.date_range("22/06/1941", periods=12, freq="T") + pandas_df[on] = pandas.date_range("22/06/1941", periods=12, freq="min") + modin_df[on] = pandas.date_range("22/06/1941", periods=12, freq="min") pandas_resampler = pandas_df.resample( rule, @@ -948,14 +957,14 @@ def test_resample_specific(rule, closed, label, on, level): ], ) def test_resample_getitem(columns): - index = pandas.date_range("1/1/2013", periods=9, freq="T") + index = pandas.date_range("1/1/2013", periods=9, freq="min") data = { "price": range(9), "volume": range(10, 19), } eval_general( *create_test_dfs(data, index=index), - lambda df: df.resample("3T")[columns].mean(), + lambda df: df.resample("3min")[columns].mean(), ) diff --git a/modin/pandas/test/dataframe/test_window.py b/modin/pandas/test/dataframe/test_window.py index a5b7830ad56..44caf587491 100644 --- a/modin/pandas/test/dataframe/test_window.py +++ b/modin/pandas/test/dataframe/test_window.py @@ -95,7 +95,7 @@ def test_diff_with_datetime_types(): pandas_df = pandas.DataFrame( [[1, 2.0, 3], [4, 5.0, 6], [7, np.nan, 9], [10, 11.3, 12], [13, 14.5, 15]] ) - data = pandas.date_range("2018-01-01", periods=5, freq="H").values + data = pandas.date_range("2018-01-01", periods=5, freq="h").values pandas_df = pandas.concat([pandas_df, pandas.Series(data)], axis=1) modin_df = pd.DataFrame(pandas_df) diff --git a/modin/pandas/test/test_api.py b/modin/pandas/test/test_api.py index ab3b6d17b8a..873ad77728d 100644 --- a/modin/pandas/test/test_api.py +++ b/modin/pandas/test/test_api.py @@ -41,8 +41,6 @@ def test_top_level_api_equality(): "tseries", "to_msgpack", # This one is experimental, and doesn't look finished "Panel", # This is deprecated and throws a warning every time. - "SparseSeries", # depreceted since pandas 1.0, not present in 1.4+ - "SparseDataFrame", # depreceted since pandas 1.0, not present in 1.4+ ] ignore_modin = [ @@ -240,9 +238,9 @@ def test_sparse_accessor_api_equality(obj): def test_groupby_api_equality(obj): modin_dir = [x for x in dir(getattr(pd.groupby, obj)) if x[0] != "_"] pandas_dir = [x for x in dir(getattr(pandas.core.groupby, obj)) if x[0] != "_"] - # These attributes are hidden in the DataFrameGroupBy/SeriesGroupBy instance, - # but available in the DataFrameGroupBy/SeriesGroupBy class in pandas. - ignore = ["keys", "level"] + # These attributes are not mentioned in the pandas documentation, + # but we might want to implement them someday. + ignore = ["keys", "level", "grouper"] missing_from_modin = set(pandas_dir) - set(modin_dir) - set(ignore) assert not len(missing_from_modin), "Differences found in API: {}".format( len(missing_from_modin) diff --git a/modin/pandas/test/test_groupby.py b/modin/pandas/test/test_groupby.py index 2dd1687188a..bd27c3ce885 100644 --- a/modin/pandas/test/test_groupby.py +++ b/modin/pandas/test/test_groupby.py @@ -90,7 +90,10 @@ "ignore:DataFrameGroupBy.shift with axis=1 is deprecated:FutureWarning" ), pytest.mark.filterwarnings( - "ignore:(DataFrameGroupBy|SeriesGroupBy|DataFrame|Series).fillna with 'method' is deprecated:FutureWarning" + "ignore:(DataFrameGroupBy|SeriesGroupBy).fillna is deprecated:FutureWarning" + ), + pytest.mark.filterwarnings( + "ignore:(DataFrame|Series).fillna with 'method' is deprecated:FutureWarning" ), # FIXME: these cases inconsistent between modin and pandas pytest.mark.filterwarnings( @@ -99,6 +102,9 @@ pytest.mark.filterwarnings( "ignore:The default of observed=False is deprecated:FutureWarning" ), + pytest.mark.filterwarnings( + "ignore:When grouping with a length-1 list-like, you will need to pass a length-1 tuple to get_group in a future:FutureWarning" + ), pytest.mark.filterwarnings( "ignore:.*DataFrame.idxmax with all-NA values, or any-NA and skipna=False, is deprecated:FutureWarning" ), @@ -1721,7 +1727,7 @@ def test_shift_freq(groupby_axis, shift_axis, groupby_sort): ) modin_df = from_pandas(pandas_df) - new_index = pandas.date_range("1/12/2020", periods=4, freq="S") + new_index = pandas.date_range("1/12/2020", periods=4, freq="s") if groupby_axis == 0 and shift_axis == 0: pandas_df.index = modin_df.index = new_index by = [["col2", "col3"], ["col2"], ["col4"], [0, 1, 0, 2]] @@ -1736,7 +1742,7 @@ def test_shift_freq(groupby_axis, shift_axis, groupby_sort): eval_general( modin_groupby, pandas_groupby, - lambda groupby: groupby.shift(axis=shift_axis, freq="S"), + lambda groupby: groupby.shift(axis=shift_axis, freq="s"), ) @@ -2715,7 +2721,7 @@ def test_skew_corner_cases(): "by", [ pandas.Grouper(key="time_stamp", freq="3D"), - [pandas.Grouper(key="time_stamp", freq="1M"), "count"], + [pandas.Grouper(key="time_stamp", freq="1ME"), "count"], ], ) def test_groupby_with_grouper(by): @@ -3212,12 +3218,12 @@ def test_groupby_fillna_axis_1_warning(): with pytest.warns( FutureWarning, - match="DataFrameGroupBy.fillna with 'method' is deprecated", + match="DataFrameGroupBy.fillna is deprecated", ): modin_groupby.fillna(method="ffill") with pytest.warns( FutureWarning, - match="DataFrameGroupBy.fillna with 'method' is deprecated", + match="DataFrameGroupBy.fillna is deprecated", ): pandas_groupby.fillna(method="ffill") diff --git a/modin/pandas/test/test_rolling.py b/modin/pandas/test/test_rolling.py index a46bbfd2c63..e156701056a 100644 --- a/modin/pandas/test/test_rolling.py +++ b/modin/pandas/test/test_rolling.py @@ -159,13 +159,13 @@ def test_dataframe_window(data, window, min_periods, axis, method, kwargs): @pytest.mark.parametrize("closed", ["both", "right"]) @pytest.mark.parametrize("window", [3, "3s"]) def test_dataframe_dt_index(axis, on, closed, window): - index = pandas.date_range("31/12/2000", periods=12, freq="T") + index = pandas.date_range("31/12/2000", periods=12, freq="min") data = {"A": range(12), "B": range(12)} pandas_df = pandas.DataFrame(data, index=index) modin_df = pd.DataFrame(data, index=index) if on is not None and axis == lib.no_default and isinstance(window, str): - pandas_df[on] = pandas.date_range("22/06/1941", periods=12, freq="T") - modin_df[on] = pd.date_range("22/06/1941", periods=12, freq="T") + pandas_df[on] = pandas.date_range("22/06/1941", periods=12, freq="min") + modin_df[on] = pd.date_range("22/06/1941", periods=12, freq="min") else: on = None if axis == "columns": @@ -305,7 +305,7 @@ def test_series_window(data, window, min_periods, method, kwargs): @pytest.mark.parametrize("closed", ["both", "right"]) def test_series_dt_index(closed): - index = pandas.date_range("1/1/2000", periods=12, freq="T") + index = pandas.date_range("1/1/2000", periods=12, freq="min") pandas_series = pandas.Series(range(12), index=index) modin_series = pd.Series(range(12), index=index) @@ -343,7 +343,7 @@ def test_issue_3512(): def test_rolling_axis_1_depr(): - index = pandas.date_range("31/12/2000", periods=12, freq="T") + index = pandas.date_range("31/12/2000", periods=12, freq="min") data = {"A": range(12), "B": range(12)} modin_df = pd.DataFrame(data, index=index) with pytest.warns( diff --git a/modin/pandas/test/test_series.py b/modin/pandas/test/test_series.py index 30ad96becfd..21eda400437 100644 --- a/modin/pandas/test/test_series.py +++ b/modin/pandas/test/test_series.py @@ -542,7 +542,7 @@ def test___repr__(name, dt_index, data): pandas_series.name = modin_series.name = name if dt_index: index = pandas.date_range( - "1/1/2000", periods=len(pandas_series.index), freq="T" + "1/1/2000", periods=len(pandas_series.index), freq="min" ) pandas_series.index = modin_series.index = index @@ -1019,7 +1019,7 @@ def test_argsort(data): def test_asfreq(): - index = pd.date_range("1/1/2000", periods=4, freq="T") + index = pd.date_range("1/1/2000", periods=4, freq="min") series = pd.Series([0.0, None, 2.0, 3.0], index=index) with warns_that_defaulting_to_pandas(): # We are only testing that this defaults to pandas, so we will just check for @@ -1560,7 +1560,7 @@ def test_diff(data, periods): def test_diff_with_dates(): - data = pandas.date_range("2018-01-01", periods=15, freq="H").values + data = pandas.date_range("2018-01-01", periods=15, freq="h").values pandas_series = pandas.Series(data) modin_series = pd.Series(pandas_series) @@ -1811,9 +1811,9 @@ def test_dt(timezone): modin_series.dt.strftime("%B %d, %Y, %r"), pandas_series.dt.strftime("%B %d, %Y, %r"), ) - df_equals(modin_series.dt.round("H"), pandas_series.dt.round("H")) - df_equals(modin_series.dt.floor("H"), pandas_series.dt.floor("H")) - df_equals(modin_series.dt.ceil("H"), pandas_series.dt.ceil("H")) + df_equals(modin_series.dt.round("h"), pandas_series.dt.round("h")) + df_equals(modin_series.dt.floor("h"), pandas_series.dt.floor("h")) + df_equals(modin_series.dt.ceil("h"), pandas_series.dt.ceil("h")) df_equals(modin_series.dt.month_name(), pandas_series.dt.month_name()) df_equals(modin_series.dt.day_name(), pandas_series.dt.day_name()) @@ -1863,7 +1863,7 @@ def dt_with_empty_partition(lib): data = pd.period_range("2016-12-31", periods=128, freq="D") modin_series = pd.Series(data) pandas_series = pandas.Series(data) - df_equals(modin_series.dt.asfreq("T"), pandas_series.dt.asfreq("T")) + df_equals(modin_series.dt.asfreq("min"), pandas_series.dt.asfreq("min")) @pytest.mark.parametrize( @@ -1997,6 +1997,15 @@ def test_ffill(data): df_equals(modin_series_cp, pandas_series_cp) +@pytest.mark.parametrize("limit_area", [None, "inside", "outside"]) +@pytest.mark.parametrize("method", ["ffill", "bfill"]) +def test_ffill_bfill_limit_area(method, limit_area): + modin_ser, pandas_ser = create_test_series([1, None, 2, None]) + eval_general( + modin_ser, pandas_ser, lambda ser: getattr(ser, method)(limit_area=limit_area) + ) + + @pytest.mark.parametrize("data", test_data_values, ids=test_data_keys) @pytest.mark.parametrize("reindex", [None, 2, -2]) @pytest.mark.parametrize("limit", [None, 1, 2, 0.5, -1, -2, 1.5]) @@ -2936,8 +2945,8 @@ def test_replace(): @pytest.mark.parametrize("level", [None, 1]) @pytest.mark.exclude_in_sanity def test_resample(closed, label, level): - rule = "5T" - freq = "H" + rule = "5min" + freq = "h" index = pandas.date_range("1/1/2000", periods=12, freq=freq) pandas_series = pandas.Series(range(12), index=index) @@ -4585,6 +4594,80 @@ def test_str_decode(encoding, errors, str_encode_decode_test_data): ) +def test_list_general(): + pa = pytest.importorskip("pyarrow") + + # Copied from pandas examples + modin_series, pandas_series = create_test_series( + [ + [1, 2, 3], + [3], + ], + dtype=pd.ArrowDtype(pa.list_(pa.int64())), + ) + eval_general(modin_series, pandas_series, lambda series: series.list.flatten()) + eval_general(modin_series, pandas_series, lambda series: series.list.len()) + eval_general(modin_series, pandas_series, lambda series: series.list[0]) + + +def test_struct_general(): + pa = pytest.importorskip("pyarrow") + + # Copied from pandas examples + modin_series, pandas_series = create_test_series( + [ + {"version": 1, "project": "pandas"}, + {"version": 2, "project": "pandas"}, + {"version": 1, "project": "numpy"}, + ], + dtype=pd.ArrowDtype( + pa.struct([("version", pa.int64()), ("project", pa.string())]) + ), + ) + eval_general(modin_series, pandas_series, lambda series: series.struct.dtypes) + eval_general( + modin_series, pandas_series, lambda series: series.struct.field("project") + ) + eval_general(modin_series, pandas_series, lambda series: series.struct.explode()) + + # nested struct types + version_type = pa.struct( + [ + ("major", pa.int64()), + ("minor", pa.int64()), + ] + ) + modin_series, pandas_series = create_test_series( + [ + {"version": {"major": 1, "minor": 5}, "project": "pandas"}, + {"version": {"major": 2, "minor": 1}, "project": "pandas"}, + {"version": {"major": 1, "minor": 26}, "project": "numpy"}, + ], + dtype=pd.ArrowDtype( + pa.struct([("version", version_type), ("project", pa.string())]) + ), + ) + eval_general( + modin_series, + pandas_series, + lambda series: series.struct.field(["version", "minor"]), + ) + + +def test_case_when(): + # Copied from pandas + c_md, c_pd = create_test_series([6, 7, 8, 9], name="c") + a_md, a_pd = create_test_series([0, 0, 1, 2]) + b_md, b_pd = create_test_series([0, 3, 4, 5]) + + results = [None, None] + for idx, (c, a, b) in enumerate(((c_md, a_md, b_md), (c_pd, a_pd, b_pd))): + results[idx] = c.case_when( + caselist=[(a.gt(0), a), (b.gt(0), b)] # condition, replacement + ) + df_equals(*results) + + @pytest.mark.parametrize("data", test_string_data_values, ids=test_string_data_keys) def test_non_commutative_add_string_to_series(data): # This test checks that add and radd do different things when addition is diff --git a/modin/pandas/test/utils.py b/modin/pandas/test/utils.py index c6ca5868f56..afbb864dad4 100644 --- a/modin/pandas/test/utils.py +++ b/modin/pandas/test/utils.py @@ -168,7 +168,7 @@ test_data_resample = { "data": {"A": range(12), "B": range(12)}, - "index": pandas.date_range("31/12/2000", periods=12, freq="H"), + "index": pandas.date_range("31/12/2000", periods=12, freq="h"), } test_data_with_duplicates = { diff --git a/requirements-dev.txt b/requirements-dev.txt index 132ac6ff19f..803646deb47 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,7 +1,7 @@ ## required dependencies -pandas>=2.1,<2.2 +pandas>=2.2,<2.3 numpy>=1.22.4 -fsspec>=2022.05.0 +fsspec>=2022.11.0 packaging>=21.0 psutil>=5.8.0 @@ -11,16 +11,16 @@ ray[default]>=1.13.0,!=2.5.0 pyarrow>=7.0.0 dask[complete]>=2.22.0 distributed>=2.22.0 -xarray>=2022.03.0 +xarray>=2022.12.0 Jinja2>=3.1.2 -scipy>=1.8.1 -s3fs>=2022.05.0 -lxml>=4.8.0 -openpyxl>=3.0.10 +scipy>=1.10.0 +s3fs>=2022.11.0 +lxml>=4.9.2 +openpyxl>=3.1.0 xlrd>=2.0.1 -matplotlib>=3.6.1 -sqlalchemy>=1.4.0,<1.4.46 -pandas-gbq>=0.15.0 +matplotlib>=3.6.3 +sqlalchemy>=2.0.0 +pandas-gbq>=0.19.0 tables>=3.7.0 # pymssql==2.2.8 broken: https://github.com/modin-project/modin/issues/6429 pymssql>=2.1.5,!=2.2.8 @@ -28,7 +28,7 @@ pymssql>=2.1.5,!=2.2.8 # but this is ok for testing and development psycopg2-binary>=2.9.3 connectorx>=0.2.6a4 -fastparquet>=0.8.1 +fastparquet>=2022.12.0 flask-cors tqdm>=4.60.0 # pandas isn't compatible with numexpr=2.8.5: https://github.com/modin-project/modin/issues/6469 @@ -43,8 +43,7 @@ pygit2>=1.9.2 ## test dependencies asv==0.5.1 coverage>=7.1.0 -# experimental version of fuzzydata requires at least 0.0.6 to successfully resolve all dependencies -fuzzydata>=0.0.6 +fuzzydata>=0.0.11 # The `numpydoc` version should match the version installed in the `lint-pydocstyle` job of the CI. numpydoc==1.1.0 moto>=4.1.0 diff --git a/requirements/env_hdk.yml b/requirements/env_hdk.yml index 3b582d03ed5..83af0dddeeb 100644 --- a/requirements/env_hdk.yml +++ b/requirements/env_hdk.yml @@ -5,23 +5,23 @@ dependencies: - pip # required dependencies - - pandas>=2.1,<2.2 + - pandas>=2.2,<2.3 - numpy>=1.22.4 - pyhdk==0.9 - - fsspec>=2022.05.0 + - fsspec>=2022.11.0 - packaging>=21.0 - psutil>=5.8.0 # optional dependencies - - s3fs>=2022.05.0 - - openpyxl>=3.0.10 + - s3fs>=2022.11.0 + - openpyxl>=3.1.0 - xlrd>=2.0.1 - - sqlalchemy>=1.4.0,<1.4.46 - - scipy>=1.8.1 - - matplotlib>=3.6.1 - - xarray>=2022.03.0 - - pytables>=3.7.0 - - fastparquet>=0.8.1 + - sqlalchemy>=2.0.0 + - scipy>=1.10.0 + - matplotlib>=3.6.3 + - xarray>=2022.12.0 + - pytables>=3.8.0 + - fastparquet>=2022.12.0 # pandas isn't compatible with numexpr=2.8.5: https://github.com/modin-project/modin/issues/6469 - numexpr<2.8.5 diff --git a/requirements/env_unidist_linux.yml b/requirements/env_unidist_linux.yml index 33247eb56cf..2cc61cba822 100644 --- a/requirements/env_unidist_linux.yml +++ b/requirements/env_unidist_linux.yml @@ -5,31 +5,31 @@ dependencies: - pip # required dependencies - - pandas>=2.1,<2.2 + - pandas>=2.2,<2.3 - numpy>=1.22.4 - unidist-mpi>=0.2.1 - mpich - - fsspec>=2022.05.0 + - fsspec>=2022.11.0 - packaging>=21.0 - psutil>=5.8.0 # optional dependencies - pyarrow>=7.0.0 - - xarray>=2022.03.0 + - xarray>=2022.12.0 - jinja2>=3.1.2 - - scipy>=1.8.1 - - s3fs>=2022.05.0 - - lxml>=4.8.0 - - openpyxl>=3.0.10 + - scipy>=1.10.0 + - s3fs>=2022.11.0 + - lxml>=4.9.2 + - openpyxl>=3.1.0 - xlrd>=2.0.1 - - matplotlib>=3.6.1 - - sqlalchemy>=1.4.0,<1.4.46 - - pandas-gbq>=0.15.0 - - pytables>=3.7.0 + - matplotlib>=3.6.3 + - sqlalchemy>=2.0.0 + - pandas-gbq>=0.19.0 + - pytables>=3.8.0 # pymssql==2.2.8 broken: https://github.com/modin-project/modin/issues/6429 - pymssql>=2.1.5,!=2.2.8 - - psycopg2>=2.9.3 - - fastparquet>=0.8.1 + - psycopg2>=2.9.6 + - fastparquet>=2022.12.0 - tqdm>=4.60.0 # pandas isn't compatible with numexpr=2.8.5: https://github.com/modin-project/modin/issues/6469 - numexpr<2.8.5 diff --git a/requirements/env_unidist_win.yml b/requirements/env_unidist_win.yml index 6c93f0c4af5..88ba9ae2b19 100644 --- a/requirements/env_unidist_win.yml +++ b/requirements/env_unidist_win.yml @@ -5,31 +5,31 @@ dependencies: - pip # required dependencies - - pandas>=2.1,<2.2 + - pandas>=2.2,<2.3 - numpy>=1.22.4 - unidist-mpi>=0.2.1 - msmpi - - fsspec>=2022.05.0 + - fsspec>=2022.11.0 - packaging>=21.0 - psutil>=5.8.0 # optional dependencies - pyarrow>=7.0.0 - - xarray>=2022.03.0 + - xarray>=2022.12.0 - jinja2>=3.1.2 - - scipy>=1.8.1 - - s3fs>=2022.05.0 - - lxml>=4.8.0 - - openpyxl>=3.0.10 + - scipy>=1.10.0 + - s3fs>=2022.11.0 + - lxml>=4.9.2 + - openpyxl>=3.1.0 - xlrd>=2.0.1 - - matplotlib>=3.6.1 - - sqlalchemy>=1.4.0,<1.4.46 - - pandas-gbq>=0.15.0 - - pytables>=3.7.0 + - matplotlib>=3.6.3 + - sqlalchemy>=2.0.0 + - pandas-gbq>=0.19.0 + - pytables>=3.8.0 # pymssql==2.2.8 broken: https://github.com/modin-project/modin/issues/6429 - pymssql>=2.1.5,!=2.2.8 - - psycopg2>=2.9.3 - - fastparquet>=0.8.1 + - psycopg2>=2.9.6 + - fastparquet>=2022.12.0 - tqdm>=4.60.0 # pandas isn't compatible with numexpr=2.8.5: https://github.com/modin-project/modin/issues/6469 - numexpr<2.8.5 diff --git a/requirements/requirements-no-engine.yml b/requirements/requirements-no-engine.yml index 1c004a001c0..e48d19dc501 100644 --- a/requirements/requirements-no-engine.yml +++ b/requirements/requirements-no-engine.yml @@ -4,25 +4,25 @@ dependencies: - pip # required dependencies - - pandas>=2.1,<2.2 + - pandas>=2.2,<2.3 - numpy>=1.22.4 - - fsspec>=2022.05.0 + - fsspec>=2022.11.0 - packaging>=21.0 - psutil>=5.8.0 # optional dependencies - pyarrow>=7.0.0 - - xarray>=2022.03.0 + - xarray>=2022.12.0 - jinja2>=3.1.2 - - scipy>=1.8.1 - - s3fs>=2022.05.0 - - lxml>=4.8.0 - - openpyxl>=3.0.10 + - scipy>=1.10.0 + - s3fs>=2022.11.0 + - lxml>=4.9.2 + - openpyxl>=3.1.0 - xlrd>=2.0.1 - - matplotlib>=3.6.1 - - sqlalchemy>=1.4.0,<1.4.46 - - pandas-gbq>=0.15.0 - - pytables>=3.7.0 + - matplotlib>=3.6.3 + - sqlalchemy>=2.0.0 + - pandas-gbq>=0.19.0 + - pytables>=3.8.0 - tqdm>=4.60.0 # pandas isn't compatible with numexpr=2.8.5: https://github.com/modin-project/modin/issues/6469 - numexpr<2.8.5 diff --git a/setup.py b/setup.py index acc32c0f26a..ed5ddc7b902 100644 --- a/setup.py +++ b/setup.py @@ -51,10 +51,10 @@ def make_distribution(self): long_description=long_description, long_description_content_type="text/markdown", install_requires=[ - "pandas>=2.1,<2.2", + "pandas>=2.2,<2.3", "packaging>=21.0", "numpy>=1.22.4", - "fsspec>=2022.05.0", + "fsspec>=2022.11.0", "psutil>=5.8.0", ], extras_require={ From 25d143ff3c6043104bda3c51a204eab626103eb4 Mon Sep 17 00:00:00 2001 From: Anatoly Myachev Date: Sat, 10 Feb 2024 12:52:32 +0100 Subject: [PATCH 175/201] TEST-#0000: fix 'push-to-master / test docs' CI job (#6927) Signed-off-by: Anatoly Myachev --- modin/test/test_docstring_urls.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/modin/test/test_docstring_urls.py b/modin/test/test_docstring_urls.py index 16283ae7871..35a2e114099 100644 --- a/modin/test/test_docstring_urls.py +++ b/modin/test/test_docstring_urls.py @@ -36,6 +36,19 @@ def doc_urls(get_generated_doc_urls): def test_all_urls_exist(doc_urls): broken = [] + # TODO: remove the hack after pandas fixes it + broken_urls = ( + "https://pandas.pydata.org/pandas-docs/version/2.2.0/reference/api/pandas.DataFrame.flags.html", + "https://pandas.pydata.org/pandas-docs/version/2.2.0/reference/api/pandas.Series.info.html", + "https://pandas.pydata.org/pandas-docs/version/2.2.0/reference/api/pandas.DataFrame.isetitem.html", + "https://pandas.pydata.org/pandas-docs/version/2.2.0/reference/api/pandas.Series.swapaxes.html", + "https://pandas.pydata.org/pandas-docs/version/2.2.0/reference/api/pandas.DataFrame.to_numpy.html", + "https://pandas.pydata.org/pandas-docs/version/2.2.0/reference/api/pandas.Series.axes.html", + "https://pandas.pydata.org/pandas-docs/version/2.2.0/reference/api/pandas.Series.divmod.html", + "https://pandas.pydata.org/pandas-docs/version/2.2.0/reference/api/pandas.Series.rdivmod.html", + ) + for broken_url in broken_urls: + doc_urls.remove(broken_url) def _test_url(url): try: From 9ff1c156263f6bb853339ad24261bfe2d82384b8 Mon Sep 17 00:00:00 2001 From: Arun Jose <40291569+arunjose696@users.noreply.github.com> Date: Tue, 13 Feb 2024 13:09:22 +0100 Subject: [PATCH 176/201] FIX-#6879: Convert the right DF to single partition before broadcasting in query_compiler.merge (#6880) Signed-off-by: arunjose696 Signed-off-by: Igoshev, Iaroslav Co-authored-by: Anatoly Myachev Co-authored-by: Igoshev, Iaroslav --- .../dataframe/pandas/dataframe/dataframe.py | 29 +++++++ .../pandas/partitioning/partition_manager.py | 83 +++++++++---------- modin/core/dataframe/pandas/utils.py | 68 ++++++++++++++- .../storage_formats/pandas/query_compiler.py | 6 +- 4 files changed, 136 insertions(+), 50 deletions(-) diff --git a/modin/core/dataframe/pandas/dataframe/dataframe.py b/modin/core/dataframe/pandas/dataframe/dataframe.py index 920b9b18583..e3ad15e1154 100644 --- a/modin/core/dataframe/pandas/dataframe/dataframe.py +++ b/modin/core/dataframe/pandas/dataframe/dataframe.py @@ -2762,6 +2762,35 @@ def explode(self, axis: Union[int, Axis], func: Callable) -> "PandasDataframe": partitions, new_index, new_columns, row_lengths, column_widths ) + def combine(self) -> "PandasDataframe": + """ + Create a single partition PandasDataframe from the partitions of the current dataframe. + + Returns + ------- + PandasDataframe + A single partition PandasDataframe. + """ + partitions = self._partition_mgr_cls.combine(self._partitions) + result = self.__constructor__( + partitions, + index=self.copy_index_cache(), + columns=self.copy_columns_cache(), + row_lengths=( + [sum(self._row_lengths_cache)] + if self._row_lengths_cache is not None + else None + ), + column_widths=( + [sum(self._column_widths_cache)] + if self._column_widths_cache is not None + else None + ), + dtypes=self.copy_dtypes_cache(), + ) + result.synchronize_labels() + return result + @lazy_metadata_decorator(apply_axis="both") def apply_full_axis( self, diff --git a/modin/core/dataframe/pandas/partitioning/partition_manager.py b/modin/core/dataframe/pandas/partitioning/partition_manager.py index 2ccbd1de47f..27def624ca6 100644 --- a/modin/core/dataframe/pandas/partitioning/partition_manager.py +++ b/modin/core/dataframe/pandas/partitioning/partition_manager.py @@ -34,7 +34,7 @@ PersistentPickle, ProgressBar, ) -from modin.core.dataframe.pandas.utils import concatenate +from modin.core.dataframe.pandas.utils import create_pandas_df_from_partitions from modin.core.storage_formats.pandas.utils import compute_chunksize from modin.error_message import ErrorMessage from modin.logging import ClassLogger @@ -781,50 +781,7 @@ def to_pandas(cls, partitions): A pandas DataFrame """ retrieved_objects = cls.get_objects_from_partitions(partitions.flatten()) - if all( - isinstance(obj, (pandas.DataFrame, pandas.Series)) - for obj in retrieved_objects - ): - height, width, *_ = tuple(partitions.shape) + (0,) - # restore 2d array - objs = iter(retrieved_objects) - retrieved_objects = [ - [next(objs) for _ in range(width)] for __ in range(height) - ] - else: - # Partitions do not always contain pandas objects, for example, hdk uses pyarrow tables. - # This implementation comes from the fact that calling `partition.get` - # function is not always equivalent to `partition.to_pandas`. - retrieved_objects = [ - [obj.to_pandas() for obj in part] for part in partitions - ] - if all( - isinstance(part, pandas.Series) for row in retrieved_objects for part in row - ): - axis = 0 - elif all( - isinstance(part, pandas.DataFrame) - for row in retrieved_objects - for part in row - ): - axis = 1 - else: - ErrorMessage.catch_bugs_and_request_email(True) - - def is_part_empty(part): - return part.empty and ( - not isinstance(part, pandas.DataFrame) or (len(part.columns) == 0) - ) - - df_rows = [ - pandas.concat([part for part in row], axis=axis, copy=False) - for row in retrieved_objects - if not all(is_part_empty(part) for part in row) - ] - if len(df_rows) == 0: - return pandas.DataFrame() - else: - return concatenate(df_rows) + return create_pandas_df_from_partitions(retrieved_objects, partitions.shape) @classmethod def to_numpy(cls, partitions, **kwargs): @@ -1141,6 +1098,42 @@ def _apply_func_to_list_of_partitions(cls, func, partitions, **kwargs): preprocessed_func = cls.preprocess_func(func) return [obj.apply(preprocessed_func, **kwargs) for obj in partitions] + @classmethod + def combine(cls, partitions): + """ + Convert a NumPy 2D array of partitions to a NumPy 2D array of a single partition. + + Parameters + ---------- + partitions : np.ndarray + The partitions which have to be converted to a single partition. + + Returns + ------- + np.ndarray + A NumPy 2D array of a single partition. + """ + + def to_pandas_remote(df, partition_shape, *dfs): + """Copy of ``cls.to_pandas()`` method adapted for a remote function.""" + return create_pandas_df_from_partitions( + (df,) + dfs, partition_shape, called_from_remote=True + ) + + preprocessed_func = cls.preprocess_func(to_pandas_remote) + partition_shape = partitions.shape + partitions_flattened = partitions.flatten() + for idx, part in enumerate(partitions_flattened): + if hasattr(part, "force_materialization"): + partitions_flattened[idx] = part.force_materialization() + partition_refs = [ + partition.list_of_blocks[0] for partition in partitions_flattened[1:] + ] + combined_partition = partitions.flat[0].apply( + preprocessed_func, partition_shape, *partition_refs + ) + return np.array([combined_partition]).reshape(1, -1) + @classmethod @wait_computations_if_benchmark_mode def apply_func_to_select_indices( diff --git a/modin/core/dataframe/pandas/utils.py b/modin/core/dataframe/pandas/utils.py index 98304ba89c3..6ecd8cba67b 100644 --- a/modin/core/dataframe/pandas/utils.py +++ b/modin/core/dataframe/pandas/utils.py @@ -17,8 +17,10 @@ import pandas from pandas.api.types import union_categoricals +from modin.error_message import ErrorMessage -def concatenate(dfs): + +def concatenate(dfs, copy=True): """ Concatenate pandas DataFrames with saving 'category' dtype. @@ -28,6 +30,8 @@ def concatenate(dfs): ---------- dfs : list List of pandas DataFrames to concatenate. + copy : bool, default: True + Make explicit copy when creating dataframe. Returns ------- @@ -60,8 +64,66 @@ def concatenate(dfs): i, pandas.Categorical(df.iloc[:, i], categories=union.categories) ) # `ValueError: buffer source array is read-only` if copy==False - if len(dfs) == 1: + if len(dfs) == 1 and copy: # concat doesn't make a copy if len(dfs) == 1, # so do it explicitly return dfs[0].copy() - return pandas.concat(dfs, copy=True) + return pandas.concat(dfs, copy=copy) + + +def create_pandas_df_from_partitions( + partition_data, partition_shape, called_from_remote=False +): + """ + Convert partition data of multiple dataframes to a single dataframe. + + Parameters + ---------- + partition_data : list + List of pandas DataFrames or list of Object references holding pandas DataFrames. + partition_shape : int or tuple + Shape of the partitions NumPy array. + called_from_remote : bool, default: False + Flag used to check if explicit copy should be done in concat. + + Returns + ------- + pandas.DataFrame + A pandas DataFrame. + """ + if all( + isinstance(obj, (pandas.DataFrame, pandas.Series)) for obj in partition_data + ): + height, width, *_ = tuple(partition_shape) + (0,) + # restore 2d array + objs = iter(partition_data) + partition_data = [[next(objs) for _ in range(width)] for __ in range(height)] + else: + # Partitions do not always contain pandas objects, for example, hdk uses pyarrow tables. + # This implementation comes from the fact that calling `partition.get` + # function is not always equivalent to `partition.to_pandas`. + partition_data = [[obj.to_pandas() for obj in part] for part in partition_data] + if all(isinstance(part, pandas.Series) for row in partition_data for part in row): + axis = 0 + elif all( + isinstance(part, pandas.DataFrame) for row in partition_data for part in row + ): + axis = 1 + else: + ErrorMessage.catch_bugs_and_request_email(True) + + def is_part_empty(part): + return part.empty and ( + not isinstance(part, pandas.DataFrame) or (len(part.columns) == 0) + ) + + df_rows = [ + pandas.concat([part for part in row], axis=axis, copy=False) + for row in partition_data + if not all(is_part_empty(part) for part in row) + ] + copy = not called_from_remote + if len(df_rows) == 0: + return pandas.DataFrame() + else: + return concatenate(df_rows, copy) diff --git a/modin/core/storage_formats/pandas/query_compiler.py b/modin/core/storage_formats/pandas/query_compiler.py index 90a3557f067..715efb3c4e8 100644 --- a/modin/core/storage_formats/pandas/query_compiler.py +++ b/modin/core/storage_formats/pandas/query_compiler.py @@ -520,6 +520,7 @@ def merge(self, right, **kwargs): left_index = kwargs.get("left_index", False) right_index = kwargs.get("right_index", False) sort = kwargs.get("sort", False) + right_to_broadcast = right._modin_frame.combine() if how in ["left", "inner"] and left_index is False and right_index is False: kwargs["sort"] = False @@ -620,7 +621,7 @@ def map_func( axis=1, func=map_func, enumerate_partitions=how == "left", - other=right._modin_frame, + other=right_to_broadcast, # We're going to explicitly change the shape across the 1-axis, # so we want for partitioning to adapt as well keep_partitioning=False, @@ -681,6 +682,7 @@ def join(self, right, **kwargs): on = kwargs.get("on", None) how = kwargs.get("how", "left") sort = kwargs.get("sort", False) + right_to_broadcast = right._modin_frame.combine() if how in ["left", "inner"]: @@ -697,7 +699,7 @@ def map_func(left, right, kwargs=kwargs): # pragma: no cover num_splits=merge_partitioning( self._modin_frame, right._modin_frame, axis=1 ), - other=right._modin_frame, + other=right_to_broadcast, ) ) return new_self.sort_rows_by_column_values(on) if sort else new_self From 180143a86402bbd9d7e7f2ec73661bf2271407fd Mon Sep 17 00:00:00 2001 From: Anatoly Myachev Date: Wed, 14 Feb 2024 11:58:06 +0100 Subject: [PATCH 177/201] FEAT-#6832: Implement read_xml_glob, to_xml_glob (#6930) Signed-off-by: Anatoly Myachev --- docs/flow/modin/experimental/pandas.rst | 2 + docs/supported_apis/dataframe_supported.rst | 5 + docs/supported_apis/io_supported.rst | 2 + docs/usage_guide/advanced_usage/index.rst | 2 + .../implementations/pandas_on_dask/io/io.py | 6 + .../dispatching/factories/dispatcher.py | 15 +++ .../dispatching/factories/factories.py | 47 ++++++++ .../implementations/pandas_on_ray/io/io.py | 6 + .../pandas_on_unidist/io/io.py | 6 + modin/core/io/io.py | 14 +++ .../core/io/glob/glob_dispatcher.py | 4 + .../core/storage_formats/pandas/parsers.py | 18 +++ modin/experimental/pandas/__init__.py | 1 + modin/experimental/pandas/io.py | 114 ++++++++++++++++++ modin/experimental/pandas/test/test_io_exp.py | 27 +++++ modin/pandas/accessor.py | 54 +++++++++ modin/pandas/dataframe.py | 42 ++++--- modin/pandas/test/test_io.py | 9 ++ 18 files changed, 354 insertions(+), 20 deletions(-) diff --git a/docs/flow/modin/experimental/pandas.rst b/docs/flow/modin/experimental/pandas.rst index 68acb705980..759b8db183b 100644 --- a/docs/flow/modin/experimental/pandas.rst +++ b/docs/flow/modin/experimental/pandas.rst @@ -15,6 +15,8 @@ Experimental API Reference .. autofunction:: read_pickle_distributed .. autofunction:: read_parquet_glob .. autofunction:: read_json_glob +.. autofunction:: read_xml_glob .. automethod:: modin.pandas.DataFrame.modin::to_pickle_distributed .. automethod:: modin.pandas.DataFrame.modin::to_parquet_glob .. automethod:: modin.pandas.DataFrame.modin::to_json_glob +.. automethod:: modin.pandas.DataFrame.modin::to_xml_glob diff --git a/docs/supported_apis/dataframe_supported.rst b/docs/supported_apis/dataframe_supported.rst index a631c335669..2b3789fc812 100644 --- a/docs/supported_apis/dataframe_supported.rst +++ b/docs/supported_apis/dataframe_supported.rst @@ -414,6 +414,10 @@ default to pandas. | | | | Experimental implementation: | | | | | DataFrame.modin.to_json_glob | +----------------------------+---------------------------+------------------------+----------------------------------------------------+ +| ``to_xml`` | `to_xml`_ | D | | +| | | | Experimental implementation: | +| | | | DataFrame.modin.to_xml_glob | ++----------------------------+---------------------------+------------------------+----------------------------------------------------+ | ``to_latex`` | `to_latex`_ | D | | +----------------------------+---------------------------+------------------------+----------------------------------------------------+ | ``to_orc`` | `to_orc`_ | D | | @@ -651,6 +655,7 @@ default to pandas. .. _`to_hdf`: https://pandas.pydata.org/pandas-docs/stable/reference/api/pandas.DataFrame.to_hdf.html#pandas.DataFrame.to_hdf .. _`to_html`: https://pandas.pydata.org/pandas-docs/stable/reference/api/pandas.DataFrame.to_html.html#pandas.DataFrame.to_html .. _`to_json`: https://pandas.pydata.org/pandas-docs/stable/reference/api/pandas.DataFrame.to_json.html#pandas.DataFrame.to_json +.. _`to_xml`: https://pandas.pydata.org/pandas-docs/stable/reference/api/pandas.DataFrame.to_xml.html#pandas.DataFrame.to_xml .. _`to_latex`: https://pandas.pydata.org/pandas-docs/stable/reference/api/pandas.DataFrame.to_latex.html#pandas.DataFrame.to_latex .. _`to_orc`: https://pandas.pydata.org/docs/reference/api/pandas.DataFrame.to_orc.html#pandas.DataFrame.to_orc .. _`to_parquet`: https://pandas.pydata.org/pandas-docs/stable/reference/api/pandas.DataFrame.to_parquet.html#pandas.DataFrame.to_parquet diff --git a/docs/supported_apis/io_supported.rst b/docs/supported_apis/io_supported.rst index 932562bc647..92c6f88ef2d 100644 --- a/docs/supported_apis/io_supported.rst +++ b/docs/supported_apis/io_supported.rst @@ -51,6 +51,8 @@ default to pandas. | `read_json`_ | P | Implemented for ``lines=True`` | | | | Experimental implementation: read_json_glob | +-------------------+---------------------------------+--------------------------------------------------------+ +| `read_xml` | D | Experimental implementation: read_xml_glob | ++-------------------+---------------------------------+--------------------------------------------------------+ | `read_html`_ | D | | +-------------------+---------------------------------+--------------------------------------------------------+ | `read_clipboard`_ | D | | diff --git a/docs/usage_guide/advanced_usage/index.rst b/docs/usage_guide/advanced_usage/index.rst index 35c910acb01..4f9cd402f2c 100644 --- a/docs/usage_guide/advanced_usage/index.rst +++ b/docs/usage_guide/advanced_usage/index.rst @@ -44,9 +44,11 @@ Modin also supports these experimental APIs on top of pandas that are under acti - :py:func:`~modin.experimental.pandas.read_pickle_distributed` -- read multiple pickle files in a directory - :py:func:`~modin.experimental.pandas.read_parquet_glob` -- read multiple parquet files in a directory - :py:func:`~modin.experimental.pandas.read_json_glob` -- read multiple json files in a directory +- :py:func:`~modin.experimental.pandas.read_xml_glob` -- read multiple xml files in a directory - :py:meth:`~modin.pandas.DataFrame.modin.to_pickle_distributed` -- write to multiple pickle files in a directory - :py:meth:`~modin.pandas.DataFrame.modin.to_parquet_glob` -- write to multiple parquet files in a directory - :py:meth:`~modin.pandas.DataFrame.modin.to_json_glob` -- write to multiple json files in a directory +- :py:meth:`~modin.pandas.DataFrame.modin.to_xml_glob` -- write to multiple xml files in a directory DataFrame partitioning API -------------------------- diff --git a/modin/core/execution/dask/implementations/pandas_on_dask/io/io.py b/modin/core/execution/dask/implementations/pandas_on_dask/io/io.py index 5a9051fe923..e1780833976 100644 --- a/modin/core/execution/dask/implementations/pandas_on_dask/io/io.py +++ b/modin/core/execution/dask/implementations/pandas_on_dask/io/io.py @@ -52,6 +52,7 @@ ExperimentalPandasJsonParser, ExperimentalPandasParquetParser, ExperimentalPandasPickleParser, + ExperimentalPandasXmlParser, ) @@ -105,6 +106,11 @@ def __make_write(*classes, build_args=build_args): ExperimentalGlobDispatcher, build_args={**build_args, "base_write": BaseIO.to_json}, ) + read_xml_glob = __make_read(ExperimentalPandasXmlParser, ExperimentalGlobDispatcher) + to_xml_glob = __make_write( + ExperimentalGlobDispatcher, + build_args={**build_args, "base_write": BaseIO.to_xml}, + ) read_pickle_distributed = __make_read( ExperimentalPandasPickleParser, ExperimentalGlobDispatcher ) diff --git a/modin/core/execution/dispatching/factories/dispatcher.py b/modin/core/execution/dispatching/factories/dispatcher.py index 7ab32b55b0d..195c62b8883 100644 --- a/modin/core/execution/dispatching/factories/dispatcher.py +++ b/modin/core/execution/dispatching/factories/dispatcher.py @@ -316,6 +316,16 @@ def read_json_glob(cls, *args, **kwargs): def to_json_glob(cls, *args, **kwargs): return cls.get_factory()._to_json_glob(*args, **kwargs) + @classmethod + @_inherit_docstrings(factories.PandasOnRayFactory._read_xml_glob) + def read_xml_glob(cls, *args, **kwargs): + return cls.get_factory()._read_xml_glob(*args, **kwargs) + + @classmethod + @_inherit_docstrings(factories.PandasOnRayFactory._to_xml_glob) + def to_xml_glob(cls, *args, **kwargs): + return cls.get_factory()._to_xml_glob(*args, **kwargs) + @classmethod @_inherit_docstrings(factories.PandasOnRayFactory._read_custom_text) def read_custom_text(cls, **kwargs): @@ -331,6 +341,11 @@ def to_csv(cls, *args, **kwargs): def to_json(cls, *args, **kwargs): return cls.get_factory()._to_json(*args, **kwargs) + @classmethod + @_inherit_docstrings(factories.BaseFactory._to_xml) + def to_xml(cls, *args, **kwargs): + return cls.get_factory()._to_xml(*args, **kwargs) + @classmethod @_inherit_docstrings(factories.BaseFactory._to_parquet) def to_parquet(cls, *args, **kwargs): diff --git a/modin/core/execution/dispatching/factories/factories.py b/modin/core/execution/dispatching/factories/factories.py index 736965d177f..4f50995dfd7 100644 --- a/modin/core/execution/dispatching/factories/factories.py +++ b/modin/core/execution/dispatching/factories/factories.py @@ -427,6 +427,20 @@ def _to_json(cls, *args, **kwargs): """ return cls.io_cls.to_json(*args, **kwargs) + @classmethod + def _to_xml(cls, *args, **kwargs): + """ + Write query compiler content to a XML file. + + Parameters + ---------- + *args : args + Arguments to pass to the writer method. + **kwargs : kwargs + Arguments to pass to the writer method. + """ + return cls.io_cls.to_xml(*args, **kwargs) + @classmethod def _to_parquet(cls, *args, **kwargs): """ @@ -596,6 +610,39 @@ def _to_json_glob(cls, *args, **kwargs): ) return cls.io_cls.to_json_glob(*args, **kwargs) + @classmethod + @doc( + _doc_io_method_raw_template, + source="XML files", + params=_doc_io_method_kwargs_params, + ) + def _read_xml_glob(cls, **kwargs): + current_execution = get_current_execution() + if current_execution not in supported_executions: + raise NotImplementedError( + f"`_read_xml_glob()` is not implemented for {current_execution} execution." + ) + return cls.io_cls.read_xml_glob(**kwargs) + + @classmethod + def _to_xml_glob(cls, *args, **kwargs): + """ + Write query compiler content to several XML files. + + Parameters + ---------- + *args : args + Arguments to pass to the writer method. + **kwargs : kwargs + Arguments to pass to the writer method. + """ + current_execution = get_current_execution() + if current_execution not in supported_executions: + raise NotImplementedError( + f"`_to_xml_glob()` is not implemented for {current_execution} execution." + ) + return cls.io_cls.to_xml_glob(*args, **kwargs) + @doc(_doc_factory_class, execution_name="PandasOnRay") class PandasOnRayFactory(BaseFactory): diff --git a/modin/core/execution/ray/implementations/pandas_on_ray/io/io.py b/modin/core/execution/ray/implementations/pandas_on_ray/io/io.py index 29ab26b95f7..1cdfb929a57 100644 --- a/modin/core/execution/ray/implementations/pandas_on_ray/io/io.py +++ b/modin/core/execution/ray/implementations/pandas_on_ray/io/io.py @@ -51,6 +51,7 @@ ExperimentalPandasJsonParser, ExperimentalPandasParquetParser, ExperimentalPandasPickleParser, + ExperimentalPandasXmlParser, ) from ..dataframe import PandasOnRayDataframe @@ -107,6 +108,11 @@ def __make_write(*classes, build_args=build_args): ExperimentalGlobDispatcher, build_args={**build_args, "base_write": RayIO.to_json}, ) + read_xml_glob = __make_read(ExperimentalPandasXmlParser, ExperimentalGlobDispatcher) + to_xml_glob = __make_write( + ExperimentalGlobDispatcher, + build_args={**build_args, "base_write": RayIO.to_xml}, + ) read_pickle_distributed = __make_read( ExperimentalPandasPickleParser, ExperimentalGlobDispatcher ) diff --git a/modin/core/execution/unidist/implementations/pandas_on_unidist/io/io.py b/modin/core/execution/unidist/implementations/pandas_on_unidist/io/io.py index c2480bc0543..1ac29ff9218 100644 --- a/modin/core/execution/unidist/implementations/pandas_on_unidist/io/io.py +++ b/modin/core/execution/unidist/implementations/pandas_on_unidist/io/io.py @@ -51,6 +51,7 @@ ExperimentalPandasJsonParser, ExperimentalPandasParquetParser, ExperimentalPandasPickleParser, + ExperimentalPandasXmlParser, ) from ..dataframe import PandasOnUnidistDataframe @@ -107,6 +108,11 @@ def __make_write(*classes, build_args=build_args): ExperimentalGlobDispatcher, build_args={**build_args, "base_write": UnidistIO.to_json}, ) + read_xml_glob = __make_read(ExperimentalPandasXmlParser, ExperimentalGlobDispatcher) + to_xml_glob = __make_write( + ExperimentalGlobDispatcher, + build_args={**build_args, "base_write": UnidistIO.to_xml}, + ) read_pickle_distributed = __make_read( ExperimentalPandasPickleParser, ExperimentalGlobDispatcher ) diff --git a/modin/core/io/io.py b/modin/core/io/io.py index 1f9d2857b44..7ace5350639 100644 --- a/modin/core/io/io.py +++ b/modin/core/io/io.py @@ -665,6 +665,20 @@ def to_json(cls, obj, path, **kwargs): # noqa: PR01 return obj.to_json(path, **kwargs) + @classmethod + @_inherit_docstrings(pandas.DataFrame.to_xml, apilink="pandas.DataFrame.to_xml") + def to_xml(cls, obj, path_or_buffer, **kwargs): # noqa: PR01 + """ + Convert the object to a XML string. + + For parameters description please refer to pandas API. + """ + ErrorMessage.default_to_pandas("`to_xml`") + if isinstance(obj, BaseQueryCompiler): + obj = obj.to_pandas() + + return obj.to_xml(path_or_buffer, **kwargs) + @classmethod @_inherit_docstrings( pandas.DataFrame.to_parquet, apilink="pandas.DataFrame.to_parquet" diff --git a/modin/experimental/core/io/glob/glob_dispatcher.py b/modin/experimental/core/io/glob/glob_dispatcher.py index 6a1d6415aff..1b17304a06b 100644 --- a/modin/experimental/core/io/glob/glob_dispatcher.py +++ b/modin/experimental/core/io/glob/glob_dispatcher.py @@ -54,6 +54,8 @@ def _read(cls, **kwargs): path_key = "path" elif "path_or_buf" in kwargs: path_key = "path_or_buf" + elif "path_or_buffer" in kwargs: + path_key = "path_or_buffer" filepath_or_buffer = kwargs.pop(path_key) filepath_or_buffer = stringify_path(filepath_or_buffer) if not (isinstance(filepath_or_buffer, str) and "*" in filepath_or_buffer): @@ -123,6 +125,8 @@ def write(cls, qc, **kwargs): path_key = "path" elif "path_or_buf" in kwargs: path_key = "path_or_buf" + elif "path_or_buffer" in kwargs: + path_key = "path_or_buffer" filepath_or_buffer = kwargs.pop(path_key) filepath_or_buffer = stringify_path(filepath_or_buffer) if not ( diff --git a/modin/experimental/core/storage_formats/pandas/parsers.py b/modin/experimental/core/storage_formats/pandas/parsers.py index 66e4ae01e91..c9cc9598b38 100644 --- a/modin/experimental/core/storage_formats/pandas/parsers.py +++ b/modin/experimental/core/storage_formats/pandas/parsers.py @@ -150,6 +150,24 @@ def parse(fname, **kwargs): return _split_result_for_readers(1, num_splits, df) + [length, width] +@doc(_doc_pandas_parser_class, data_type="XML files") +class ExperimentalPandasXmlParser(PandasParser): + @staticmethod + @doc(_doc_parse_func, parameters=_doc_parse_parameters_common) + def parse(fname, **kwargs): + warnings.filterwarnings("ignore") + num_splits = 1 + single_worker_read = kwargs.pop("single_worker_read", None) + df = pandas.read_xml(fname, **kwargs) + if single_worker_read: + return df + + length = len(df) + width = len(df.columns) + + return _split_result_for_readers(1, num_splits, df) + [length, width] + + @doc(_doc_pandas_parser_class, data_type="custom text") class ExperimentalCustomTextParser(PandasParser): @staticmethod diff --git a/modin/experimental/pandas/__init__.py b/modin/experimental/pandas/__init__.py index e8278a7e2ae..34598a8c63b 100644 --- a/modin/experimental/pandas/__init__.py +++ b/modin/experimental/pandas/__init__.py @@ -44,6 +44,7 @@ read_parquet_glob, read_pickle_distributed, read_sql, + read_xml_glob, to_pickle_distributed, ) diff --git a/modin/experimental/pandas/io.py b/modin/experimental/pandas/io.py index 770f46d9d26..7dc19ac4652 100644 --- a/modin/experimental/pandas/io.py +++ b/modin/experimental/pandas/io.py @@ -600,3 +600,117 @@ def to_json_glob( storage_options=storage_options, mode=mode, ) + + +@expanduser_path_arg("path_or_buffer") +def read_xml_glob( + path_or_buffer, + *, + xpath="./*", + namespaces=None, + elems_only=False, + attrs_only=False, + names=None, + dtype=None, + converters=None, + parse_dates=None, + encoding="utf-8", + parser="lxml", + stylesheet=None, + iterparse=None, + compression="infer", + storage_options: StorageOptions = None, + dtype_backend=lib.no_default, +) -> DataFrame: # noqa: PR01 + """ + Read XML document into a DataFrame object. + + This experimental feature provides parallel reading from multiple XML files which are + defined by glob pattern. The files must contain parts of one dataframe, which can be + obtained, for example, by `DataFrame.modin.to_xml_glob` function. + + Returns + ------- + DataFrame + + Notes + ----- + * Only string type supported for `path_or_buffer` argument. + * The rest of the arguments are the same as for `pandas.read_xml`. + """ + from modin.core.execution.dispatching.factories.dispatcher import FactoryDispatcher + + return DataFrame( + query_compiler=FactoryDispatcher.read_xml_glob( + path_or_buffer=path_or_buffer, + xpath=xpath, + namespaces=namespaces, + elems_only=elems_only, + attrs_only=attrs_only, + names=names, + dtype=dtype, + converters=converters, + parse_dates=parse_dates, + encoding=encoding, + parser=parser, + stylesheet=stylesheet, + iterparse=iterparse, + compression=compression, + storage_options=storage_options, + dtype_backend=dtype_backend, + ) + ) + + +@expanduser_path_arg("path_or_buffer") +def to_xml_glob( + self, + path_or_buffer=None, + index=True, + root_name="data", + row_name="row", + na_rep=None, + attr_cols=None, + elem_cols=None, + namespaces=None, + prefix=None, + encoding="utf-8", + xml_declaration=True, + pretty_print=True, + parser="lxml", + stylesheet=None, + compression="infer", + storage_options=None, +) -> None: # noqa: PR01 + """ + Render a DataFrame to an XML document. + + Notes + ----- + * Only string type supported for `path_or_buffer` argument. + * The rest of the arguments are the same as for `pandas.to_xml`. + """ + obj = self + from modin.core.execution.dispatching.factories.dispatcher import FactoryDispatcher + + if isinstance(self, DataFrame): + obj = self._query_compiler + FactoryDispatcher.to_xml_glob( + obj, + path_or_buffer=path_or_buffer, + index=index, + root_name=root_name, + row_name=row_name, + na_rep=na_rep, + attr_cols=attr_cols, + elem_cols=elem_cols, + namespaces=namespaces, + prefix=prefix, + encoding=encoding, + xml_declaration=xml_declaration, + pretty_print=pretty_print, + parser=parser, + stylesheet=stylesheet, + compression=compression, + storage_options=storage_options, + ) diff --git a/modin/experimental/pandas/test/test_io_exp.py b/modin/experimental/pandas/test/test_io_exp.py index 4954ab2a037..cbbe62e7974 100644 --- a/modin/experimental/pandas/test/test_io_exp.py +++ b/modin/experimental/pandas/test/test_io_exp.py @@ -321,6 +321,33 @@ def test_json_glob(tmp_path, filename): df_equals(read_df, df) +@pytest.mark.skipif( + Engine.get() not in ("Ray", "Unidist", "Dask"), + reason=f"{Engine.get()} does not have experimental API", +) +@pytest.mark.parametrize( + "filename", + ["test_xml_glob.xml", "test_xml_glob*.xml"], +) +def test_xml_glob(tmp_path, filename): + data = test_data["int_data"] + df = pd.DataFrame(data) + + filename_param = filename + + with ( + warns_that_defaulting_to_pandas() + if filename_param == "test_xml_glob.xml" + else contextlib.nullcontext() + ): + df.modin.to_xml_glob(str(tmp_path / filename), index=False) + read_df = pd.read_xml_glob(str(tmp_path / filename)) + + # Index get messed up when concatting so we reset it. + read_df = read_df.reset_index(drop=True) + df_equals(read_df, df) + + @pytest.mark.skipif( Engine.get() not in ("Ray", "Unidist", "Dask"), reason=f"{Engine.get()} does not have experimental read_custom_text API", diff --git a/modin/pandas/accessor.py b/modin/pandas/accessor.py index 3ee22a08561..0eb9c20cadf 100644 --- a/modin/pandas/accessor.py +++ b/modin/pandas/accessor.py @@ -344,3 +344,57 @@ def to_json_glob( storage_options=storage_options, mode=mode, ) + + def to_xml_glob( + self, + path_or_buffer=None, + index=True, + root_name="data", + row_name="row", + na_rep=None, + attr_cols=None, + elem_cols=None, + namespaces=None, + prefix=None, + encoding="utf-8", + xml_declaration=True, + pretty_print=True, + parser="lxml", + stylesheet=None, + compression="infer", + storage_options=None, + ) -> None: # noqa: PR01 + """ + Render a DataFrame to an XML document. + + Notes + ----- + * Only string type supported for `path_or_buffer` argument. + * The rest of the arguments are the same as for `pandas.to_xml`. + """ + from modin.experimental.pandas.io import to_xml_glob + + if path_or_buffer is None: + raise NotImplementedError( + "`to_xml_glob` doesn't support path_or_buffer=None, use `to_xml` in that case." + ) + + to_xml_glob( + self._data, + path_or_buffer=path_or_buffer, + index=index, + root_name=root_name, + row_name=row_name, + na_rep=na_rep, + attr_cols=attr_cols, + elem_cols=elem_cols, + namespaces=namespaces, + prefix=prefix, + encoding=encoding, + xml_declaration=xml_declaration, + pretty_print=pretty_print, + parser=parser, + stylesheet=stylesheet, + compression=compression, + storage_options=storage_options, + ) diff --git a/modin/pandas/dataframe.py b/modin/pandas/dataframe.py index 690d4f15423..a8f98287a63 100644 --- a/modin/pandas/dataframe.py +++ b/modin/pandas/dataframe.py @@ -2303,26 +2303,28 @@ def to_xml( compression="infer", storage_options=None, ): - return self.__constructor__( - query_compiler=self._query_compiler.default_to_pandas( - pandas.DataFrame.to_xml, - path_or_buffer=path_or_buffer, - index=index, - root_name=root_name, - row_name=row_name, - na_rep=na_rep, - attr_cols=attr_cols, - elem_cols=elem_cols, - namespaces=namespaces, - prefix=prefix, - encoding=encoding, - xml_declaration=xml_declaration, - pretty_print=pretty_print, - parser=parser, - stylesheet=stylesheet, - compression=compression, - storage_options=storage_options, - ) + from modin.core.execution.dispatching.factories.dispatcher import ( + FactoryDispatcher, + ) + + return FactoryDispatcher.to_xml( + self._query_compiler, + path_or_buffer=path_or_buffer, + index=index, + root_name=root_name, + row_name=row_name, + na_rep=na_rep, + attr_cols=attr_cols, + elem_cols=elem_cols, + namespaces=namespaces, + prefix=prefix, + encoding=encoding, + xml_declaration=xml_declaration, + pretty_print=pretty_print, + parser=parser, + stylesheet=stylesheet, + compression=compression, + storage_options=storage_options, ) def to_timestamp( diff --git a/modin/pandas/test/test_io.py b/modin/pandas/test/test_io.py index e6ae1d1f797..b13fda644b0 100644 --- a/modin/pandas/test/test_io.py +++ b/modin/pandas/test/test_io.py @@ -3208,6 +3208,15 @@ def test_to_latex(): assert modin_df.to_latex() == to_pandas(modin_df).to_latex() +@pytest.mark.filterwarnings(default_to_pandas_ignore_string) +def test_to_xml(): + # `lxml` is a required dependency for `to_xml`, but optional for Modin. + # For some engines we do not install it (like for HDK). + pytest.importorskip("lxml") + modin_df, _ = create_test_dfs(TEST_DATA) + assert modin_df.to_xml() == to_pandas(modin_df).to_xml() + + @pytest.mark.filterwarnings(default_to_pandas_ignore_string) def test_to_period(): index = pandas.DatetimeIndex( From d54dcfd8e4cceecdbf818a48bbc712854dda906e Mon Sep 17 00:00:00 2001 From: Anatoly Myachev Date: Wed, 14 Feb 2024 13:40:17 +0100 Subject: [PATCH 178/201] Release version 0.27.0 (#6931) Signed-off-by: Anatoly Myachev From 56fb47e97cdb382cf0f9c20487c613a908c0580e Mon Sep 17 00:00:00 2001 From: Anatoly Myachev Date: Fri, 16 Feb 2024 15:52:38 +0100 Subject: [PATCH 179/201] TEST-#6932: don't use deprecated 'pandas._testing.makeStringIndex' (#6933) Signed-off-by: Anatoly Myachev --- asv_bench/benchmarks/benchmarks.py | 26 +++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/asv_bench/benchmarks/benchmarks.py b/asv_bench/benchmarks/benchmarks.py index 47920de5811..d352c708383 100644 --- a/asv_bench/benchmarks/benchmarks.py +++ b/asv_bench/benchmarks/benchmarks.py @@ -22,7 +22,6 @@ import math import numpy as np -import pandas._testing as tm from .utils import ( GROUPBY_NGROUPS, @@ -139,8 +138,10 @@ class TimeJoinStringIndex: def setup(self, shapes, sort): assert shapes[0] % 100 == 0, "implementation restriction" - level1 = tm.makeStringIndex(10).values - level2 = tm.makeStringIndex(shapes[0] // 100).values + level1 = IMPL.Index([f"i-{i}" for i in range(10)], dtype=object).values + level2 = IMPL.Index( + [f"i-{i}" for i in range(shapes[0] // 100)], dtype=object + ).values codes1 = np.arange(10).repeat(shapes[0] // 100) codes2 = np.tile(np.arange(shapes[0] // 100), 10) index2 = IMPL.MultiIndex(levels=[level1, level2], codes=[codes1, codes2]) @@ -897,8 +898,12 @@ def setup(self, shape): self.df2 = IMPL.DataFrame( index=range(rows), data=np.random.rand(rows, cols), columns=range(cols) ) - level1 = tm.makeStringIndex(rows // 10).values.repeat(10) - level2 = np.tile(tm.makeStringIndex(10).values, rows // 10) + level1 = IMPL.Index( + [f"i-{i}" for i in range(rows // 10)], dtype=object + ).values.repeat(10) + level2 = np.tile( + IMPL.Index([f"i-{i}" for i in range(10)], dtype=object).values, rows // 10 + ) index = IMPL.MultiIndex.from_arrays([level1, level2]) self.s = IMPL.Series(np.random.randn(rows), index=index) self.s_subset = self.s[::2] @@ -1033,7 +1038,9 @@ def setup(self, shape): temp_df = DataFrame() # dataframe would have cols-1 keys(strings) and one value(int) column for col in range(cols - 1): - temp_df["key" + str(col + 1)] = tm.makeStringIndex(N).values.repeat(K) + temp_df["key" + str(col + 1)] = IMPL.Index( + [f"i-{i}" for i in range(N)], dtype=object + ).values.repeat(K) self.df = IMPL.DataFrame(temp_df) self.df["value"] = np.random.randn(N * K) execute(self.df) @@ -1052,7 +1059,12 @@ class TimeDropDuplicatesSeries: def setup(self, shape): rows = shape[0] - self.series = IMPL.Series(np.tile(tm.makeStringIndex(rows // 10).values, 10)) + self.series = IMPL.Series( + np.tile( + IMPL.Index([f"i-{i}" for i in range(rows // 10)], dtype=object).values, + 10, + ) + ) execute(self.series) def time_drop_dups(self, shape): From 3f9a733c0f876172a04294266fe73b5018e80bbf Mon Sep 17 00:00:00 2001 From: Anatoly Myachev Date: Fri, 16 Feb 2024 17:26:55 +0100 Subject: [PATCH 180/201] FIX-#6936: Fix 'read_parquet' when dataset is created with 'to_parquet' and 'index=False' (#6937) Signed-off-by: Anatoly Myachev --- modin/core/io/column_stores/parquet_dispatcher.py | 1 + modin/pandas/test/test_io.py | 8 ++++++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/modin/core/io/column_stores/parquet_dispatcher.py b/modin/core/io/column_stores/parquet_dispatcher.py index 0b40ea3f16f..dee5ad5243f 100644 --- a/modin/core/io/column_stores/parquet_dispatcher.py +++ b/modin/core/io/column_stores/parquet_dispatcher.py @@ -690,6 +690,7 @@ def build_query_compiler(cls, dataset, columns, index_columns, **kwargs): if ( dataset.pandas_metadata and "column_indexes" in dataset.pandas_metadata + and len(dataset.pandas_metadata["column_indexes"]) == 1 and dataset.pandas_metadata["column_indexes"][0]["numpy_type"] == "int64" ): columns = pandas.Index(columns).astype("int64").to_list() diff --git a/modin/pandas/test/test_io.py b/modin/pandas/test/test_io.py index b13fda644b0..4cde4d8a5ed 100644 --- a/modin/pandas/test/test_io.py +++ b/modin/pandas/test/test_io.py @@ -2029,15 +2029,19 @@ def test_read_parquet_5767(self, tmp_path, engine): # both Modin and pandas read column "b" as a category df_equals(test_df, read_df.astype("int64")) - def test_read_parquet_6855(self, tmp_path, engine): + @pytest.mark.parametrize("index", [False, True]) + def test_read_parquet_6855(self, tmp_path, engine, index): if engine == "fastparquet": pytest.skip("integer columns aren't supported") test_df = pandas.DataFrame(np.random.rand(10**2, 10)) path = tmp_path / "data" path.mkdir() file_name = "issue6855.parquet" - test_df.to_parquet(path / file_name, engine=engine) + test_df.to_parquet(path / file_name, index=index, engine=engine) read_df = pd.read_parquet(path / file_name, engine=engine) + if not index: + # In that case pyarrow cannot preserve index dtype + read_df.columns = pandas.Index(read_df.columns).astype("int64").to_list() df_equals(test_df, read_df) def test_read_parquet_s3_with_column_partitioning( From d020aacd5cfdf9d7938d70bd65ab8286ca5bc8cb Mon Sep 17 00:00:00 2001 From: Dmitry Chigarev Date: Fri, 16 Feb 2024 18:47:27 +0100 Subject: [PATCH 181/201] REFACTOR-#6939: Make 'modin.pandas.DataFrame._to_pandas' a public method (#6940) Signed-off-by: Dmitry Chigarev --- modin/pandas/dataframe.py | 2 ++ modin/pandas/io.py | 4 ++-- modin/pandas/series.py | 2 ++ modin/pandas/test/test_api.py | 9 +++++---- modin/utils.py | 17 ++--------------- 5 files changed, 13 insertions(+), 21 deletions(-) diff --git a/modin/pandas/dataframe.py b/modin/pandas/dataframe.py index a8f98287a63..5867e76663c 100644 --- a/modin/pandas/dataframe.py +++ b/modin/pandas/dataframe.py @@ -3026,6 +3026,8 @@ def _to_pandas(self): """ return self._query_compiler.to_pandas() + to_pandas = _to_pandas + def _validate_eval_query(self, expr, **kwargs): """ Validate the arguments of ``eval`` and ``query`` functions. diff --git a/modin/pandas/io.py b/modin/pandas/io.py index 1c67fa7604d..b6ee084271b 100644 --- a/modin/pandas/io.py +++ b/modin/pandas/io.py @@ -69,8 +69,8 @@ from modin.logging import ClassLogger, enable_logging from modin.utils import ( SupportsPrivateToNumPy, - SupportsPrivateToPandas, SupportsPublicToNumPy, + SupportsPublicToPandas, _inherit_docstrings, classproperty, expanduser_path_arg, @@ -1034,7 +1034,7 @@ def from_dataframe(df): return ModinObjects.DataFrame(query_compiler=FactoryDispatcher.from_dataframe(df)) -def to_pandas(modin_obj: SupportsPrivateToPandas) -> Any: +def to_pandas(modin_obj: SupportsPublicToPandas) -> Any: """ Convert a Modin DataFrame/Series to a pandas DataFrame/Series. diff --git a/modin/pandas/series.py b/modin/pandas/series.py index 0469f39cdd9..3219961d56d 100644 --- a/modin/pandas/series.py +++ b/modin/pandas/series.py @@ -2225,6 +2225,8 @@ def _to_pandas(self): series.name = None return series + to_pandas = _to_pandas + def _to_datetime(self, **kwargs): """ Convert `self` to datetime. diff --git a/modin/pandas/test/test_api.py b/modin/pandas/test/test_api.py index 873ad77728d..c920f726880 100644 --- a/modin/pandas/test/test_api.py +++ b/modin/pandas/test/test_api.py @@ -152,7 +152,7 @@ def test_dataframe_api_equality(): ignore_in_pandas = ["timetuple"] # modin - namespace for using experimental functionality - ignore_in_modin = ["modin"] + ignore_in_modin = ["modin", "to_pandas"] missing_from_modin = set(pandas_dir) - set(modin_dir) assert not len( missing_from_modin - set(ignore_in_pandas) @@ -164,7 +164,7 @@ def test_dataframe_api_equality(): ), "Differences found in API: {}".format(set(modin_dir) - set(pandas_dir)) # These have to be checked manually - allowed_different = ["to_hdf", "hist", "modin"] + allowed_different = ["to_hdf", "hist", "modin", "to_pandas"] assert_parameters_eq((pandas.DataFrame, pd.DataFrame), modin_dir, allowed_different) @@ -267,13 +267,14 @@ def test_series_api_equality(): assert not len(missing_from_modin), "Differences found in API: {}".format( missing_from_modin ) - extra_in_modin = set(modin_dir) - set(pandas_dir) + ignore_in_modin = ["to_pandas"] + extra_in_modin = set(modin_dir) - set(ignore_in_modin) - set(pandas_dir) assert not len(extra_in_modin), "Differences found in API: {}".format( extra_in_modin ) # These have to be checked manually - allowed_different = ["to_hdf", "hist"] + allowed_different = ["to_hdf", "hist", "to_pandas"] assert_parameters_eq((pandas.Series, pd.Series), modin_dir, allowed_different) diff --git a/modin/utils.py b/modin/utils.py index 0a19c8c9411..bfb3885dd61 100644 --- a/modin/utils.py +++ b/modin/utils.py @@ -58,15 +58,6 @@ """Function type parameter (used in decorators that don't change a function's signature)""" -@runtime_checkable -class SupportsPrivateToPandas(Protocol): # noqa: PR01 - """Structural type for objects with a ``_to_pandas`` method (note the leading underscore).""" - - def _to_pandas(self) -> Any: # noqa: GL08 - # TODO add proper return type - pass - - @runtime_checkable class SupportsPublicToPandas(Protocol): # noqa: PR01 """Structural type for objects with a ``to_pandas`` method (without a leading underscore).""" @@ -576,16 +567,12 @@ def try_cast_to_pandas(obj: Any, squeeze: bool = False) -> Any: object Converted object. """ - if isinstance(obj, SupportsPrivateToPandas): - result = obj._to_pandas() - if squeeze: - result = result.squeeze(axis=1) - return result if isinstance(obj, SupportsPublicToPandas): result = obj.to_pandas() if squeeze: result = result.squeeze(axis=1) - # Query compiler case, it doesn't have logic about convertion to Series + + # QueryCompiler/low-level ModinFrame case, it doesn't have logic about convertion to Series if ( isinstance(getattr(result, "name", None), str) and result.name == MODIN_UNNAMED_SERIES_LABEL From 3615811f2412225be098d73d3a5884b87c1eb07a Mon Sep 17 00:00:00 2001 From: Kirill Suvorov Date: Mon, 19 Feb 2024 15:48:17 +0100 Subject: [PATCH 182/201] DOCS-#6871: Update Modin on Ray cluster tutorial (#6872) Co-authored-by: Iaroslav Igoshev Signed-off-by: Kirill Suvorov --- .../using_modin/using_modin_cluster.rst | 164 +++++++-------- .../execution/pandas_on_ray/cluster/README.md | 39 ++++ .../pandas_on_ray/cluster/exercise_5.ipynb | 146 ------------- .../pandas_on_ray/cluster/exercise_5.py | 20 ++ .../pandas_on_ray/cluster/exercise_6.ipynb | 186 ----------------- .../pandas_on_ray/cluster/modin-cluster.yaml | 197 ++++++++++-------- .../jupyter/img/modin_cluster_perf.png | Bin 51080 -> 0 bytes 7 files changed, 252 insertions(+), 500 deletions(-) create mode 100644 examples/tutorial/jupyter/execution/pandas_on_ray/cluster/README.md delete mode 100644 examples/tutorial/jupyter/execution/pandas_on_ray/cluster/exercise_5.ipynb create mode 100644 examples/tutorial/jupyter/execution/pandas_on_ray/cluster/exercise_5.py delete mode 100644 examples/tutorial/jupyter/execution/pandas_on_ray/cluster/exercise_6.ipynb delete mode 100644 examples/tutorial/jupyter/img/modin_cluster_perf.png diff --git a/docs/getting_started/using_modin/using_modin_cluster.rst b/docs/getting_started/using_modin/using_modin_cluster.rst index 3bf9f5996af..adf87f722d0 100644 --- a/docs/getting_started/using_modin/using_modin_cluster.rst +++ b/docs/getting_started/using_modin/using_modin_cluster.rst @@ -1,132 +1,130 @@ -======================== Using Modin in a Cluster ======================== .. note:: | *Estimated Reading Time: 15 minutes* - | You can follow along in a Jupyter notebook in this two-part tutorial: `Part 1`_, `Part 2`_. -Often in practice we have a need to exceed the capabilities of a single machine. Modin -works and performs well in both local mode and in a cluster environment. The key -advantage of Modin is that your notebook does not change between local development and -cluster execution. Users are not required to think about how many workers exist or how -to distribute and partition their data; Modin handles all of this seamlessly and -transparently. +Often in practice we have a need to exceed the capabilities of a single machine. +Modin works and performs well in both local mode and in a cluster environment. +The key advantage of Modin is that your python code does not change between +local development and cluster execution. Users are not required to think about +how many workers exist or how to distribute and partition their data; +Modin handles all of this seamlessly and transparently. + +.. note:: + It is possible to use a Jupyter notebook, but you will have to deploy a Jupyter server + on the remote cluster head node and connect to it. -Starting up a Ray Cluster -------------------------- -Modin is able to utilize Ray's built-in autoscaled cluster. To launch a Ray cluster -using Amazon Web Service (AWS), you can use `Modin's cluster setup config`_ -(`Ray's autoscaler options`_). +.. image:: ../../img/modin_cluster.png + :alt: Modin cluster + :align: center + +Extra requirements for AWS authentication +----------------------------------------- + +First of all, install the necessary dependencies in your environment: .. code-block:: bash pip install boto3 - aws configure -To start up the Ray cluster, run the following command in your terminal: +The next step is to setup your AWS credentials. One can set ``AWS_ACCESS_KEY_ID``, +``AWS_SECRET_ACCESS_KEY`` and ``AWS_SESSION_TOKEN`` (Optional) +(refer to `AWS CLI environment variables`_ to get more insight on this) or +just run the following command: .. code-block:: bash - ray up modin-cluster.yaml + aws configure -This configuration script starts 1 head node (m5.24xlarge) and 7 workers (m5.24xlarge), -768 total CPUs. For more information on how to launch a Ray cluster across different -cloud providers or on-premise, you can also refer to the `Ray's cluster docs`_. +Starting and connecting to the cluster +-------------------------------------- -.. note:: - By default, Modin on Ray uses 60% of the system memory. It is recommended to use the same - amount, when using your own cluster (for each node). +This example starts 1 head node (m5.24xlarge) and 5 worker nodes (m5.24xlarge), 576 total CPUs. +You can check the `Amazon EC2 pricing`_ page. + +It is possble to manually create AWS EC2 instances and configure them or just use the `Ray CLI`_ to +create and initialize a Ray cluster on AWS using `Modin's Ray cluster setup config`_, +which we are going to utilize in this example. +Refer to `Ray's autoscaler options`_ page on how to modify the file. + +More details on how to launch a Ray cluster can be found on `Ray's cluster docs`_. -Connecting to a Ray Cluster ---------------------------- +To start up the Ray cluster, run the following command in your terminal: + +.. code-block:: bash -To connect to the Ray cluster, run the following command in your terminal: + ray up modin-cluster.yaml + +Once the head node has completed initialization, you can optionally connect to it by running the following command. .. code-block:: bash ray attach modin-cluster.yaml -The following code checks that the Ray cluster is properly configured and attached to -Modin: +To exit the ssh session and return back into your local shell session, type: -.. code-block:: python +.. code-block:: bash - import ray - ray.init(address="auto") - from modin.config import NPartitions - assert NPartitions.get() == 768, "Not all Ray nodes are started up yet" - ray.shutdown() + exit -Congratualions! You have successfully connected to the Ray cluster. +Executing in a cluster environment +---------------------------------- .. note:: - Be careful when using the Ray client to connect to a remote cluster. - This connection mode may not work. Known bugs: + Be careful when using the `Ray client`_ to connect to a remote cluster. + We don't recommend this connection mode, beacuse it may not work. Known bugs: - https://github.com/ray-project/ray/issues/38713, - https://github.com/modin-project/modin/issues/6641. -Using Modin on a Ray Cluster ----------------------------- - -Now that we have a Ray cluster up and running, we can use Modin to perform pandas -operation as if we were working with pandas on a single machine. We test Modin's -performance on the 200MB `NYC Taxi dataset`_ that was provided as part of our -`Modin's cluster setup config`_. We can time the following operation in a Jupyter -notebook: +Modin lets you instantly speed up your workflows with a large data by scaling pandas +on a cluster. In this tutorial, we will use a 12.5 GB ``big_yellow.csv`` file that was +created by concatenating a 200MB `NYC Taxi dataset`_ file 64 times. Preparing this +file was provided as part of our `Modin's Ray cluster setup config`_. -.. code-block:: python +If you want to use the other dataset, you should provide it to each of +the cluster nodes with the same path. We recomnend doing this by customizing the +``setup_commands`` section of the `Modin's Ray cluster setup config`_. - %%time - df = pd.read_csv("big_yellow.csv", quoting=3) +To run any script in a remote cluster, you need to submit it to the Ray. In this way, +the script file is sent to the the remote cluster head node and executed there. - %%time - count_result = df.count() +In this tutorial, we provide the `exercise_5.py`_ script, which reads the data from the +CSV file and executes such pandas operations as count, groupby and map. +As the result, you will see the size of the file being read and the execution time of the entire script. - %%time - groupby_result = df.groupby("passenger_count").count() +You can submit this script to the existing remote cluster by running the following command. - %%time - apply_result = df.map(str) - -.. note:: - When using local paths, make sure that they are available on all nodes in the - cluster, for example using distributed file system like NFS. +.. code-block:: bash -Modin performance scales as the number of nodes and cores increases. The following -chart shows the performance of the above operations with 2, 4, and 8 nodes, with -improvements in performance as we increase the number of resources Modin can use. + ray submit modin-cluster.yaml exercise_5.py -.. image:: ../../../examples/tutorial/jupyter/img/modin_cluster_perf.png - :alt: Cluster Performance - :align: center - :scale: 90% +To download or upload files to the cluster head node, use ``ray rsync_down`` or ``ray rsync_up``. +It may help if you want to use some other Python modules that should be available to +execute your own script or download a result file after executing the script. -Advanced: Configuring your Ray Environment ------------------------------------------- +.. code-block:: bash -In some cases, it may be useful to customize your Ray environment. Below, we have listed -a few ways you can solve common problems in data management with Modin by customizing -your Ray environment. It is possible to use any of Ray's initialization parameters, -which are all found in `Ray's API docs`_. + # download a file from the cluster to the local machine: + ray rsync_down modin-cluster.yaml '/path/on/cluster' '/local/path' + # upload a file from the local machine to the cluster: + ray rsync_up modin-cluster.yaml '/local/path' '/path/on/cluster' -.. code-block:: python +Shutting down the cluster +-------------------------- - import ray - ray.init() - import modin.pandas as pd +Now that we have finished the computation, we need to shut down the cluster with `ray down` command. -Modin will automatically connect to the Ray instance that is already running. This way, -you can customize your Ray environment for use in Modin! +.. code-block:: bash + ray down modin-cluster.yaml -.. _`DataFrame`: https://pandas.pydata.org/pandas-docs/stable/reference/api/pandas.DataFrame.html -.. _`pandas`: https://pandas.pydata.org/pandas-docs/stable/ -.. _`open an issue`: https://github.com/modin-project/modin/issues -.. _`Ray's API docs`: https://ray.readthedocs.io/en/latest/api.html -.. _`Part 1`: https://github.com/modin-project/modin/tree/master/examples/tutorial/jupyter/execution/pandas_on_ray/cluster/exercise_5.ipynb -.. _`Part 2`: https://github.com/modin-project/modin/tree/master/examples/tutorial/jupyter/execution/pandas_on_ray/cluster/exercise_6.ipynb .. _`Ray's autoscaler options`: https://docs.ray.io/en/latest/cluster/vms/references/ray-cluster-configuration.html#cluster-config .. _`Ray's cluster docs`: https://docs.ray.io/en/latest/cluster/getting-started.html .. _`NYC Taxi dataset`: https://modin-datasets.intel.com/testing/yellow_tripdata_2015-01.csv -.. _`Modin's cluster setup config`: https://github.com/modin-project/modin/blob/master/examples/tutorial/jupyter/execution/pandas_on_ray/cluster/modin-cluster.yaml +.. _`Modin's Ray cluster setup config`: https://github.com/modin-project/modin/blob/master/examples/tutorial/jupyter/execution/pandas_on_ray/cluster/modin-cluster.yaml +.. _`Amazon EC2 pricing`: https://aws.amazon.com/ec2/pricing/on-demand/ +.. _`exercise_5.py`: https://github.com/modin-project/modin/blob/master/examples/tutorial/jupyter/execution/pandas_on_ray/cluster/exercise_5.py +.. _`Ray client`: https://docs.ray.io/en/latest/cluster/running-applications/job-submission/ray-client.html +.. _`Ray CLI`: https://docs.ray.io/en/latest/cluster/vms/getting-started.html#running-applications-on-a-ray-cluster +.. _`AWS CLI environment variables`: https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-envvars.html \ No newline at end of file diff --git a/examples/tutorial/jupyter/execution/pandas_on_ray/cluster/README.md b/examples/tutorial/jupyter/execution/pandas_on_ray/cluster/README.md new file mode 100644 index 00000000000..b6d13e7d312 --- /dev/null +++ b/examples/tutorial/jupyter/execution/pandas_on_ray/cluster/README.md @@ -0,0 +1,39 @@ +![LOGO](../../../img/MODIN_ver2_hrz.png) + +
+

Scale your pandas workflows on a Ray cluster

+
+ +**NOTE**: Before starting the exercise, please read the full instructions in the +[Modin documenation](https://modin.readthedocs.io/en/latest/getting_started/using_modin/using_modin_cluster.html). + +The basic steps to run the script on a remote Ray cluster are: + +Step 1. Install the necessary dependencies + +```bash +pip install boto3 +``` + +Step 2. Setup your AWS credentials. + +```bash +aws configure +``` + +Step 3. Modify configuration file and start up the Ray cluster. + +```bash +ray up modin-cluster.yaml +``` + +Step 4. Submit your script to the remote cluster. + +```bash +ray submit modin-cluster.yaml exercise_5.py +``` + +Step 5. Shut down the Ray remote cluster. + +```bash +ray down diff --git a/examples/tutorial/jupyter/execution/pandas_on_ray/cluster/exercise_5.ipynb b/examples/tutorial/jupyter/execution/pandas_on_ray/cluster/exercise_5.ipynb deleted file mode 100644 index 670fa769a0f..00000000000 --- a/examples/tutorial/jupyter/execution/pandas_on_ray/cluster/exercise_5.ipynb +++ /dev/null @@ -1,146 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "![LOGO](../../../img/MODIN_ver2_hrz.png)\n", - "\n", - "

Scale your pandas workflows by changing one line of code

\n" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Exercise 5: Setting up cluster environment\n", - "\n", - "**GOAL**: Learn how to set up a cluster for Modin.\n", - "\n", - "**NOTE**: This exercise has extra requirements. Read instructions carefully before attempting. \n", - "\n", - "**This exercise instructs the user on how to start a 700+ core cluster, and it is not shut down until the end of Exercise 5. Read instructions carefully.**" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Often in practice we have a need to exceed the capabilities of a single machine. Modin works and performs well in both local mode and in a cluster environment. The key advantage of Modin is that your notebook does not change between local development and cluster execution. Users are not required to think about how many workers exist or how to distribute and partition their data; Modin handles all of this seamlessly and transparently.\n", - "\n", - "![Cluster](../../../img/modin_cluster.png)\n", - "\n", - "**Extra Requirements for this exercise**\n", - "\n", - "Detailed instructions can be found here: https://docs.ray.io/en/latest/cluster/cloud.html\n", - "\n", - "From command line:\n", - "- `pip install boto3`\n", - "- `aws configure`\n", - "- `ray up modin-cluster.yaml`\n", - "\n", - "Included in this directory is a file named [`modin-cluster.yaml`](https://github.com/modin-project/modin/blob/master/examples/tutorial/jupyter/execution/pandas_on_ray/cluster/modin-cluster.yaml). We will use this to start the cluster." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# !pip install boto3" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# !aws configure" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Starting and connecting to the cluster\n", - "\n", - "This example starts 1 head node (m5.24xlarge) and 7 workers (m5.24xlarge), 768 total CPUs.\n", - "\n", - "Cost of this cluster can be found here: https://aws.amazon.com/ec2/pricing/on-demand/." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# !ray up modin-cluster.yaml" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Connect to the cluster with `ray attach`" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# !ray attach modin-cluster.yaml" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# DO NOT CHANGE THIS CODE!\n", - "# Changing this code risks breaking further exercises\n", - "\n", - "import time\n", - "time.sleep(600) # We need to give ray enough time to start up all the workers\n", - "import ray\n", - "ray.init(address=\"auto\")\n", - "from modin.config import NPartitions\n", - "assert NPartitions.get() == 768, \"Not all Ray nodes are started up yet\"\n", - "ray.shutdown()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "**Please move on to [Exercise 6](./exercise_6.ipynb) when you are ready**" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3 (ipykernel)", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.9.9" - } - }, - "nbformat": 4, - "nbformat_minor": 2 -} diff --git a/examples/tutorial/jupyter/execution/pandas_on_ray/cluster/exercise_5.py b/examples/tutorial/jupyter/execution/pandas_on_ray/cluster/exercise_5.py new file mode 100644 index 00000000000..d68d85764ee --- /dev/null +++ b/examples/tutorial/jupyter/execution/pandas_on_ray/cluster/exercise_5.py @@ -0,0 +1,20 @@ +import time +import ray + +import modin.pandas as pd + +ray.init(address="auto") +cpu_count = ray.cluster_resources()["CPU"] +assert cpu_count == 576, f"Expected 576 CPUs, but found {cpu_count}" + +file_path = "big_yellow.csv" + +t0 = time.perf_counter() + +df = pd.read_csv(file_path, quoting=3) +df_count = df.count() +df_groupby_count = df.groupby("passenger_count").count() +df_map = df.map(str) + +t1 = time.perf_counter() +print(f"Full script time is {(t1 - t0):.3f}") # noqa: T201 diff --git a/examples/tutorial/jupyter/execution/pandas_on_ray/cluster/exercise_6.ipynb b/examples/tutorial/jupyter/execution/pandas_on_ray/cluster/exercise_6.ipynb deleted file mode 100644 index dd72518737c..00000000000 --- a/examples/tutorial/jupyter/execution/pandas_on_ray/cluster/exercise_6.ipynb +++ /dev/null @@ -1,186 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "![LOGO](../../../img/MODIN_ver2_hrz.png)\n", - "\n", - "

Scale your pandas workflows by changing one line of code

\n" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Exercise 6: Executing on a cluster environment\n", - "\n", - "**GOAL**: Learn how to connect Modin to a Ray cluster and run pandas queries on a cluster.\n", - "\n", - "**NOTE**: Exercise 5 must be completed first, this exercise relies on the cluster created in Exercise 5." - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Modin performance scales as the number of nodes and cores increases. In this exercise, we will reproduce the data from the plot below using the 200MB [NYC Taxi dataset](https://modin-datasets.intel.com/testing/yellow_tripdata_2015-01.csv) that was provided as part of our [modin-cluster.yaml script](https://github.com/modin-project/modin/blob/master/examples/tutorial/jupyter/execution/pandas_on_ray/cluster/modin-cluster.yaml).\n", - "\n", - "![ClusterPerf](../../../img/modin_cluster_perf.png)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# Don't change this cell!\n", - "import ray\n", - "ray.init(address=\"auto\")\n", - "import modin.pandas as pd\n", - "from modin.config import NPartitions\n", - "if NPartitions.get() != 768:\n", - " print(\"This notebook was designed and tested for an 8 node Ray cluster. \"\n", - " \"Proceed at your own risk!\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "!du -h big_yellow.csv" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "%%time\n", - "df = pd.read_csv(\"big_yellow.csv\", quoting=3)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "%%time\n", - "count_result = df.count()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# print\n", - "count_result" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "%%time\n", - "groupby_result = df.groupby(\"passenger_count\").count()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# print\n", - "groupby_result" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "%%time\n", - "apply_result = df.applymap(str)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# print\n", - "apply_result" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "ray.shutdown()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Shutting down the cluster\n", - "\n", - "**You may have to change the path below**. If this does not work, log in to your \n", - "\n", - "Now that we have finished computation, we can shut down the cluster with `ray down`." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "!ray down modin-cluster.yaml" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### This ends the cluster exercise" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.6.8" - } - }, - "nbformat": 4, - "nbformat_minor": 2 -} diff --git a/examples/tutorial/jupyter/execution/pandas_on_ray/cluster/modin-cluster.yaml b/examples/tutorial/jupyter/execution/pandas_on_ray/cluster/modin-cluster.yaml index 78b3be39daa..70c9d557f18 100644 --- a/examples/tutorial/jupyter/execution/pandas_on_ray/cluster/modin-cluster.yaml +++ b/examples/tutorial/jupyter/execution/pandas_on_ray/cluster/modin-cluster.yaml @@ -1,50 +1,36 @@ # An unique identifier for the head node and workers of this cluster. cluster_name: modin_init -# The minimum number of workers nodes to launch in addition to the head -# node. This number should be >= 0. -min_workers: 7 - # The maximum number of workers nodes to launch in addition to the head -# node. This takes precedence over min_workers. -max_workers: 7 - -# The initial number of worker nodes to launch in addition to the head -# node. When the cluster is first brought up (or when it is refreshed with a -# subsequent `ray up`) this number of nodes will be started. -initial_workers: 7 +# node. +max_workers: 5 -# Whether or not to autoscale aggressively. If this is enabled, if at any point -# we would start more workers, we start at least enough to bring us to -# initial_workers. -autoscaling_mode: default +# The autoscaler will scale up the cluster faster with higher upscaling speed. +# E.g., if the task requires adding more nodes then autoscaler will gradually +# scale up the cluster in chunks of upscaling_speed*currently_running_nodes. +# This number should be > 0. +upscaling_speed: 1.0 # This executes all commands on all nodes in the docker container, # and opens all the necessary ports to support the Ray cluster. # Empty string means disabled. docker: - image: "" # e.g., rayproject/ray:0.8.7 - container_name: "" # e.g. ray_docker + # image: "rayproject/ray-ml:latest-gpu" # You can change this to latest-cpu if you don't need GPU support and want a faster startup + image: rayproject/ray:latest-cpu # use this one if you don't need ML dependencies, it's faster to pull + container_name: "ray_container" # If true, pulls latest version of image. Otherwise, `docker run` will only pull the image # if no cached version is present. pull_before_run: True - run_options: [] # Extra options to pass into "docker run" + run_options: # Extra options to pass into "docker run" + - --ulimit nofile=65536:65536 # Example of running a GPU head with CPU workers - # head_image: "rayproject/ray:0.8.7-gpu" - # head_run_options: - # - --runtime=nvidia + # head_image: "rayproject/ray-ml:latest-gpu" + # Allow Ray to automatically detect GPUs - # worker_image: "rayproject/ray:0.8.7" + # worker_image: "rayproject/ray-ml:latest-cpu" # worker_run_options: [] -# The autoscaler will scale up the cluster to this target fraction of resource -# usage. For example, if a cluster of 10 nodes is 100% busy and -# target_utilization is 0.8, it would resize the cluster to 13. This fraction -# can be decreased to increase the aggressiveness of upscaling. -# This max value allowed is 1.0, which is the most conservative setting. -target_utilization_fraction: 0.8 - # If a node is idle for this many minutes, it will be removed. idle_timeout_minutes: 5 @@ -53,12 +39,12 @@ provider: type: aws region: us-west-2 # Availability zone(s), comma-separated, that nodes may be launched in. - # Nodes are currently spread between zones by a round-robin approach, - # however this implementation detail should not be relied upon. + # Nodes will be launched in the first listed availability zone and will + # be tried in the subsequent availability zones if launching fails. availability_zone: us-west-2a,us-west-2b # Whether to allow node reuse. If set to False, nodes will be terminated # instead of stopped. - cache_stopped_nodes: True # If not present, the default is True. + cache_stopped_nodes: False # If not present, the default is True. # How Ray will authenticate with newly launched nodes. auth: @@ -68,42 +54,76 @@ auth: # configurations below. # ssh_private_key: /path/to/your/key.pem -# Provider-specific config for the head node, e.g. instance type. By default -# Ray will auto-configure unspecified fields such as SubnetId and KeyName. -# For more documentation on available fields, see: -# http://boto3.readthedocs.io/en/latest/reference/services/ec2.html#EC2.ServiceResource.create_instances -head_node: - InstanceType: m5.24xlarge - ImageId: ami-0a2363a9cff180a64 # Deep Learning AMI (Ubuntu) Version 30 - - # You can provision additional disk space with a conf as follows - BlockDeviceMappings: - - DeviceName: /dev/sda1 - Ebs: - VolumeSize: 500 - - # Additional options in the boto docs. - -# Provider-specific config for worker nodes, e.g. instance type. By default -# Ray will auto-configure unspecified fields such as SubnetId and KeyName. -# For more documentation on available fields, see: -# http://boto3.readthedocs.io/en/latest/reference/services/ec2.html#EC2.ServiceResource.create_instances -worker_nodes: - InstanceType: m5.24xlarge - ImageId: ami-0a2363a9cff180a64 # Deep Learning AMI (Ubuntu) Version 30 - - BlockDeviceMappings: - - DeviceName: /dev/sda1 - Ebs: - VolumeSize: 500 - # Run workers on spot by default. Comment this out to use on-demand. - # InstanceMarketOptions: - # MarketType: spot - # Additional options can be found in the boto docs, e.g. - # SpotOptions: - # MaxPrice: MAX_HOURLY_PRICE - - # Additional options in the boto docs. +# Tell the autoscaler the allowed node types and the resources they provide. +# The key is the name of the node type, which is just for debugging purposes. +# The node config specifies the launch config and physical instance type. +available_node_types: + ray.head.default: + # The node type's CPU and GPU resources are auto-detected based on AWS instance type. + # If desired, you can override the autodetected CPU and GPU resources advertised to the autoscaler. + # You can also set custom resources. + # For example, to mark a node type as having 1 CPU, 1 GPU, and 5 units of a resource called "custom", set + # resources: {"CPU": 1, "GPU": 1, "custom": 5} + resources: {} + # Provider-specific config for this node type, e.g. instance type. By default + # Ray will auto-configure unspecified fields such as SubnetId and KeyName. + # For more documentation on available fields, see: + # http://boto3.readthedocs.io/en/latest/reference/services/ec2.html#EC2.ServiceResource.create_instances + node_config: + InstanceType: m5.24xlarge + # Default AMI for us-west-2. + # Check https://github.com/ray-project/ray/blob/master/python/ray/autoscaler/_private/aws/config.py + # for default images for other zones. + ImageId: ami-0387d929287ab193e + # You can provision additional disk space with a conf as follows + BlockDeviceMappings: + - DeviceName: /dev/sda1 + Ebs: + VolumeSize: 500 + VolumeType: gp3 + # Additional options in the boto docs. + ray.worker.default: + # The minimum number of worker nodes of this type to launch. + # This number should be >= 0. + min_workers: 5 + # The maximum number of worker nodes of this type to launch. + # This takes precedence over min_workers. + max_workers: 5 + # The node type's CPU and GPU resources are auto-detected based on AWS instance type. + # If desired, you can override the autodetected CPU and GPU resources advertised to the autoscaler. + # You can also set custom resources. + # For example, to mark a node type as having 1 CPU, 1 GPU, and 5 units of a resource called "custom", set + # resources: {"CPU": 1, "GPU": 1, "custom": 5} + resources: {} + # Provider-specific config for this node type, e.g. instance type. By default + # Ray will auto-configure unspecified fields such as SubnetId and KeyName. + # For more documentation on available fields, see: + # http://boto3.readthedocs.io/en/latest/reference/services/ec2.html#EC2.ServiceResource.create_instances + node_config: + InstanceType: m5.24xlarge + # Default AMI for us-west-2. + # Check https://github.com/ray-project/ray/blob/master/python/ray/autoscaler/_private/aws/config.py + # for default images for other zones. + ImageId: ami-0387d929287ab193e + # You can provision additional disk space with a conf as follows + BlockDeviceMappings: + - DeviceName: /dev/sda1 + Ebs: + VolumeSize: 500 + VolumeType: gp3 + # Run workers on spot by default. Comment this out to use on-demand. + # NOTE: If relying on spot instances, it is best to specify multiple different instance + # types to avoid interruption when one instance type is experiencing heightened demand. + # Demand information can be found at https://aws.amazon.com/ec2/spot/instance-advisor/ + # InstanceMarketOptions: + # MarketType: spot + # Additional options can be found in the boto docs, e.g. + # SpotOptions: + # MaxPrice: MAX_HOURLY_PRICE + # Additional options in the boto docs. + +# Specify the node type of the head node (as configured above). +head_node_type: ray.head.default # Files or directories to copy to the head and worker nodes. The format is a # dictionary from REMOTE_PATH: LOCAL_PATH, e.g. @@ -122,6 +142,17 @@ cluster_synced_files: [] # should sync to the worker node continuously file_mounts_sync_continuously: False +# Patterns for files to exclude when running rsync up or rsync down +rsync_exclude: + - "**/.git" + - "**/.git/**" + +# Pattern files to use for filtering out files when running rsync up or rsync down. The file is searched for +# in the source directory and recursively through all subdirectories. For example, if .gitignore is provided +# as a value, the behavior will match git's behavior for finding and using .gitignore files. +rsync_filter: + - ".gitignore" + # List of commands that will be run before `setup_commands`. If docker is # enabled, these commands will run outside the container and before docker # is setup. @@ -129,25 +160,21 @@ initialization_commands: [] # List of shell commands to run to set up nodes. setup_commands: - # Note: if you're developing Ray, you probably want to create an AMI that + # Note: if you're developing Ray, you probably want to create a Docker image that # has your Ray repo pre-cloned. Then, you can replace the pip installs # below with a git checkout (and possibly a recompile). - - echo 'export PATH="$HOME/anaconda3/envs/tensorflow_p36/bin:$PATH"' >> ~/.bashrc - - pip install modin - - pip install ray==1.0.0 - - pip install pyarrow==0.16 - - pip install -U fsspec - - wget https://s3.amazonaws.com/nyc-tlc/trip+data/yellow_tripdata_2015-01.csv - - printf "VendorID,tpep_pickup_datetime,tpep_dropoff_datetime,passenger_count,trip_distance,pickup_longitude,pickup_latitude,RateCodeID,store_and_fwd_flag,dropoff_longitude,dropoff_latitude,payment_type,fare_amount,extra,mta_tax,tip_amount,tolls_amount,improvement_surcharge,total_amount\n" > big_yellow.csv + # To run the nightly version of ray (as opposed to the latest), either use a rayproject docker image + # that has the "nightly" (e.g. "rayproject/ray-ml:nightly-gpu") or uncomment the following line: + # - pip install -U "ray[default] @ https://s3-us-west-2.amazonaws.com/ray-wheels/latest/ray-3.0.0.dev0-cp37-cp37m-manylinux2014_x86_64.whl" + - conda create -n "modin" -c conda-forge modin "ray-default">=1.13.0,!=2.5.0 -y + - conda activate modin && pip install -U fsspec>=2022.11.0 boto3 + - echo "conda activate modin" >> ~/.bashrc + - wget https://modin-datasets.intel.com/testing/yellow_tripdata_2015-01.csv + - printf "VendorID,tpep_pickup_datetime,tpep_dropoff_datetime,passenger_count,trip_distance,pickup_longitude,pickup_latitude,RateCodeID,store_and_fwd_flag,dropoff_longitude,dropoff_latitude,payment_type,fare_amount,extra,mta_tax,tip_amount,tolls_amount,improvement_surcharge,total_amount,congestion_surcharge,airport_fee\n" > big_yellow.csv - tail -n +2 yellow_tripdata_2015-01.csv{,}{,}{,}{,}{,}{,} >> big_yellow.csv - # Consider uncommenting these if you also want to run apt-get commands during setup - # - sudo pkill -9 apt-get || true - # - sudo pkill -9 dpkg || true - # - sudo dpkg --configure -a - + # Custom commands that will be run on the head node after common setup. -head_setup_commands: - - pip install boto3==1.4.8 # 1.4.8 adds InstanceMarketOptions +head_setup_commands: [] # Custom commands that will be run on worker nodes after common setup. worker_setup_commands: [] @@ -155,9 +182,9 @@ worker_setup_commands: [] # Command to start ray on the head node. You don't need to change this. head_start_ray_commands: - ray stop - - ulimit -n 65536; ray start --head --port=6379 --object-manager-port=8076 --autoscaling-config=~/ray_bootstrap_config.yaml + - ray start --head --port=6379 --object-manager-port=8076 --autoscaling-config=~/ray_bootstrap_config.yaml --dashboard-host=0.0.0.0 # Command to start ray on worker nodes. You don't need to change this. worker_start_ray_commands: - ray stop - - ulimit -n 65536; ray start --address=$RAY_HEAD_IP:6379 --object-manager-port=8076 + - ray start --address=$RAY_HEAD_IP:6379 --object-manager-port=8076 diff --git a/examples/tutorial/jupyter/img/modin_cluster_perf.png b/examples/tutorial/jupyter/img/modin_cluster_perf.png deleted file mode 100644 index d35e2411c1974c13edb80638fa5f708bf3a0b96c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 51080 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

ow37CGR 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 Hjh_E6>0u Date: Mon, 19 Feb 2024 16:18:29 +0100 Subject: [PATCH 183/201] FEAT-#6942: Enable range-partitioning impl for 'groupby().rolling()' by default (#6943) Signed-off-by: Dmitry Chigarev --- .../storage_formats/pandas/query_compiler.py | 37 +++++++++++-------- 1 file changed, 22 insertions(+), 15 deletions(-) diff --git a/modin/core/storage_formats/pandas/query_compiler.py b/modin/core/storage_formats/pandas/query_compiler.py index 715efb3c4e8..dffb1285eef 100644 --- a/modin/core/storage_formats/pandas/query_compiler.py +++ b/modin/core/storage_formats/pandas/query_compiler.py @@ -3993,23 +3993,30 @@ def agg_func(window, *args, **kwargs): else: assert callable(agg_func) - if unsupported_groupby: - obj = super(PandasQueryCompiler, self) - else: - obj = self - - return obj.groupby_agg( - by=by, - agg_func=lambda grp, *args, **kwargs: agg_func( + kwargs = { + "by": by, + "agg_func": lambda grp, *args, **kwargs: agg_func( grp.rolling(**rolling_kwargs), *args, **kwargs ), - axis=axis, - groupby_kwargs=groupby_kwargs, - agg_args=agg_args, - agg_kwargs=agg_kwargs, - how="direct", - drop=drop, - ) + "axis": axis, + "groupby_kwargs": groupby_kwargs, + "agg_args": agg_args, + "agg_kwargs": agg_kwargs, + "how": "direct", + "drop": drop, + } + + if unsupported_groupby: + return super(PandasQueryCompiler, self).groupby_agg(**kwargs) + + try: + return self._groupby_shuffle(**kwargs) + except NotImplementedError as e: + get_logger().info( + f"Can't use range-partitioning groupby implementation because of: {e}" + + "\nFalling back to a full-axis implementation." + ) + return self.groupby_agg(**kwargs) def groupby_agg( self, From 5ec075dbf8f8e26270a78ef9abce43d67b4a3729 Mon Sep 17 00:00:00 2001 From: Dmitry Chigarev Date: Mon, 19 Feb 2024 16:33:30 +0100 Subject: [PATCH 184/201] FIX-#6944: Apply 'isort' formatting for scripts from tutorials (#6945) Signed-off-by: Dmitry Chigarev --- .github/workflows/ci-required.yml | 12 ++++++++++++ .github/workflows/ci.yml | 12 ------------ .../execution/pandas_on_ray/cluster/exercise_5.py | 1 + 3 files changed, 13 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci-required.yml b/.github/workflows/ci-required.yml index 755199e0ef8..0fe992a757e 100644 --- a/.github/workflows/ci-required.yml +++ b/.github/workflows/ci-required.yml @@ -105,3 +105,15 @@ jobs: - run: python scripts/doc_checker.py modin/experimental/core/execution/native/implementations/hdk_on_native/interchange/dataframe_protocol - run: python scripts/doc_checker.py modin/experimental/batch/pipeline.py - run: python scripts/doc_checker.py modin/logging + + lint-black-isort: + name: lint (black and isort) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: ./.github/actions/python-only + - run: pip install black>=24.1.0 isort>=5.12 + # NOTE: keep the black command here in sync with the pre-commit hook in + # /contributing/pre-commit + - run: black --check --diff modin/ asv_bench/benchmarks scripts/doc_checker.py + - run: isort . --check-only diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 841b7d8a4d9..b028cfdfe8c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,18 +26,6 @@ env: MODIN_GITHUB_CI: true jobs: - lint-black-isort: - name: lint (black and isort) - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - uses: ./.github/actions/python-only - - run: pip install black>=24.1.0 isort>=5.12 - # NOTE: keep the black command here in sync with the pre-commit hook in - # /contributing/pre-commit - - run: black --check --diff modin/ asv_bench/benchmarks scripts/doc_checker.py - - run: isort . --check-only - lint-mypy: name: lint (mypy) runs-on: ubuntu-latest diff --git a/examples/tutorial/jupyter/execution/pandas_on_ray/cluster/exercise_5.py b/examples/tutorial/jupyter/execution/pandas_on_ray/cluster/exercise_5.py index d68d85764ee..7339b00bb9a 100644 --- a/examples/tutorial/jupyter/execution/pandas_on_ray/cluster/exercise_5.py +++ b/examples/tutorial/jupyter/execution/pandas_on_ray/cluster/exercise_5.py @@ -1,4 +1,5 @@ import time + import ray import modin.pandas as pd From b875991755033b126f2f9916f02b80ab28ab6675 Mon Sep 17 00:00:00 2001 From: Dmitry Chigarev Date: Mon, 19 Feb 2024 18:21:50 +0100 Subject: [PATCH 185/201] FIX-#6946: Remove 'needs: [lint-black-isort, ...]' (#6947) Signed-off-by: Dmitry Chigarev --- .github/workflows/ci.yml | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b028cfdfe8c..7b7ce6198bf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -66,7 +66,7 @@ jobs: - uses: ./.github/actions/upload-coverage test-clean-install: - needs: [lint-flake8, lint-black-isort] + needs: [lint-flake8] strategy: matrix: os: @@ -94,7 +94,7 @@ jobs: if: matrix.os == 'ubuntu' test-internals: - needs: [lint-flake8, lint-black-isort] + needs: [lint-flake8] runs-on: ubuntu-latest defaults: run: @@ -119,7 +119,7 @@ jobs: - uses: ./.github/actions/upload-coverage test-defaults: - needs: [lint-flake8, lint-black-isort] + needs: [lint-flake8] runs-on: ubuntu-latest defaults: run: @@ -150,7 +150,7 @@ jobs: - uses: ./.github/actions/upload-coverage test-hdk: - needs: [lint-flake8, lint-black-isort] + needs: [lint-flake8] runs-on: ubuntu-latest defaults: run: @@ -207,7 +207,7 @@ jobs: test-asv-benchmarks: if: github.event_name == 'pull_request' - needs: [lint-flake8, lint-black-isort] + needs: [lint-flake8] runs-on: ubuntu-latest defaults: run: @@ -312,7 +312,7 @@ jobs: "${{ steps.filter.outputs.ray }}" "${{ steps.filter.outputs.dask }}" >> $GITHUB_OUTPUT test-all-unidist: - needs: [lint-flake8, lint-black-isort, execution-filter] + needs: [lint-flake8, execution-filter] if: github.event_name == 'push' || needs.execution-filter.outputs.unidist == 'true' runs-on: ubuntu-latest defaults: @@ -387,7 +387,7 @@ jobs: - uses: ./.github/actions/upload-coverage test-all: - needs: [lint-flake8, lint-black-isort, execution-filter] + needs: [lint-flake8, execution-filter] strategy: matrix: os: @@ -521,7 +521,7 @@ jobs: if: matrix.os == 'windows' test-sanity: - needs: [lint-flake8, lint-black-isort, execution-filter] + needs: [lint-flake8, execution-filter] if: github.event_name == 'pull_request' strategy: matrix: @@ -653,7 +653,7 @@ jobs: - uses: ./.github/actions/upload-coverage test-experimental: - needs: [lint-flake8, lint-black-isort] + needs: [lint-flake8] runs-on: ubuntu-latest defaults: run: @@ -682,7 +682,7 @@ jobs: - uses: ./.github/actions/upload-coverage test-spreadsheet: - needs: [lint-flake8, lint-black-isort] + needs: [lint-flake8] runs-on: ubuntu-latest defaults: run: From 4704751c4440574ce59b9948e88e6991e1d3183b Mon Sep 17 00:00:00 2001 From: Dmitry Chigarev Date: Mon, 19 Feb 2024 22:37:17 +0100 Subject: [PATCH 186/201] FEAT-#6934: Support 'include_groups=False' parameter in 'groupby.apply()' (#6938) Signed-off-by: Dmitry Chigarev --- .../dataframe/pandas/dataframe/dataframe.py | 7 +---- modin/pandas/groupby.py | 12 +------- modin/pandas/test/test_groupby.py | 28 +++++++++++++++++++ 3 files changed, 30 insertions(+), 17 deletions(-) diff --git a/modin/core/dataframe/pandas/dataframe/dataframe.py b/modin/core/dataframe/pandas/dataframe/dataframe.py index e3ad15e1154..11709df0723 100644 --- a/modin/core/dataframe/pandas/dataframe/dataframe.py +++ b/modin/core/dataframe/pandas/dataframe/dataframe.py @@ -3967,12 +3967,7 @@ def apply_func(df): # pragma: no cover df = df.squeeze(axis=1) result = operator(df.groupby(by, **kwargs)) - if ( - align_result_columns - and df.empty - and result.empty - and df.columns.equals(result.columns) - ): + if align_result_columns and df.empty and result.empty: # We want to align columns only of those frames that actually performed # some groupby aggregation, if an empty frame was originally passed # (an empty bin on reshuffling was created) then there were no groupby diff --git a/modin/pandas/groupby.py b/modin/pandas/groupby.py index bfa425913f3..1b22f0aa8d9 100644 --- a/modin/pandas/groupby.py +++ b/modin/pandas/groupby.py @@ -646,16 +646,6 @@ def cummax(self, axis=lib.no_default, numeric_only=False, **kwargs): ) def apply(self, func, *args, include_groups=True, **kwargs): - if not include_groups: - return self._default_to_pandas( - lambda df: df.apply( - func, - *args, - include_groups=include_groups, - **kwargs, - ) - ) - func = cast_function_modin2pandas(func) if not isinstance(func, BuiltinFunctionType): func = wrap_udf_function(func) @@ -665,7 +655,7 @@ def apply(self, func, *args, include_groups=True, **kwargs): numeric_only=False, agg_func=func, agg_args=args, - agg_kwargs=kwargs, + agg_kwargs={**kwargs, "include_groups": include_groups}, how="group_wise", ) reduced_index = pandas.Index([MODIN_UNNAMED_SERIES_LABEL]) diff --git a/modin/pandas/test/test_groupby.py b/modin/pandas/test/test_groupby.py index bd27c3ce885..f971cd45ad9 100644 --- a/modin/pandas/test/test_groupby.py +++ b/modin/pandas/test/test_groupby.py @@ -3358,3 +3358,31 @@ def test_range_groupby_categories_external_grouper(columns, cat_cols): pd_df, pd_by = get_external_groupers(pd_df, columns, drop_from_original_df=True) eval_general(md_df.groupby(md_by), pd_df.groupby(pd_by), lambda grp: grp.count()) + + +@pytest.mark.parametrize("by", [["a"], ["a", "b"]]) +@pytest.mark.parametrize("as_index", [True, False]) +@pytest.mark.parametrize("include_groups", [True, False]) +def test_include_groups(by, as_index, include_groups): + data = { + "a": [1, 1, 2, 2] * 64, + "b": [11, 11, 22, 22] * 64, + "c": [111, 111, 222, 222] * 64, + "data": [1, 2, 3, 4] * 64, + } + + def func(df): + if include_groups: + assert len(df.columns.intersection(by)) == len(by) + else: + assert len(df.columns.intersection(by)) == 0 + return df.sum() + + md_df, pd_df = create_test_dfs(data) + eval_general( + md_df, + pd_df, + lambda df: df.groupby(by, as_index=as_index).apply( + func, include_groups=include_groups + ), + ) From c422c784f14d984cccc5b51c2c5679befa2693f3 Mon Sep 17 00:00:00 2001 From: Bailey Brownie Date: Wed, 21 Feb 2024 13:07:56 -0500 Subject: [PATCH 187/201] FIX-#6952: use `render_as_string` to get sqlalchemy engine url (#6953) --- modin/core/io/sql/sql_dispatcher.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/modin/core/io/sql/sql_dispatcher.py b/modin/core/io/sql/sql_dispatcher.py index 782207e95d7..63e464fe4de 100644 --- a/modin/core/io/sql/sql_dispatcher.py +++ b/modin/core/io/sql/sql_dispatcher.py @@ -149,7 +149,9 @@ def write(cls, qc, **kwargs): # are not pickleable. We have to convert it to the URL string and connect from # each of the workers. if cls._is_supported_sqlalchemy_object(kwargs["con"]): - kwargs["con"] = str(kwargs["con"].engine.url) + kwargs["con"] = kwargs["con"].engine.url.render_as_string( + hide_password=False + ) empty_df = qc.getitem_row_array([0]).to_pandas().head(0) empty_df.to_sql(**kwargs) From 6dfe13fb5468c3fbac85e11fcbc9a64b30d14ac7 Mon Sep 17 00:00:00 2001 From: Anatoly Myachev Date: Thu, 22 Feb 2024 15:20:38 +0100 Subject: [PATCH 188/201] FIX-#6948: Fix groupby when Modin dataframe has several column partitions (#6951) Signed-off-by: Anatoly Myachev --- modin/pandas/groupby.py | 32 +++++++++++++++--- modin/pandas/test/test_groupby.py | 54 +++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 5 deletions(-) diff --git a/modin/pandas/groupby.py b/modin/pandas/groupby.py index 1b22f0aa8d9..23a730e0570 100644 --- a/modin/pandas/groupby.py +++ b/modin/pandas/groupby.py @@ -22,6 +22,7 @@ import pandas.core.common as com import pandas.core.groupby from pandas._libs import lib +from pandas.api.types import is_scalar from pandas.core.apply import reconstruct_func from pandas.core.dtypes.common import ( is_datetime64_any_dtype, @@ -894,19 +895,40 @@ def aggregate(self, func=None, *args, engine=None, engine_kwargs=None, **kwargs) do_relabel = None if isinstance(func, dict) or func is None: - relabeling_required, func_dict, new_columns, order = reconstruct_func( + # the order from `reconstruct_func` cannot be used correctly if there + # is more than one columnar partition, since for correct use all columns + # must be available within one partition. + old_kwargs = dict(kwargs) + relabeling_required, func_dict, new_columns, _ = reconstruct_func( func, **kwargs ) if relabeling_required: def do_relabel(obj_to_relabel): # noqa: F811 - new_order, new_columns_idx = order, pandas.Index(new_columns) + # unwrap nested labels into one level tuple + result_labels = [None] * len(old_kwargs) + for idx, labels in enumerate(old_kwargs.values()): + if is_scalar(labels) or callable(labels): + result_labels[idx] = ( + labels if not callable(labels) else labels.__name__ + ) + continue + new_elem = [] + for label in labels: + if is_scalar(label) or callable(label): + new_elem.append( + label if not callable(label) else label.__name__ + ) + else: + new_elem.extend(label) + result_labels[idx] = tuple(new_elem) + + new_order = obj_to_relabel.columns.get_indexer(result_labels) + new_columns_idx = pandas.Index(new_columns) if not self._as_index: nby_cols = len(obj_to_relabel.columns) - len(new_columns_idx) - new_order = np.concatenate( - [np.arange(nby_cols), new_order + nby_cols] - ) + new_order = np.concatenate([np.arange(nby_cols), new_order]) by_cols = obj_to_relabel.columns[:nby_cols] if by_cols.nlevels != new_columns_idx.nlevels: by_cols = by_cols.remove_unused_levels() diff --git a/modin/pandas/test/test_groupby.py b/modin/pandas/test/test_groupby.py index f971cd45ad9..d4e93aafc94 100644 --- a/modin/pandas/test/test_groupby.py +++ b/modin/pandas/test/test_groupby.py @@ -3087,6 +3087,60 @@ def test_groupby_named_aggregation(): ) +def test_groupby_several_column_partitions(): + # see details in #6948 + columns = [ + "l_returnflag", + "l_linestatus", + "l_discount", + "l_extendedprice", + "l_quantity", + ] + modin_df, pandas_df = create_test_dfs( + np.random.randint(0, 100, size=(1000, len(columns))), columns=columns + ) + + pandas_df["a"] = (pandas_df.l_extendedprice) * (1 - (pandas_df.l_discount)) + # to create another column partition + modin_df["a"] = (modin_df.l_extendedprice) * (1 - (modin_df.l_discount)) + + eval_general( + modin_df, + pandas_df, + lambda df: df.groupby(["l_returnflag", "l_linestatus"]) + .agg( + sum_qty=("l_quantity", "sum"), + sum_base_price=("l_extendedprice", "sum"), + sum_disc_price=("a", "sum"), + # sum_charge=("b", "sum"), + avg_qty=("l_quantity", "mean"), + avg_price=("l_extendedprice", "mean"), + avg_disc=("l_discount", "mean"), + count_order=("l_returnflag", "count"), + ) + .reset_index(), + ) + + +def test_groupby_named_agg(): + # from pandas docs + + data = { + "A": [1, 1, 2, 2], + "B": [1, 2, 3, 4], + "C": [0.362838, 0.227877, 1.267767, -0.562860], + } + modin_df, pandas_df = create_test_dfs(data) + eval_general( + modin_df, + pandas_df, + lambda df: df.groupby("A").agg( + b_min=pd.NamedAgg(column="B", aggfunc="min"), + c_sum=pd.NamedAgg(column="C", aggfunc="sum"), + ), + ) + + ### TEST GROUPBY WARNINGS ### From 156ea84a9856ce78938881df287ec779606d07e5 Mon Sep 17 00:00:00 2001 From: Arun Jose <40291569+arunjose696@users.noreply.github.com> Date: Thu, 22 Feb 2024 17:24:21 +0100 Subject: [PATCH 189/201] FIX-#6935: Fix Merge failed when right operand is an empty dataframe (#6941) Co-authored-by: Iaroslav Igoshev Signed-off-by: arunjose696 --- .../dataframe/pandas/dataframe/dataframe.py | 21 ++++++++++++++++++- .../pandas/partitioning/partition_manager.py | 20 ++++++++++++++++++ modin/pandas/test/dataframe/test_join_sort.py | 7 +++++++ 3 files changed, 47 insertions(+), 1 deletion(-) diff --git a/modin/core/dataframe/pandas/dataframe/dataframe.py b/modin/core/dataframe/pandas/dataframe/dataframe.py index 11709df0723..b69bf25ad61 100644 --- a/modin/core/dataframe/pandas/dataframe/dataframe.py +++ b/modin/core/dataframe/pandas/dataframe/dataframe.py @@ -3203,6 +3203,25 @@ def _prepare_frame_to_broadcast(self, axis, indices, broadcast_all): passed_len += len(internal) return result_dict + def _extract_partitions(self): + """ + Extract partitions if partitions are present. + + If partitions are empty return a dummy partition with empty data but + index and columns of current dataframe. + + Returns + ------- + np.ndarray + NumPy array with extracted partitions. + """ + if self._partitions.size > 0: + return self._partitions + else: + return self._partition_mgr_cls.create_partition_from_metadata( + index=self.index, columns=self.columns + ) + @lazy_metadata_decorator(apply_axis="both") def broadcast_apply_select_indices( self, @@ -3351,7 +3370,7 @@ def broadcast_apply_full_axis( if other is not None: if not isinstance(other, list): other = [other] - other = [o._partitions for o in other] if len(other) else None + other = [o._extract_partitions() for o in other] if len(other) else None if apply_indices is not None: numeric_indices = self.get_axis(axis ^ 1).get_indexer_for(apply_indices) diff --git a/modin/core/dataframe/pandas/partitioning/partition_manager.py b/modin/core/dataframe/pandas/partitioning/partition_manager.py index 27def624ca6..f9a02f7dcad 100644 --- a/modin/core/dataframe/pandas/partitioning/partition_manager.py +++ b/modin/core/dataframe/pandas/partitioning/partition_manager.py @@ -175,6 +175,24 @@ def preprocess_func(cls, map_func): # END Abstract Methods + @classmethod + def create_partition_from_metadata(cls, **metadata): + """ + Create NumPy array of partitions that holds an empty dataframe with given metadata. + + Parameters + ---------- + **metadata : dict + Metadata that has to be wrapped in a partition. + + Returns + ------- + np.ndarray + A NumPy 2D array of a single partition which contains the data. + """ + metadata_dataframe = pandas.DataFrame(**metadata) + return np.array([[cls._partition_class.put(metadata_dataframe)]]) + @classmethod def column_partitions(cls, partitions, full_axis=True): """ @@ -1113,6 +1131,8 @@ def combine(cls, partitions): np.ndarray A NumPy 2D array of a single partition. """ + if partitions.size <= 1: + return partitions def to_pandas_remote(df, partition_shape, *dfs): """Copy of ``cls.to_pandas()`` method adapted for a remote function.""" diff --git a/modin/pandas/test/dataframe/test_join_sort.py b/modin/pandas/test/dataframe/test_join_sort.py index 2925f999b05..653e39bec0a 100644 --- a/modin/pandas/test/dataframe/test_join_sort.py +++ b/modin/pandas/test/dataframe/test_join_sort.py @@ -383,6 +383,13 @@ def test_merge(test_data, test_data2): modin_df.merge("Non-valid type") +def test_merge_empty(): + data = np.random.uniform(0, 100, size=(2**6, 2**6)) + pandas_df = pandas.DataFrame(data) + modin_df = pd.DataFrame(data) + eval_general(modin_df, pandas_df, lambda df: df.merge(df.iloc[:0])) + + def test_merge_with_mi_columns(): modin_df1, pandas_df1 = create_test_dfs( { From a96a9a94ea20deb8f5ebcb54819d90756c54a33e Mon Sep 17 00:00:00 2001 From: Anatoly Myachev Date: Thu, 22 Feb 2024 17:25:18 +0100 Subject: [PATCH 190/201] REFACTOR-#6958: Remove DataFrame.to_pickle_distributed in favour of DataFrame.modin.to_pickle_distributed (#6959) Signed-off-by: Anatoly Myachev --- modin/experimental/pandas/__init__.py | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/modin/experimental/pandas/__init__.py b/modin/experimental/pandas/__init__.py index 34598a8c63b..19573399d81 100644 --- a/modin/experimental/pandas/__init__.py +++ b/modin/experimental/pandas/__init__.py @@ -32,9 +32,6 @@ >>> df = pd.read_csv_glob("data*.csv") """ -import functools -import warnings - from modin.pandas import * # noqa F401, F403 from .io import ( # noqa F401 @@ -45,20 +42,4 @@ read_pickle_distributed, read_sql, read_xml_glob, - to_pickle_distributed, ) - -old_to_pickle_distributed = to_pickle_distributed - - -@functools.wraps(to_pickle_distributed) -def to_pickle_distributed(*args, **kwargs): - warnings.warn( - "`DataFrame.to_pickle_distributed` is deprecated and will be removed in a future version. " - + "Please use `DataFrame.modin.to_pickle_distributed` instead.", - category=FutureWarning, - ) - return old_to_pickle_distributed(*args, **kwargs) - - -setattr(DataFrame, "to_pickle_distributed", to_pickle_distributed) # noqa: F405 From ca42a1928e3ee68650eec5c3a8799356636bb782 Mon Sep 17 00:00:00 2001 From: Bailey Brownie Date: Thu, 22 Feb 2024 13:07:51 -0500 Subject: [PATCH 191/201] FEAT-#6913: Support sqlalchemy connectables in `read_sql` by getting connection url (#6956) --- modin/core/io/sql/sql_dispatcher.py | 30 +++++++++++++++++------------ 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/modin/core/io/sql/sql_dispatcher.py b/modin/core/io/sql/sql_dispatcher.py index 63e464fe4de..c32afc1225b 100644 --- a/modin/core/io/sql/sql_dispatcher.py +++ b/modin/core/io/sql/sql_dispatcher.py @@ -32,6 +32,17 @@ class SQLDispatcher(FileDispatcher): """Class handles utils for reading SQL queries or database tables.""" + @classmethod + def _is_supported_sqlalchemy_object(cls, obj): # noqa: GL08 + supported = None + try: + import sqlalchemy as sa + + supported = isinstance(obj, (sa.engine.Engine, sa.engine.Connection)) + except ImportError: + supported = False + return supported + @classmethod def _read(cls, sql, con, index_col=None, **kwargs): """ @@ -55,6 +66,12 @@ def _read(cls, sql, con, index_col=None, **kwargs): """ if isinstance(con, str): con = ModinDatabaseConnection("sqlalchemy", con) + + if cls._is_supported_sqlalchemy_object(con): + con = ModinDatabaseConnection( + "sqlalchemy", con.engine.url.render_as_string(hide_password=False) + ) + if not isinstance(con, ModinDatabaseConnection): return cls.single_worker_read( sql, @@ -62,7 +79,7 @@ def _read(cls, sql, con, index_col=None, **kwargs): index_col=index_col, read_sql_engine=ReadSqlEngine.get(), reason="To use the parallel implementation of `read_sql`, pass either " - + "the SQL connection string or a ModinDatabaseConnection " + + "a SQLAlchemy connectable, the SQL connection string, or a ModinDatabaseConnection " + "with the arguments required to make a connection, instead " + f"of {type(con)}. For documentation on the ModinDatabaseConnection, see " + "https://modin.readthedocs.io/en/latest/supported_apis/io_supported.html#connecting-to-a-database-for-read-sql", @@ -111,17 +128,6 @@ def _read(cls, sql, con, index_col=None, **kwargs): new_frame.synchronize_labels(axis=0) return cls.query_compiler_cls(new_frame) - @classmethod - def _is_supported_sqlalchemy_object(cls, obj): # noqa: GL08 - supported = None - try: - import sqlalchemy as sa - - supported = isinstance(obj, (sa.engine.Engine, sa.engine.Connection)) - except ImportError: - supported = False - return supported - @classmethod def write(cls, qc, **kwargs): """ From 138169713d05fb518dbe63e46da12672d8866931 Mon Sep 17 00:00:00 2001 From: Anatoly Myachev Date: Fri, 23 Feb 2024 11:01:14 +0100 Subject: [PATCH 192/201] REFACTOR-#6856: Rename read_pickle_distributed/to_pickle_distributed to read_pickle_glob/to_pickle_glob (#6957) Signed-off-by: Anatoly Myachev --- docs/flow/modin/experimental/pandas.rst | 4 ++-- docs/supported_apis/dataframe_supported.rst | 2 +- docs/supported_apis/io_supported.rst | 2 +- docs/usage_guide/advanced_usage/index.rst | 4 ++-- .../implementations/pandas_on_dask/io/io.py | 4 ++-- .../dispatching/factories/dispatcher.py | 12 +++++----- .../dispatching/factories/factories.py | 12 +++++----- .../implementations/pandas_on_ray/io/io.py | 4 ++-- .../pandas_on_unidist/io/io.py | 4 ++-- modin/experimental/pandas/__init__.py | 10 +++++++- modin/experimental/pandas/io.py | 10 ++++---- modin/experimental/pandas/test/test_io_exp.py | 12 ++++++---- modin/pandas/accessor.py | 23 ++++++++++++++++--- 13 files changed, 65 insertions(+), 38 deletions(-) diff --git a/docs/flow/modin/experimental/pandas.rst b/docs/flow/modin/experimental/pandas.rst index 759b8db183b..5f26c84d328 100644 --- a/docs/flow/modin/experimental/pandas.rst +++ b/docs/flow/modin/experimental/pandas.rst @@ -12,11 +12,11 @@ Experimental API Reference .. autofunction:: read_sql .. autofunction:: read_csv_glob .. autofunction:: read_custom_text -.. autofunction:: read_pickle_distributed +.. autofunction:: read_pickle_glob .. autofunction:: read_parquet_glob .. autofunction:: read_json_glob .. autofunction:: read_xml_glob -.. automethod:: modin.pandas.DataFrame.modin::to_pickle_distributed +.. automethod:: modin.pandas.DataFrame.modin::to_pickle_glob .. automethod:: modin.pandas.DataFrame.modin::to_parquet_glob .. automethod:: modin.pandas.DataFrame.modin::to_json_glob .. automethod:: modin.pandas.DataFrame.modin::to_xml_glob diff --git a/docs/supported_apis/dataframe_supported.rst b/docs/supported_apis/dataframe_supported.rst index 2b3789fc812..11a1415aa7f 100644 --- a/docs/supported_apis/dataframe_supported.rst +++ b/docs/supported_apis/dataframe_supported.rst @@ -433,7 +433,7 @@ default to pandas. | ``to_period`` | `to_period`_ | D | | +----------------------------+---------------------------+------------------------+----------------------------------------------------+ | ``to_pickle`` | `to_pickle`_ | D | Experimental implementation: | -| | | | DataFrame.modin.to_pickle_distributed | +| | | | DataFrame.modin.to_pickle_glob | +----------------------------+---------------------------+------------------------+----------------------------------------------------+ | ``to_records`` | `to_records`_ | D | | +----------------------------+---------------------------+------------------------+----------------------------------------------------+ diff --git a/docs/supported_apis/io_supported.rst b/docs/supported_apis/io_supported.rst index 92c6f88ef2d..a44adb71abc 100644 --- a/docs/supported_apis/io_supported.rst +++ b/docs/supported_apis/io_supported.rst @@ -68,7 +68,7 @@ default to pandas. | `read_sas`_ | D | | +-------------------+---------------------------------+--------------------------------------------------------+ | `read_pickle`_ | D | Experimental implementation: | -| | | read_pickle_distributed | +| | | read_pickle_glob | +-------------------+---------------------------------+--------------------------------------------------------+ | `read_sql`_ | Y | | +-------------------+---------------------------------+--------------------------------------------------------+ diff --git a/docs/usage_guide/advanced_usage/index.rst b/docs/usage_guide/advanced_usage/index.rst index 4f9cd402f2c..3ca050403ed 100644 --- a/docs/usage_guide/advanced_usage/index.rst +++ b/docs/usage_guide/advanced_usage/index.rst @@ -41,11 +41,11 @@ Modin also supports these experimental APIs on top of pandas that are under acti - :py:func:`~modin.experimental.pandas.read_csv_glob` -- read multiple files in a directory - :py:func:`~modin.experimental.pandas.read_sql` -- add optional parameters for the database connection - :py:func:`~modin.experimental.pandas.read_custom_text` -- read custom text data from file -- :py:func:`~modin.experimental.pandas.read_pickle_distributed` -- read multiple pickle files in a directory +- :py:func:`~modin.experimental.pandas.read_pickle_glob` -- read multiple pickle files in a directory - :py:func:`~modin.experimental.pandas.read_parquet_glob` -- read multiple parquet files in a directory - :py:func:`~modin.experimental.pandas.read_json_glob` -- read multiple json files in a directory - :py:func:`~modin.experimental.pandas.read_xml_glob` -- read multiple xml files in a directory -- :py:meth:`~modin.pandas.DataFrame.modin.to_pickle_distributed` -- write to multiple pickle files in a directory +- :py:meth:`~modin.pandas.DataFrame.modin.to_pickle_glob` -- write to multiple pickle files in a directory - :py:meth:`~modin.pandas.DataFrame.modin.to_parquet_glob` -- write to multiple parquet files in a directory - :py:meth:`~modin.pandas.DataFrame.modin.to_json_glob` -- write to multiple json files in a directory - :py:meth:`~modin.pandas.DataFrame.modin.to_xml_glob` -- write to multiple xml files in a directory diff --git a/modin/core/execution/dask/implementations/pandas_on_dask/io/io.py b/modin/core/execution/dask/implementations/pandas_on_dask/io/io.py index e1780833976..b0b5b48203d 100644 --- a/modin/core/execution/dask/implementations/pandas_on_dask/io/io.py +++ b/modin/core/execution/dask/implementations/pandas_on_dask/io/io.py @@ -111,10 +111,10 @@ def __make_write(*classes, build_args=build_args): ExperimentalGlobDispatcher, build_args={**build_args, "base_write": BaseIO.to_xml}, ) - read_pickle_distributed = __make_read( + read_pickle_glob = __make_read( ExperimentalPandasPickleParser, ExperimentalGlobDispatcher ) - to_pickle_distributed = __make_write( + to_pickle_glob = __make_write( ExperimentalGlobDispatcher, build_args={**build_args, "base_write": BaseIO.to_pickle}, ) diff --git a/modin/core/execution/dispatching/factories/dispatcher.py b/modin/core/execution/dispatching/factories/dispatcher.py index 195c62b8883..f8ff035fda9 100644 --- a/modin/core/execution/dispatching/factories/dispatcher.py +++ b/modin/core/execution/dispatching/factories/dispatcher.py @@ -197,9 +197,9 @@ def read_csv_glob(cls, **kwargs): return cls.get_factory()._read_csv_glob(**kwargs) @classmethod - @_inherit_docstrings(factories.PandasOnRayFactory._read_pickle_distributed) - def read_pickle_distributed(cls, **kwargs): - return cls.get_factory()._read_pickle_distributed(**kwargs) + @_inherit_docstrings(factories.PandasOnRayFactory._read_pickle_glob) + def read_pickle_glob(cls, **kwargs): + return cls.get_factory()._read_pickle_glob(**kwargs) @classmethod @_inherit_docstrings(factories.BaseFactory._read_json) @@ -292,9 +292,9 @@ def to_pickle(cls, *args, **kwargs): return cls.get_factory()._to_pickle(*args, **kwargs) @classmethod - @_inherit_docstrings(factories.PandasOnRayFactory._to_pickle_distributed) - def to_pickle_distributed(cls, *args, **kwargs): - return cls.get_factory()._to_pickle_distributed(*args, **kwargs) + @_inherit_docstrings(factories.PandasOnRayFactory._to_pickle_glob) + def to_pickle_glob(cls, *args, **kwargs): + return cls.get_factory()._to_pickle_glob(*args, **kwargs) @classmethod @_inherit_docstrings(factories.PandasOnRayFactory._read_parquet_glob) diff --git a/modin/core/execution/dispatching/factories/factories.py b/modin/core/execution/dispatching/factories/factories.py index 4f50995dfd7..ee0c96f1ee0 100644 --- a/modin/core/execution/dispatching/factories/factories.py +++ b/modin/core/execution/dispatching/factories/factories.py @@ -476,13 +476,13 @@ def _read_csv_glob(cls, **kwargs): source="Pickle files", params=_doc_io_method_kwargs_params, ) - def _read_pickle_distributed(cls, **kwargs): + def _read_pickle_glob(cls, **kwargs): current_execution = get_current_execution() if current_execution not in supported_executions: raise NotImplementedError( - f"`_read_pickle_distributed()` is not implemented for {current_execution} execution." + f"`_read_pickle_glob()` is not implemented for {current_execution} execution." ) - return cls.io_cls.read_pickle_distributed(**kwargs) + return cls.io_cls.read_pickle_glob(**kwargs) @classmethod @doc( @@ -526,7 +526,7 @@ def _read_custom_text(cls, **kwargs): return cls.io_cls.read_custom_text(**kwargs) @classmethod - def _to_pickle_distributed(cls, *args, **kwargs): + def _to_pickle_glob(cls, *args, **kwargs): """ Distributed pickle query compiler object. @@ -540,9 +540,9 @@ def _to_pickle_distributed(cls, *args, **kwargs): current_execution = get_current_execution() if current_execution not in supported_executions: raise NotImplementedError( - f"`_to_pickle_distributed()` is not implemented for {current_execution} execution." + f"`_to_pickle_glob()` is not implemented for {current_execution} execution." ) - return cls.io_cls.to_pickle_distributed(*args, **kwargs) + return cls.io_cls.to_pickle_glob(*args, **kwargs) @classmethod @doc( diff --git a/modin/core/execution/ray/implementations/pandas_on_ray/io/io.py b/modin/core/execution/ray/implementations/pandas_on_ray/io/io.py index 1cdfb929a57..3839bd62d0d 100644 --- a/modin/core/execution/ray/implementations/pandas_on_ray/io/io.py +++ b/modin/core/execution/ray/implementations/pandas_on_ray/io/io.py @@ -113,10 +113,10 @@ def __make_write(*classes, build_args=build_args): ExperimentalGlobDispatcher, build_args={**build_args, "base_write": RayIO.to_xml}, ) - read_pickle_distributed = __make_read( + read_pickle_glob = __make_read( ExperimentalPandasPickleParser, ExperimentalGlobDispatcher ) - to_pickle_distributed = __make_write( + to_pickle_glob = __make_write( ExperimentalGlobDispatcher, build_args={**build_args, "base_write": RayIO.to_pickle}, ) diff --git a/modin/core/execution/unidist/implementations/pandas_on_unidist/io/io.py b/modin/core/execution/unidist/implementations/pandas_on_unidist/io/io.py index 1ac29ff9218..c5bc772ad7f 100644 --- a/modin/core/execution/unidist/implementations/pandas_on_unidist/io/io.py +++ b/modin/core/execution/unidist/implementations/pandas_on_unidist/io/io.py @@ -113,10 +113,10 @@ def __make_write(*classes, build_args=build_args): ExperimentalGlobDispatcher, build_args={**build_args, "base_write": UnidistIO.to_xml}, ) - read_pickle_distributed = __make_read( + read_pickle_glob = __make_read( ExperimentalPandasPickleParser, ExperimentalGlobDispatcher ) - to_pickle_distributed = __make_write( + to_pickle_glob = __make_write( ExperimentalGlobDispatcher, build_args={**build_args, "base_write": UnidistIO.to_pickle}, ) diff --git a/modin/experimental/pandas/__init__.py b/modin/experimental/pandas/__init__.py index 19573399d81..0825a5bb202 100644 --- a/modin/experimental/pandas/__init__.py +++ b/modin/experimental/pandas/__init__.py @@ -33,13 +33,21 @@ """ from modin.pandas import * # noqa F401, F403 +from modin.utils import func_from_deprecated_location from .io import ( # noqa F401 read_csv_glob, read_custom_text, read_json_glob, read_parquet_glob, - read_pickle_distributed, + read_pickle_glob, read_sql, read_xml_glob, ) + +read_pickle_distributed = func_from_deprecated_location( + "read_pickle_glob", + "modin.experimental.pandas.io", + "`modin.experimental.pandas.read_pickle_distributed` is deprecated and will be removed in a future version. " + + "Please use `modin.experimental.pandas.to_pickle_glob` instead.", +) diff --git a/modin/experimental/pandas/io.py b/modin/experimental/pandas/io.py index 7dc19ac4652..0122f303963 100644 --- a/modin/experimental/pandas/io.py +++ b/modin/experimental/pandas/io.py @@ -303,7 +303,7 @@ def _read(**kwargs) -> DataFrame: @expanduser_path_arg("filepath_or_buffer") -def read_pickle_distributed( +def read_pickle_glob( filepath_or_buffer, compression: Optional[str] = "infer", storage_options: StorageOptions = None, @@ -313,7 +313,7 @@ def read_pickle_distributed( This experimental feature provides parallel reading from multiple pickle files which are defined by glob pattern. The files must contain parts of one dataframe, which can be - obtained, for example, by `DataFrame.modin.to_pickle_distributed` function. + obtained, for example, by `DataFrame.modin.to_pickle_glob` function. Parameters ---------- @@ -344,11 +344,11 @@ def read_pickle_distributed( from modin.core.execution.dispatching.factories.dispatcher import FactoryDispatcher - return DataFrame(query_compiler=FactoryDispatcher.read_pickle_distributed(**kwargs)) + return DataFrame(query_compiler=FactoryDispatcher.read_pickle_glob(**kwargs)) @expanduser_path_arg("filepath_or_buffer") -def to_pickle_distributed( +def to_pickle_glob( self, filepath_or_buffer, compression: CompressionOptions = "infer", @@ -392,7 +392,7 @@ def to_pickle_distributed( if isinstance(self, DataFrame): obj = self._query_compiler - FactoryDispatcher.to_pickle_distributed( + FactoryDispatcher.to_pickle_glob( obj, filepath_or_buffer=filepath_or_buffer, compression=compression, diff --git a/modin/experimental/pandas/test/test_io_exp.py b/modin/experimental/pandas/test/test_io_exp.py index cbbe62e7974..ed091dee10a 100644 --- a/modin/experimental/pandas/test/test_io_exp.py +++ b/modin/experimental/pandas/test/test_io_exp.py @@ -249,7 +249,11 @@ def _pandas_read_csv_glob(path, storage_options): @pytest.mark.parametrize( "filename", ["test_default_to_pickle.pkl", "test_to_pickle*.pkl"] ) -def test_distributed_pickling(tmp_path, filename, compression, pathlike): +@pytest.mark.parametrize("read_func", ["read_pickle_glob", "read_pickle_distributed"]) +@pytest.mark.parametrize("to_func", ["to_pickle_glob", "to_pickle_distributed"]) +def test_distributed_pickling( + tmp_path, filename, compression, pathlike, read_func, to_func +): data = test_data["int_data"] df = pd.DataFrame(data) @@ -264,10 +268,8 @@ def test_distributed_pickling(tmp_path, filename, compression, pathlike): if filename_param == "test_default_to_pickle.pkl" else contextlib.nullcontext() ): - df.modin.to_pickle_distributed( - str(tmp_path / filename), compression=compression - ) - pickled_df = pd.read_pickle_distributed( + getattr(df.modin, to_func)(str(tmp_path / filename), compression=compression) + pickled_df = getattr(pd, read_func)( str(tmp_path / filename), compression=compression ) df_equals(pickled_df, df) diff --git a/modin/pandas/accessor.py b/modin/pandas/accessor.py index 0eb9c20cadf..623293e7402 100644 --- a/modin/pandas/accessor.py +++ b/modin/pandas/accessor.py @@ -22,6 +22,7 @@ """ import pickle +import warnings import pandas from pandas._typing import CompressionOptions, StorageOptions @@ -209,7 +210,7 @@ class ExperimentalFunctions: def __init__(self, data): self._data = data - def to_pickle_distributed( + def to_pickle_glob( self, filepath_or_buffer, compression: CompressionOptions = "infer", @@ -248,9 +249,9 @@ def to_pickle_distributed( this argument with a non-fsspec URL. See the fsspec and backend storage implementation docs for the set of allowed keys and values. """ - from modin.experimental.pandas.io import to_pickle_distributed + from modin.experimental.pandas.io import to_pickle_glob - to_pickle_distributed( + to_pickle_glob( self._data, filepath_or_buffer=filepath_or_buffer, compression=compression, @@ -258,6 +259,22 @@ def to_pickle_distributed( storage_options=storage_options, ) + def to_pickle_distributed( + self, + filepath_or_buffer, + compression: CompressionOptions = "infer", + protocol: int = pickle.HIGHEST_PROTOCOL, + storage_options: StorageOptions = None, + ) -> None: # noqa + warnings.warn( + "`DataFrame.modin.to_pickle_distributed` is deprecated and will be removed in a future version. " + + "Please use `DataFrame.modin.to_pickle_glob` instead.", + category=FutureWarning, + ) + return self.to_pickle_glob( + filepath_or_buffer, compression, protocol, storage_options + ) + def to_parquet_glob( self, path, From e36749008fdf277d08f01c5dc675edb1b8d62512 Mon Sep 17 00:00:00 2001 From: Iaroslav Igoshev Date: Fri, 23 Feb 2024 18:56:16 +0100 Subject: [PATCH 193/201] DOCS-#6962: Remove links to https://discuss.modin.org (#6963) Signed-off-by: Igoshev, Iaroslav --- README.md | 1 - docs/conf.py | 5 ----- docs/contact.rst | 6 ------ docs/development/architecture.rst | 5 ++--- docs/release-procedure.md | 5 ++--- docs/usage_guide/advanced_usage/index.rst | 3 +-- 6 files changed, 5 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index b11e68ba769..31203c980bf 100644 --- a/README.md +++ b/README.md @@ -252,7 +252,6 @@ to be modular so we can plug in different components as they develop and improve #### Modin Community - [Slack](https://join.slack.com/t/modin-project/shared_invite/zt-yvk5hr3b-f08p_ulbuRWsAfg9rMY3uA) -- [Discourse](https://discuss.modin.org) - [Twitter](https://twitter.com/modin_project) - [Mailing List](https://groups.google.com/g/modin-dev) - [GitHub Issues](https://github.com/modin-project/modin/issues) diff --git a/docs/conf.py b/docs/conf.py index 882527b4d56..b32f68b7d84 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -185,11 +185,6 @@ def noop_decorator(*args, **kwargs): "url": "https://modin.org/slack.html", "icon": "fab fa-slack", }, - { - "name": "Discourse", - "url": "https://discuss.modin.org/", - "icon": "fab fa-discourse", - }, { "name": "Mailing List", "url": "https://groups.google.com/forum/#!forum/modin-dev", diff --git a/docs/contact.rst b/docs/contact.rst index c0f9ef5001e..e3377286c91 100644 --- a/docs/contact.rst +++ b/docs/contact.rst @@ -7,11 +7,6 @@ Slack Join our `Slack`_ community to connect with Modin users and contributors, discuss, and ask questions about all things Modin-related. -Discussion forum ----------------- -Check out our `discussion forum`_ to discuss release announcements, general -questions, issues, use-cases, and tutorials. - Mailing List ------------ @@ -27,6 +22,5 @@ Bug reports and feature requests can be directed to the issues_ page of the Modi GitHub repo. .. _Slack: https://modin.org/slack.html -.. _discussion forum: https://discuss.modin.org/ .. _developer mailing list: https://groups.google.com/forum/#!forum/modin-dev .. _issues: https://github.com/modin-project/modin/issues diff --git a/docs/development/architecture.rst b/docs/development/architecture.rst index cdb311d928b..557e794c7c0 100644 --- a/docs/development/architecture.rst +++ b/docs/development/architecture.rst @@ -114,8 +114,8 @@ More documentation can be found internally in the code_. This API is not complet represents an overwhelming majority of operations and behaviors. This API can be implemented by other distributed/parallel DataFrame libraries and -plugged in to Modin as well. Create an issue_ or discuss on our Discourse_ or `Slack `_ for more -information! +plugged in to Modin as well. Create an issue_ or discuss +on our `Slack `_ for more information! The :doc:`Core Modin Dataframe ` is responsible for the data layout and shuffling, partitioning, and serializing the tasks that get sent to each partition. Other implementations of the @@ -352,6 +352,5 @@ details. The documentation covers most modules, with more docs being added every .. _Dask: https://github.com/dask/dask .. _Dask Futures: https://docs.dask.org/en/latest/futures.html .. _issue: https://github.com/modin-project/modin/issues -.. _Discourse: https://discuss.modin.org .. _task parallel: https://en.wikipedia.org/wiki/Task_parallelism .. _experimental features: /usage_guide/advanced_usage/index.html diff --git a/docs/release-procedure.md b/docs/release-procedure.md index 10fc90f6721..a85350a22c5 100644 --- a/docs/release-procedure.md +++ b/docs/release-procedure.md @@ -137,6 +137,5 @@ to make new Modin release appear in `conda-forge` channel. For detailed instruct on how to ensure the PR passes CI and is merge-able, check out [the how-to page in the modin-feedstock repo](https://github.com/conda-forge/modin-feedstock/blob/main/HOWTO.md)! ## Publicize Release -Once the release has been finalized, make sure to post an announcement in the #general channel of -the public Modin Slack, as well as in the discourse (discuss.modin.org)! Make sure to unpin the -previous announcement on discourse, and globally pin the new announcement! +Once the release has been finalized, make sure to post an announcement +in the #general channel of the public Modin Slack! diff --git a/docs/usage_guide/advanced_usage/index.rst b/docs/usage_guide/advanced_usage/index.rst index 3ca050403ed..da2b83aad37 100644 --- a/docs/usage_guide/advanced_usage/index.rst +++ b/docs/usage_guide/advanced_usage/index.rst @@ -21,7 +21,7 @@ Advanced Usage Modin aims to not only optimize pandas, but also provide a comprehensive, integrated toolkit for data scientists. We are actively developing data science tools such as DataFrame spreadsheet integration, DataFrame algebra, progress bars, SQL queries -on DataFrames, and more. Join us on `Slack`_ and `Discourse`_ for the latest updates! +on DataFrames, and more. Join us on `Slack`_ for the latest updates! Modin engines ------------- @@ -126,7 +126,6 @@ downloaded as an artifact from the GitHub Actions tab for further inspection. Se .. _`Modin Spreadsheet API documentation`: spreadsheets_api.html .. _`Progress Bar documentation`: progress_bar.html .. _`Paper`: https://arxiv.org/pdf/2001.00888.pdf -.. _`Discourse`: https://discuss.modin.org .. _`Slack`: https://modin.org/slack.html .. _`tqdm`: https://github.com/tqdm/tqdm .. _`distributed XGBoost`: https://medium.com/intel-analytics-software/distributed-xgboost-with-modin-on-ray-fc17edef7720 From a1d5dd4778ce517429974ed1d31dfc2984301b70 Mon Sep 17 00:00:00 2001 From: Dmitry Chigarev Date: Mon, 26 Feb 2024 18:18:30 +0100 Subject: [PATCH 194/201] FIX-#6968: Align API with pandas (#6969) Signed-off-by: Dmitry Chigarev --- modin/pandas/groupby.py | 8 ++++---- modin/pandas/series.py | 10 ++++++++-- modin/pandas/test/test_groupby.py | 16 ++++++++++++++++ 3 files changed, 28 insertions(+), 6 deletions(-) diff --git a/modin/pandas/groupby.py b/modin/pandas/groupby.py index 23a730e0570..ee509ff7224 100644 --- a/modin/pandas/groupby.py +++ b/modin/pandas/groupby.py @@ -682,17 +682,17 @@ def dtypes(self): ) ) - def first(self, numeric_only=False, min_count=-1): + def first(self, numeric_only=False, min_count=-1, skipna=True): return self._wrap_aggregation( type(self._query_compiler).groupby_first, - agg_kwargs=dict(min_count=min_count), + agg_kwargs=dict(min_count=min_count, skipna=skipna), numeric_only=numeric_only, ) - def last(self, numeric_only=False, min_count=-1): + def last(self, numeric_only=False, min_count=-1, skipna=True): return self._wrap_aggregation( type(self._query_compiler).groupby_last, - agg_kwargs=dict(min_count=min_count), + agg_kwargs=dict(min_count=min_count, skipna=skipna), numeric_only=numeric_only, ) diff --git a/modin/pandas/series.py b/modin/pandas/series.py index 3219961d56d..3084bea465e 100644 --- a/modin/pandas/series.py +++ b/modin/pandas/series.py @@ -706,13 +706,19 @@ def argmin(self, axis=None, skipna=True, *args, **kwargs): # noqa: PR01, RT01, result = -1 return result - def argsort(self, axis=0, kind="quicksort", order=None): # noqa: PR01, RT01, D200 + def argsort( + self, axis=0, kind="quicksort", order=None, stable=None + ): # noqa: PR01, RT01, D200 """ Return the integer indices that would sort the Series values. """ return self.__constructor__( query_compiler=self._query_compiler.argsort( - axis=axis, kind=kind, order=order + # 'stable' parameter has no effect in Pandas and is only accepted + # for compatibility with NumPy, so we're not passing it forward on purpose + axis=axis, + kind=kind, + order=order, ) ) diff --git a/modin/pandas/test/test_groupby.py b/modin/pandas/test/test_groupby.py index d4e93aafc94..fe1cd865a15 100644 --- a/modin/pandas/test/test_groupby.py +++ b/modin/pandas/test/test_groupby.py @@ -3440,3 +3440,19 @@ def func(df): func, include_groups=include_groups ), ) + + +@pytest.mark.parametrize("skipna", [True, False]) +@pytest.mark.parametrize("how", ["first", "last"]) +def test_first_last_skipna(how, skipna): + md_df, pd_df = create_test_dfs( + { + "a": [2, 1, 1, 2, 3, 3] * 20, + "b": [np.nan, 3.0, np.nan, 4.0, np.nan, np.nan] * 20, + "c": [np.nan, 3.0, np.nan, 4.0, np.nan, np.nan] * 20, + } + ) + + pd_res = getattr(pd_df.groupby("a"), how)(skipna=skipna) + md_res = getattr(md_df.groupby("a"), how)(skipna=skipna) + df_equals(md_res, pd_res) From 271d98b260f03b345ae2384001f2df83b98322bf Mon Sep 17 00:00:00 2001 From: Kirill Suvorov Date: Tue, 27 Feb 2024 11:12:46 +0100 Subject: [PATCH 195/201] DOCS-#6949: Create Modin on Dask cluster tutorial (#6950) Co-authored-by: Iaroslav Igoshev Co-authored-by: Anatoly Myachev Signed-off-by: Kirill Suvorov --- .../pandas_on_dask/cluster/exercise_5.ipynb | 372 ++++++++++++++++++ 1 file changed, 372 insertions(+) create mode 100644 examples/tutorial/jupyter/execution/pandas_on_dask/cluster/exercise_5.ipynb diff --git a/examples/tutorial/jupyter/execution/pandas_on_dask/cluster/exercise_5.ipynb b/examples/tutorial/jupyter/execution/pandas_on_dask/cluster/exercise_5.ipynb new file mode 100644 index 00000000000..b13089bb1a1 --- /dev/null +++ b/examples/tutorial/jupyter/execution/pandas_on_dask/cluster/exercise_5.ipynb @@ -0,0 +1,372 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "![LOGO](../../../img/MODIN_ver2_hrz.png)\n", + "\n", + "

Scale your pandas workflows by changing one line of code

" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Exercise 5: Setting up cluster environment\n", + "\n", + "**GOAL**: Learn how to set up a Dask cluster for Modin, connect Modin to a Dask cluster and run pandas queries on a cluster.\n", + "\n", + "**NOTE**: This exercise has extra requirements. Read instructions carefully before attempting. \n", + "\n", + "**This exercise instructs users on how to start a 500+ core Dask cluster, and it is not shut down until the end of exercise.**" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Often in practice we have a need to exceed the capabilities of a single machine. Modin works and performs well \n", + "in both local mode and in a cluster environment. The key advantage of Modin is that your python code does not \n", + "change between local development and cluster execution. Users are not required to think about how many workers \n", + "exist or how to distribute and partition their data; Modin handles all of this seamlessly and transparently.\n", + "\n", + "![Cluster](../../../img/modin_cluster.png)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Extra requirements for AWS authentication\n", + "\n", + "First of all, install the necessary dependencies in your environment:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "!pip install dask_cloudprovider[aws]" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The next step is to setup your AWS credentials, namely, set ``AWS_ACCESS_KEY_ID``, ``AWS_SECRET_ACCESS_KEY``\n", + "and ``AWS_SESSION_TOKEN`` (Optional) (refer to [AWS CLI environment variables](https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-envvars.html) to get more insight on this):" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import os\n", + "\n", + "os.environ[\"AWS_ACCESS_KEY_ID\"] = \"\"\n", + "os.environ[\"AWS_SECRET_ACCESS_KEY\"] = \"\"\n", + "os.environ[\"AWS_SESSION_TOKEN\"] = \"\"" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Starting and connecting to the cluster\n", + "\n", + "This example starts 1 scheduler node (m5.24xlarge) and 6 worker nodes (m5.24xlarge), 576 total CPUs. Keep in mind the scheduler node manages cluster operation but doesn't perform any execution.\n", + "\n", + "You can check the [Amazon EC2 pricing](https://aws.amazon.com/ec2/pricing/on-demand/) page.\n", + "\n", + "Dask cluster can be deployed in different ways (refer to [Dask documentaion](https://docs.dask.org/en/latest/deploying.html) to get more information about it), but in this tutorial we will use the ``EC2Cluster`` from [dask_cloudprovider](https://cloudprovider.dask.org/en/latest/) to create and initialize a Dask cluster on Amazon Web Service (AWS).\n", + "\n", + "**Note**: EC2Cluster uses a docker container to run the scheduler and each of the workers. Probably you need to use another docker image depending on your python version and requirements. You can find more docker-images on [daskdev](https://hub.docker.com/u/daskdev) page.\n", + "\n", + "In the next cell you can see how the EC2Cluster is being created. Set your ``key_name`` and modify AWS settings as required before running it." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from dask_cloudprovider.aws import EC2Cluster\n", + "\n", + "n_workers = 6\n", + "cluster = EC2Cluster(\n", + " # AWS parameters\n", + " key_name = \"\", # set your keyname\n", + " region = \"us-west-2\",\n", + " availability_zone = [\"us-west-2a\"],\n", + " ami = \"ami-0387d929287ab193e\",\n", + " instance_type = \"m5.24xlarge\",\n", + " vpc = \"vpc-002bd14c63f227832\",\n", + " subnet_id = \"subnet-09860dafd79720938\",\n", + " filesystem_size = 200, # in GB\n", + "\n", + " # DASK parameters\n", + " n_workers = n_workers,\n", + " docker_image = \"daskdev/dask:latest\",\n", + " debug = True,\n", + " security=False,\n", + ")\n", + "\n", + "scheduler_adress = cluster.scheduler_address\n", + "print(f\"Scheduler IP address of Dask cluster: {scheduler_adress}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "After creating the cluster you need to connect to it. To do this you should put the ``EC2Cluster`` instance or the scheduler IP address in ``distributed.Client``.\n", + "\n", + "When you connect to the cluster, the workers may not be initialized yet, so you need to wait for them using ``client.wait_for_workers``.\n", + "\n", + "Then you can call ``client.ncores()`` and check which workers are available and how many threads are used for each of them." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from distributed import Client\n", + "\n", + "client = Client(cluster)\n", + "# Or use an IP address connection if the cluster instance is unavailable:\n", + "# client = Client(f\"{scheduler_adress}:8687\")\n", + "\n", + "client.wait_for_workers(n_workers)\n", + "client.ncores()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "After successful initialization of the cluster, you need to configure it.\n", + "\n", + "You can use plugins to install any requirements into workers:\n", + "* [InstallPlugin](https://distributed.dask.org/en/stable/plugins.html#distributed.diagnostics.plugin.InstallPlugin)\n", + "* [PipInstall](https://distributed.dask.org/en/stable/plugins.html#distributed.diagnostics.plugin.PipInstall)\n", + "* [CondaInstall](https://distributed.dask.org/en/stable/plugins.html#distributed.diagnostics.plugin.CondaInstall).\n", + "\n", + "You have to install Modin package on each worker using ``PipInstall`` plugin." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from dask.distributed import PipInstall\n", + "\n", + "client.register_plugin(PipInstall(packages=[\"modin\"]))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "If you need an additional workers configuration, you can create your own [WorkerPlugin](https://distributed.dask.org/en/stable/plugins.html#worker-plugins) or function that will be executed on each worker upon calling ``client.run()``.\n", + "\n", + "**NOTE**: Dask cluster does not check if this plugin or function has been called before. Therefore, you need to take this into account when using them.\n", + "\n", + "In this tutorial a CSV file will be read, so you need to download it to each of the workers and local machine with the same global path." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from dask.distributed import Worker\n", + "import os\n", + "import urllib\n", + "\n", + "def dataset_upload(file_url, file_path):\n", + " try:\n", + " dir_name = os.path.dirname(file_path)\n", + " if not os.path.exists(dir_name):\n", + " os.makedirs(dir_name)\n", + " if os.path.exists(file_path):\n", + " return \"File has already existed.\"\n", + " else:\n", + " urllib.request.urlretrieve(file_url, file_path)\n", + " return \"OK\"\n", + " except Exception as ex:\n", + " return str(ex)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Set the directory where it should be downloaded (the local directory will be used by default):" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "directory_path = \"./\"" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Then you need to run `dataset_upload` function on all workers. As the result, you will get a dictionary, where the result of the function execution will be for each workers:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "file_path = os.path.join(os.path.abspath(directory_path), \"taxi.csv\")\n", + "client.run(dataset_upload, \"https://modin-datasets.intel.com/testing/yellow_tripdata_2015-01.csv\", file_path)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "You have to also execute this function on the local machine:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "dataset_upload(\"https://modin-datasets.intel.com/testing/yellow_tripdata_2015-01.csv\", file_path)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Congratulations! The cluster is now fully configured and we can start running Pandas queries." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Executing in a cluster environment\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Same as local mode Modin on cluster uses Ray as an execution engine by default so no additional action is required to start to use it. Alternatively, if you need to use another engine, it should be specified either by setting the Modin config or by setting Modin environment variable before the first operation with Modin as it is shown below. Also, note that the full list of Modin configs and corresponding environment variables can be found in the [Modin Configuration Settings](https://modin.readthedocs.io/en/stable/flow/modin/config.html#modin-configs-list) section of the Modin documentation." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Modin engine can be specified either by config\n", + "import modin.config as cfg\n", + "cfg.Engine.put(\"dask\")\n", + "\n", + "# or by setting the environment variable\n", + "# import os\n", + "# os.environ[\"MODIN_ENGINE\"] = \"dask\"" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Now you can use Modin on the Dask cluster.\n", + "\n", + "Let's read the downloaded CSV file and execute such pandas operations as count, groupby and map:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import modin.pandas as pd\n", + "import time\n", + "\n", + "t0 = time.perf_counter()\n", + "\n", + "df = pd.read_csv(file_path, quoting=3)\n", + "df_count = df.count()\n", + "df_groupby_count = df.groupby(\"passenger_count\").count()\n", + "df_map = df.map(str)\n", + "\n", + "t1 = time.perf_counter()\n", + "print(f\"Full script time is {(t1 - t0):.3f}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Shutting down the cluster\n", + "\n", + "Now that we have finished computation, we can shut down the cluster:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "cluster.close()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### This ends the cluster exercise" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.12" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} From f601f8a9d602c70333548df3ef5e3c953df04f37 Mon Sep 17 00:00:00 2001 From: Dmitry Chigarev Date: Wed, 28 Feb 2024 16:36:06 +0100 Subject: [PATCH 196/201] FIX-#6974: Always use actual pandas version in 'test_all_urls_exist' (#6975) Signed-off-by: Dmitry Chigarev --- modin/test/test_docstring_urls.py | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/modin/test/test_docstring_urls.py b/modin/test/test_docstring_urls.py index 35a2e114099..b4f89467abf 100644 --- a/modin/test/test_docstring_urls.py +++ b/modin/test/test_docstring_urls.py @@ -20,6 +20,7 @@ import pytest import modin.pandas +from modin.utils import PANDAS_API_URL_TEMPLATE @pytest.fixture @@ -37,18 +38,18 @@ def doc_urls(get_generated_doc_urls): def test_all_urls_exist(doc_urls): broken = [] # TODO: remove the hack after pandas fixes it - broken_urls = ( - "https://pandas.pydata.org/pandas-docs/version/2.2.0/reference/api/pandas.DataFrame.flags.html", - "https://pandas.pydata.org/pandas-docs/version/2.2.0/reference/api/pandas.Series.info.html", - "https://pandas.pydata.org/pandas-docs/version/2.2.0/reference/api/pandas.DataFrame.isetitem.html", - "https://pandas.pydata.org/pandas-docs/version/2.2.0/reference/api/pandas.Series.swapaxes.html", - "https://pandas.pydata.org/pandas-docs/version/2.2.0/reference/api/pandas.DataFrame.to_numpy.html", - "https://pandas.pydata.org/pandas-docs/version/2.2.0/reference/api/pandas.Series.axes.html", - "https://pandas.pydata.org/pandas-docs/version/2.2.0/reference/api/pandas.Series.divmod.html", - "https://pandas.pydata.org/pandas-docs/version/2.2.0/reference/api/pandas.Series.rdivmod.html", + methods_with_broken_urls = ( + "pandas.DataFrame.flags", + "pandas.Series.info", + "pandas.DataFrame.isetitem", + "pandas.Series.swapaxes", + "pandas.DataFrame.to_numpy", + "pandas.Series.axes", + "pandas.Series.divmod", + "pandas.Series.rdivmod", ) - for broken_url in broken_urls: - doc_urls.remove(broken_url) + for broken_method in methods_with_broken_urls: + doc_urls.remove(PANDAS_API_URL_TEMPLATE.format(broken_method)) def _test_url(url): try: From b3e79edfb2cba027102f51211dfb1fce0986dfdf Mon Sep 17 00:00:00 2001 From: Dmitry Chigarev Date: Thu, 29 Feb 2024 12:56:49 +0100 Subject: [PATCH 197/201] PERF-#6979: Do not trigger `._copartition()` for identical indices on binary operations (#6980) Signed-off-by: Dmitry Chigarev --- .../dataframe/pandas/dataframe/dataframe.py | 28 ++++++++++++++----- .../storage_formats/pandas/test_internals.py | 22 +++++++++++++++ 2 files changed, 43 insertions(+), 7 deletions(-) diff --git a/modin/core/dataframe/pandas/dataframe/dataframe.py b/modin/core/dataframe/pandas/dataframe/dataframe.py index b69bf25ad61..126539d5c49 100644 --- a/modin/core/dataframe/pandas/dataframe/dataframe.py +++ b/modin/core/dataframe/pandas/dataframe/dataframe.py @@ -196,7 +196,7 @@ def row_lengths(self): @classmethod def _get_lengths(cls, parts, axis): """ - Get list of dimensions for all the provided parts. + Get list of dimensions for all the provided parts. Parameters ---------- @@ -3548,14 +3548,24 @@ def _copartition(self, axis, other, how, sort, force_repartition=False): Tuple containing: 1) 2-d NumPy array of aligned left partitions 2) list of 2-d NumPy arrays of aligned right partitions - 3) joined index along ``axis`` - 4) List with sizes of partitions along axis that partitioning - was done on. This list will be empty if and only if all + 3) joined index along ``axis``, may be ``ModinIndex`` if not materialized + 4) If materialized, list with sizes of partitions along axis that partitioning + was done on, otherwise ``None``. This list will be empty if and only if all the frames are empty. """ if isinstance(other, type(self)): other = [other] + if not force_repartition and all( + o._check_if_axes_identical(self, axis) for o in other + ): + return ( + self._partitions, + [o._partitions for o in other], + self.copy_axis_cache(axis, copy_lengths=True), + self._get_axis_lengths_cache(axis), + ) + self_index = self.get_axis(axis) others_index = [o.get_axis(axis) for o in other] joined_index, make_reindexer = self._join_index_objects( @@ -3684,15 +3694,19 @@ def n_ary_op( ) if copartition_along_columns: new_left_frame = self.__constructor__( - left_parts, joined_index, self.columns, row_lengths, self.column_widths + left_parts, + joined_index, + self.copy_columns_cache(copy_lengths=True), + row_lengths, + self._column_widths_cache, ) new_right_frames = [ self.__constructor__( right_parts, joined_index, - right_frame.columns, + right_frame.copy_columns_cache(copy_lengths=True), row_lengths, - right_frame.column_widths, + right_frame._column_widths_cache, ) for right_parts, right_frame in zip(list_of_right_parts, right_frames) ] diff --git a/modin/test/storage_formats/pandas/test_internals.py b/modin/test/storage_formats/pandas/test_internals.py index fe96a7c80bb..33d9ca1929e 100644 --- a/modin/test/storage_formats/pandas/test_internals.py +++ b/modin/test/storage_formats/pandas/test_internals.py @@ -1319,6 +1319,28 @@ def test_filter_empties_resets_lengths(self): assert new_cache._lengths_id == old_cache._lengths_id assert new_cache._lengths_cache == old_cache._lengths_cache + def test_binops_without_repartitioning(self): + """Test that binary operations for identical indices works without materializing the axis.""" + df = pd.DataFrame({f"col{i}": np.arange(256) for i in range(64)}) + remove_axis_cache(df) + + col1 = df["col1"] + assert_has_no_cache(col1) + assert_has_no_cache(df) + + col2 = df["col2"] + assert_has_no_cache(col2) + assert_has_no_cache(df) + + # perform a binary op and insert the result back then check that no index computation were triggered + with self._patch_get_index(df) as get_index_df: + df["result"] = col1 + col2 + # check that no cache computation was triggered + assert_has_no_cache(df) + assert_has_no_cache(col1) + assert_has_no_cache(col2) + get_index_df.assert_not_called() + def test_skip_set_columns(): """ From 9adaf3396cd88b1cc1ab25a72d4373d874cc6069 Mon Sep 17 00:00:00 2001 From: Dmitry Chigarev Date: Thu, 29 Feb 2024 12:59:38 +0100 Subject: [PATCH 198/201] PERF-#6976: Do not trigger unnecessary computations on `._propagate_index_objs()` (#6977) Signed-off-by: Dmitry Chigarev --- modin/core/dataframe/pandas/dataframe/dataframe.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/modin/core/dataframe/pandas/dataframe/dataframe.py b/modin/core/dataframe/pandas/dataframe/dataframe.py index 126539d5c49..4235370497f 100644 --- a/modin/core/dataframe/pandas/dataframe/dataframe.py +++ b/modin/core/dataframe/pandas/dataframe/dataframe.py @@ -880,7 +880,7 @@ def _propagate_index_objs(self, axis=None): axis : int, default: None The axis to apply to. If it's None applies to both axes. """ - self._filter_empties() + self._filter_empties(compute_metadata=False) if axis is None or axis == 0: cum_row_lengths = np.cumsum([0] + self.row_lengths) if axis is None or axis == 1: @@ -931,7 +931,11 @@ def apply_idx_objs(df, idx): slice(cum_row_lengths[i], cum_row_lengths[i + 1]) ], length=self.row_lengths[i], - width=self.column_widths[j], + width=( + self.column_widths[j] + if self._column_widths_cache is not None + else None + ), ) for j in range(len(self._partitions[i])) ] @@ -952,7 +956,11 @@ def apply_idx_objs(df, cols): cols=self.columns[ slice(cum_col_widths[j], cum_col_widths[j + 1]) ], - length=self.row_lengths[i], + length=( + self.row_lengths[i] + if self._row_lengths_cache is not None + else None + ), width=self.column_widths[j], ) for j in range(len(self._partitions[i])) From cee9b9819803e029c31961b4cf2c913b9f00b962 Mon Sep 17 00:00:00 2001 From: Devin Petersohn Date: Fri, 1 Mar 2024 02:45:37 -0600 Subject: [PATCH 199/201] FEAT-#3044: Create Extentions Module in Modin (#6961) * FEAT-#6960: Create Exentions Module in Modin --------- Signed-off-by: Devin Petersohn Co-authored-by: Iaroslav Igoshev --- .github/workflows/ci.yml | 1 + modin/pandas/__init__.py | 26 +++ modin/pandas/api/__init__.py | 16 ++ modin/pandas/api/extensions/__init__.py | 24 +++ modin/pandas/api/extensions/extensions.py | 163 ++++++++++++++++++ modin/pandas/api/extensions/test/__init__.py | 12 ++ .../test/test_dataframe_extensions.py | 54 ++++++ .../api/extensions/test/test_pd_extensions.py | 37 ++++ .../extensions/test/test_series_extensions.py | 54 ++++++ modin/pandas/dataframe.py | 5 +- modin/pandas/series.py | 5 +- 11 files changed, 395 insertions(+), 2 deletions(-) create mode 100644 modin/pandas/api/__init__.py create mode 100644 modin/pandas/api/extensions/__init__.py create mode 100644 modin/pandas/api/extensions/extensions.py create mode 100644 modin/pandas/api/extensions/test/__init__.py create mode 100644 modin/pandas/api/extensions/test/test_dataframe_extensions.py create mode 100644 modin/pandas/api/extensions/test/test_pd_extensions.py create mode 100644 modin/pandas/api/extensions/test/test_series_extensions.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7b7ce6198bf..37dc948aa7e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -584,6 +584,7 @@ jobs: - run: MODIN_BENCHMARK_MODE=True ${{ matrix.execution.shell-ex }} modin/pandas/test/internals/test_benchmark_mode.py - run: ${{ matrix.execution.shell-ex }} $PARALLEL modin/pandas/test/internals/test_repartition.py - run: ${{ matrix.execution.shell-ex }} $PARALLEL modin/test/test_partition_api.py + - run: ${{ matrix.execution.shell-ex }} modin/pandas/api/extensions/test - name: xgboost tests run: | # TODO(https://github.com/modin-project/modin/issues/5194): Uncap xgboost diff --git a/modin/pandas/__init__.py b/modin/pandas/__init__.py index 1d1b53b035c..1c40219d55e 100644 --- a/modin/pandas/__init__.py +++ b/modin/pandas/__init__.py @@ -27,6 +27,9 @@ + f" Modin ({__pandas_version__}.X). This may cause undesired side effects!" ) +# The extensions assigned to this module +_PD_EXTENSIONS_ = {} + # to not pollute namespace del version @@ -225,7 +228,30 @@ def _update_engine(publisher: Parameter): from .plotting import Plotting as plotting from .series import Series + +def __getattr__(name: str): + """ + Overrides getattr on the module to enable extensions. + + Parameters + ---------- + name : str + The name of the attribute being retrieved. + + Returns + ------- + Attribute + Returns the extension attribute, if it exists, otherwise returns the attribute + imported in this file. + """ + try: + return _PD_EXTENSIONS_.get(name, globals()[name]) + except KeyError: + raise AttributeError(f"module 'modin.pandas' has no attribute '{name}'") + + __all__ = [ # noqa: F405 + "_PD_EXTENSIONS_", "DataFrame", "Series", "read_csv", diff --git a/modin/pandas/api/__init__.py b/modin/pandas/api/__init__.py new file mode 100644 index 00000000000..86b1ba44f0a --- /dev/null +++ b/modin/pandas/api/__init__.py @@ -0,0 +1,16 @@ +# Licensed to Modin Development Team under one or more contributor license agreements. +# See the NOTICE file distributed with this work for additional information regarding +# copyright ownership. The Modin Development Team licenses this file to you under the +# Apache License, Version 2.0 (the "License"); you may not use this file except in +# compliance with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software distributed under +# the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF +# ANY KIND, either express or implied. See the License for the specific language +# governing permissions and limitations under the License. + +from modin.pandas.api import extensions + +__all__ = ["extensions"] diff --git a/modin/pandas/api/extensions/__init__.py b/modin/pandas/api/extensions/__init__.py new file mode 100644 index 00000000000..58da23d5ad6 --- /dev/null +++ b/modin/pandas/api/extensions/__init__.py @@ -0,0 +1,24 @@ +# Licensed to Modin Development Team under one or more contributor license agreements. +# See the NOTICE file distributed with this work for additional information regarding +# copyright ownership. The Modin Development Team licenses this file to you under the +# Apache License, Version 2.0 (the "License"); you may not use this file except in +# compliance with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software distributed under +# the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF +# ANY KIND, either express or implied. See the License for the specific language +# governing permissions and limitations under the License. + +from .extensions import ( + register_dataframe_accessor, + register_pd_accessor, + register_series_accessor, +) + +__all__ = [ + "register_dataframe_accessor", + "register_series_accessor", + "register_pd_accessor", +] diff --git a/modin/pandas/api/extensions/extensions.py b/modin/pandas/api/extensions/extensions.py new file mode 100644 index 00000000000..64830423d4e --- /dev/null +++ b/modin/pandas/api/extensions/extensions.py @@ -0,0 +1,163 @@ +# Licensed to Modin Development Team under one or more contributor license agreements. +# See the NOTICE file distributed with this work for additional information regarding +# copyright ownership. The Modin Development Team licenses this file to you under the +# Apache License, Version 2.0 (the "License"); you may not use this file except in +# compliance with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software distributed under +# the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF +# ANY KIND, either express or implied. See the License for the specific language +# governing permissions and limitations under the License. + +from types import ModuleType +from typing import Any, Union + +import modin.pandas as pd + + +def _set_attribute_on_obj( + name: str, extensions_dict: dict, obj: Union[pd.DataFrame, pd.Series, ModuleType] +): + """ + Create a new or override existing attribute on obj. + + Parameters + ---------- + name : str + The name of the attribute to assign to `obj`. + extensions_dict : dict + The dictionary mapping extension name to `new_attr` (assigned below). + obj : DataFrame, Series, or modin.pandas + The object we are assigning the new attribute to. + + Returns + ------- + decorator + Returns the decorator function. + """ + + def decorator(new_attr: Any): + """ + The decorator for a function or class to be assigned to name + + Parameters + ---------- + new_attr : Any + The new attribute to assign to name. + + Returns + ------- + new_attr + Unmodified new_attr is return from the decorator. + """ + extensions_dict[name] = new_attr + setattr(obj, name, new_attr) + return new_attr + + return decorator + + +def register_dataframe_accessor(name: str): + """ + Registers a dataframe attribute with the name provided. + + This is a decorator that assigns a new attribute to DataFrame. It can be used + with the following syntax: + + ``` + @register_dataframe_accessor("new_method") + def my_new_dataframe_method(*args, **kwargs): + # logic goes here + return + ``` + + The new attribute can then be accessed with the name provided: + + ``` + df.new_method(*my_args, **my_kwargs) + ``` + + Parameters + ---------- + name : str + The name of the attribute to assign to DataFrame. + + Returns + ------- + decorator + Returns the decorator function. + """ + return _set_attribute_on_obj( + name, pd.dataframe._DATAFRAME_EXTENSIONS_, pd.DataFrame + ) + + +def register_series_accessor(name: str): + """ + Registers a series attribute with the name provided. + + This is a decorator that assigns a new attribute to Series. It can be used + with the following syntax: + + ``` + @register_series_accessor("new_method") + def my_new_series_method(*args, **kwargs): + # logic goes here + return + ``` + + The new attribute can then be accessed with the name provided: + + ``` + s.new_method(*my_args, **my_kwargs) + ``` + + Parameters + ---------- + name : str + The name of the attribute to assign to Series. + + Returns + ------- + decorator + Returns the decorator function. + """ + return _set_attribute_on_obj(name, pd.series._SERIES_EXTENSIONS_, pd.Series) + + +def register_pd_accessor(name: str): + """ + Registers a pd namespace attribute with the name provided. + + This is a decorator that assigns a new attribute to modin.pandas. It can be used + with the following syntax: + + ``` + @register_pd_accessor("new_function") + def my_new_pd_function(*args, **kwargs): + # logic goes here + return + ``` + + The new attribute can then be accessed with the name provided: + + ``` + import modin.pandas as pd + + pd.new_method(*my_args, **my_kwargs) + ``` + + + Parameters + ---------- + name : str + The name of the attribute to assign to modin.pandas. + + Returns + ------- + decorator + Returns the decorator function. + """ + return _set_attribute_on_obj(name, pd._PD_EXTENSIONS_, pd) diff --git a/modin/pandas/api/extensions/test/__init__.py b/modin/pandas/api/extensions/test/__init__.py new file mode 100644 index 00000000000..cae6413e559 --- /dev/null +++ b/modin/pandas/api/extensions/test/__init__.py @@ -0,0 +1,12 @@ +# Licensed to Modin Development Team under one or more contributor license agreements. +# See the NOTICE file distributed with this work for additional information regarding +# copyright ownership. The Modin Development Team licenses this file to you under the +# Apache License, Version 2.0 (the "License"); you may not use this file except in +# compliance with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software distributed under +# the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF +# ANY KIND, either express or implied. See the License for the specific language +# governing permissions and limitations under the License. diff --git a/modin/pandas/api/extensions/test/test_dataframe_extensions.py b/modin/pandas/api/extensions/test/test_dataframe_extensions.py new file mode 100644 index 00000000000..cb5b2772970 --- /dev/null +++ b/modin/pandas/api/extensions/test/test_dataframe_extensions.py @@ -0,0 +1,54 @@ +# Licensed to Modin Development Team under one or more contributor license agreements. +# See the NOTICE file distributed with this work for additional information regarding +# copyright ownership. The Modin Development Team licenses this file to you under the +# Apache License, Version 2.0 (the "License"); you may not use this file except in +# compliance with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software distributed under +# the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF +# ANY KIND, either express or implied. See the License for the specific language +# governing permissions and limitations under the License. + +import modin.pandas as pd +from modin.pandas.api.extensions import register_dataframe_accessor + + +def test_dataframe_extension_simple_method(): + expected_string_val = "Some string value" + method_name = "new_method" + df = pd.DataFrame([1, 2, 3]) + + @register_dataframe_accessor(method_name) + def my_method_implementation(self): + return expected_string_val + + assert method_name in pd.dataframe._DATAFRAME_EXTENSIONS_.keys() + assert pd.dataframe._DATAFRAME_EXTENSIONS_[method_name] is my_method_implementation + assert df.new_method() == expected_string_val + + +def test_dataframe_extension_non_method(): + expected_val = 4 + attribute_name = "four" + register_dataframe_accessor(attribute_name)(expected_val) + df = pd.DataFrame([1, 2, 3]) + + assert attribute_name in pd.dataframe._DATAFRAME_EXTENSIONS_.keys() + assert pd.dataframe._DATAFRAME_EXTENSIONS_[attribute_name] == 4 + assert df.four == expected_val + + +def test_dataframe_extension_accessing_existing_methods(): + df = pd.DataFrame([1, 2, 3]) + method_name = "self_accessor" + expected_result = df.sum() / df.count() + + @register_dataframe_accessor(method_name) + def my_average(self): + return self.sum() / self.count() + + assert method_name in pd.dataframe._DATAFRAME_EXTENSIONS_.keys() + assert pd.dataframe._DATAFRAME_EXTENSIONS_[method_name] is my_average + assert df.self_accessor().equals(expected_result) diff --git a/modin/pandas/api/extensions/test/test_pd_extensions.py b/modin/pandas/api/extensions/test/test_pd_extensions.py new file mode 100644 index 00000000000..65da822694b --- /dev/null +++ b/modin/pandas/api/extensions/test/test_pd_extensions.py @@ -0,0 +1,37 @@ +# Licensed to Modin Development Team under one or more contributor license agreements. +# See the NOTICE file distributed with this work for additional information regarding +# copyright ownership. The Modin Development Team licenses this file to you under the +# Apache License, Version 2.0 (the "License"); you may not use this file except in +# compliance with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software distributed under +# the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF +# ANY KIND, either express or implied. See the License for the specific language +# governing permissions and limitations under the License. + +import modin.pandas as pd +from modin.pandas.api.extensions import register_pd_accessor + + +def test_dataframe_extension_simple_method(): + expected_string_val = "Some string value" + method_name = "new_method" + + @register_pd_accessor(method_name) + def my_method_implementation(): + return expected_string_val + + assert method_name in pd._PD_EXTENSIONS_.keys() + assert pd._PD_EXTENSIONS_[method_name] is my_method_implementation + assert pd.new_method() == expected_string_val + + +def test_dataframe_extension_non_method(): + expected_val = 4 + attribute_name = "four" + register_pd_accessor(attribute_name)(expected_val) + assert attribute_name in pd.dataframe._DATAFRAME_EXTENSIONS_.keys() + assert pd._PD_EXTENSIONS_[attribute_name] == 4 + assert pd.four == expected_val diff --git a/modin/pandas/api/extensions/test/test_series_extensions.py b/modin/pandas/api/extensions/test/test_series_extensions.py new file mode 100644 index 00000000000..4b58f71768f --- /dev/null +++ b/modin/pandas/api/extensions/test/test_series_extensions.py @@ -0,0 +1,54 @@ +# Licensed to Modin Development Team under one or more contributor license agreements. +# See the NOTICE file distributed with this work for additional information regarding +# copyright ownership. The Modin Development Team licenses this file to you under the +# Apache License, Version 2.0 (the "License"); you may not use this file except in +# compliance with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software distributed under +# the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF +# ANY KIND, either express or implied. See the License for the specific language +# governing permissions and limitations under the License. + +import modin.pandas as pd +from modin.pandas.api.extensions import register_series_accessor + + +def test_series_extension_simple_method(): + expected_string_val = "Some string value" + method_name = "new_method" + ser = pd.Series([1, 2, 3]) + + @register_series_accessor(method_name) + def my_method_implementation(self): + return expected_string_val + + assert method_name in pd.series._SERIES_EXTENSIONS_.keys() + assert pd.series._SERIES_EXTENSIONS_[method_name] is my_method_implementation + assert ser.new_method() == expected_string_val + + +def test_series_extension_non_method(): + expected_val = 4 + attribute_name = "four" + register_series_accessor(attribute_name)(expected_val) + ser = pd.Series([1, 2, 3]) + + assert attribute_name in pd.series._SERIES_EXTENSIONS_.keys() + assert pd.series._SERIES_EXTENSIONS_[attribute_name] == 4 + assert ser.four == expected_val + + +def test_series_extension_accessing_existing_methods(): + ser = pd.Series([1, 2, 3]) + method_name = "self_accessor" + expected_result = ser.sum() / ser.count() + + @register_series_accessor(method_name) + def my_average(self): + return self.sum() / self.count() + + assert method_name in pd.series._SERIES_EXTENSIONS_.keys() + assert pd.series._SERIES_EXTENSIONS_[method_name] is my_average + assert ser.self_accessor() == expected_result diff --git a/modin/pandas/dataframe.py b/modin/pandas/dataframe.py index 5867e76663c..130c5238d35 100644 --- a/modin/pandas/dataframe.py +++ b/modin/pandas/dataframe.py @@ -64,6 +64,9 @@ cast_function_modin2pandas, ) +# Dictionary of extensions assigned to this class +_DATAFRAME_EXTENSIONS_ = {} + @_inherit_docstrings( pandas.DataFrame, excluded=[pandas.DataFrame.__init__], apilink="pandas.DataFrame" @@ -2504,7 +2507,7 @@ def __getattr__(self, key): try to get `key` from ``DataFrame`` fields. """ try: - return object.__getattribute__(self, key) + return _DATAFRAME_EXTENSIONS_.get(key, object.__getattribute__(self, key)) except AttributeError as err: if key not in _ATTRS_NO_LOOKUP and key in self.columns: return self[key] diff --git a/modin/pandas/series.py b/modin/pandas/series.py index 3084bea465e..b079911bb82 100644 --- a/modin/pandas/series.py +++ b/modin/pandas/series.py @@ -50,6 +50,9 @@ if TYPE_CHECKING: from .dataframe import DataFrame +# Dictionary of extensions assigned to this class +_SERIES_EXTENSIONS_ = {} + @_inherit_docstrings( pandas.Series, excluded=[pandas.Series.__init__], apilink="pandas.Series" @@ -317,7 +320,7 @@ def __getattr__(self, key): try to get `key` from `Series` fields. """ try: - return object.__getattribute__(self, key) + return _SERIES_EXTENSIONS_.get(key, object.__getattribute__(self, key)) except AttributeError as err: if key not in _ATTRS_NO_LOOKUP and key in self.index: return self[key] From 2e5aba1291465a6b77cf59fc7c304ba67671b98e Mon Sep 17 00:00:00 2001 From: Anatoly Myachev Date: Fri, 1 Mar 2024 11:52:40 +0100 Subject: [PATCH 200/201] FIX-#6984: Ensure the results of inplace operations materialize (for tests) (#6985) Signed-off-by: Anatoly Myachev --- modin/pandas/test/dataframe/test_map_metadata.py | 16 ++++------------ modin/pandas/test/utils.py | 8 +++++++- 2 files changed, 11 insertions(+), 13 deletions(-) diff --git a/modin/pandas/test/dataframe/test_map_metadata.py b/modin/pandas/test/dataframe/test_map_metadata.py index b32e732c25f..2e49abd9dd3 100644 --- a/modin/pandas/test/dataframe/test_map_metadata.py +++ b/modin/pandas/test/dataframe/test_map_metadata.py @@ -1590,28 +1590,20 @@ def test_transpose(data): ({"A": [1, 2, 3], "B": [400, 500, 600]}, {"B": [4, np.nan, 6]}), ], ) -@pytest.mark.parametrize( - "raise_errors", bool_arg_values, ids=arg_keys("raise_errors", bool_arg_keys) -) -def test_update(data, other_data, raise_errors): +@pytest.mark.parametrize("errors", ["raise", "ignore"]) +def test_update(data, other_data, errors): modin_df, pandas_df = create_test_dfs(data) other_modin_df, other_pandas_df = create_test_dfs(other_data) - if raise_errors: - kwargs = {"errors": "raise"} - else: - kwargs = {} - eval_general( modin_df, pandas_df, lambda df: ( - df.update(other_modin_df) + df.update(other_modin_df, errors=errors) if isinstance(df, pd.DataFrame) - else df.update(other_pandas_df) + else df.update(other_pandas_df, errors=errors) ), __inplace__=True, - **kwargs, ) diff --git a/modin/pandas/test/utils.py b/modin/pandas/test/utils.py index afbb864dad4..55420b1d46f 100644 --- a/modin/pandas/test/utils.py +++ b/modin/pandas/test/utils.py @@ -889,7 +889,13 @@ def execute_callable(fn, inplace=False, md_kwargs={}, pd_kwargs={}): if check_exception_type is None: return None with pytest.raises(Exception) as md_e: - try_cast_to_pandas(fn(modin_df, **md_kwargs)) # force materialization + if inplace: + _ = fn(modin_df, **md_kwargs) + try_cast_to_pandas(modin_df) # force materialization + else: + try_cast_to_pandas( + fn(modin_df, **md_kwargs) + ) # force materialization if check_exception_type: assert isinstance( md_e.value, type(pd_e) From a96639529a2e121f24b8c5462e1e76c243b85ede Mon Sep 17 00:00:00 2001 From: Dmitry Chigarev Date: Fri, 1 Mar 2024 12:10:09 +0100 Subject: [PATCH 201/201] FEAT-#6965: Implement `.merge()` using range-partitioning implementation (#6966) Signed-off-by: Dmitry Chigarev --- .../actions/run-core-tests/group_2/action.yml | 2 + .github/workflows/ci.yml | 1 + .github/workflows/push-to-master.yml | 1 + docs/flow/modin/experimental/index.rst | 2 +- .../range_partitioning_groupby.rst | 6 + modin/config/__init__.py | 2 + modin/config/envvars.py | 12 + .../dataframe/pandas/dataframe/dataframe.py | 68 ++++ .../pandas/partitioning/partition_manager.py | 57 +++- modin/core/storage_formats/pandas/merge.py | 302 ++++++++++++++++++ .../storage_formats/pandas/query_compiler.py | 178 +---------- modin/pandas/test/dataframe/test_join_sort.py | 37 ++- 12 files changed, 484 insertions(+), 184 deletions(-) create mode 100644 modin/core/storage_formats/pandas/merge.py diff --git a/.github/actions/run-core-tests/group_2/action.yml b/.github/actions/run-core-tests/group_2/action.yml index d330e65061a..3022acf43a2 100644 --- a/.github/actions/run-core-tests/group_2/action.yml +++ b/.github/actions/run-core-tests/group_2/action.yml @@ -20,3 +20,5 @@ runs: modin/pandas/test/dataframe/test_pickle.py echo "::endgroup::" shell: bash -l {0} + - run: MODIN_RANGE_PARTITIONING=1 python -m pytest modin/pandas/test/dataframe/test_join_sort.py -k "merge" + shell: bash -l {0} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 37dc948aa7e..64c0e0e4a13 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -188,6 +188,7 @@ jobs: - run: python -m pytest modin/pandas/test/dataframe/test_binary.py - run: python -m pytest modin/pandas/test/dataframe/test_reduce.py - run: python -m pytest modin/pandas/test/dataframe/test_join_sort.py + - run: MODIN_RANGE_PARTITIONING=1 python -m pytest modin/pandas/test/dataframe/test_join_sort.py -k "merge" - run: python -m pytest modin/pandas/test/test_general.py - run: python -m pytest modin/pandas/test/dataframe/test_indexing.py - run: python -m pytest modin/pandas/test/test_series.py diff --git a/.github/workflows/push-to-master.yml b/.github/workflows/push-to-master.yml index 35e41bcec81..1355203f86a 100644 --- a/.github/workflows/push-to-master.yml +++ b/.github/workflows/push-to-master.yml @@ -46,6 +46,7 @@ jobs: python -m pytest modin/pandas/test/dataframe/test_indexing.py python -m pytest modin/pandas/test/dataframe/test_iter.py python -m pytest modin/pandas/test/dataframe/test_join_sort.py + MODIN_RANGE_PARTITIONING=1 python -m pytest modin/pandas/test/dataframe/test_join_sort.py -k "merge" python -m pytest modin/pandas/test/dataframe/test_map_metadata.py python -m pytest modin/pandas/test/dataframe/test_reduce.py python -m pytest modin/pandas/test/dataframe/test_udf.py diff --git a/docs/flow/modin/experimental/index.rst b/docs/flow/modin/experimental/index.rst index 6e5a1607a9d..90840e2c800 100644 --- a/docs/flow/modin/experimental/index.rst +++ b/docs/flow/modin/experimental/index.rst @@ -15,7 +15,7 @@ and provides a limited set of functionality: * :doc:`xgboost ` * :doc:`sklearn ` * :doc:`batch ` -* :doc:`Range-partitioning GroupBy implementation ` +* :doc:`Range-partitioning implementations ` .. toctree:: diff --git a/docs/flow/modin/experimental/range_partitioning_groupby.rst b/docs/flow/modin/experimental/range_partitioning_groupby.rst index ef82967ce15..b575badcc4f 100644 --- a/docs/flow/modin/experimental/range_partitioning_groupby.rst +++ b/docs/flow/modin/experimental/range_partitioning_groupby.rst @@ -72,3 +72,9 @@ implementation with the respective warning if it meets an unsupported case: ... # Range-partitioning groupby is only supported when grouping on a column(s) of the same frame. ... # https://github.com/modin-project/modin/issues/5926 ... # Falling back to a TreeReduce implementation. + +Range-partitioning Merge +"""""""""""""""""""""""" + +It is recommended to use this implementation if the right dataframe in merge is as big as +the left dataframe. In this case, range-partitioning implementation works faster and consumes less RAM. diff --git a/modin/config/__init__.py b/modin/config/__init__.py index c0107f814d6..0450782b4f6 100644 --- a/modin/config/__init__.py +++ b/modin/config/__init__.py @@ -44,6 +44,7 @@ NPartitions, PersistentPickle, ProgressBar, + RangePartitioning, RangePartitioningGroupby, RayRedisAddress, RayRedisPassword, @@ -92,6 +93,7 @@ "ModinNumpy", "ExperimentalNumPyAPI", "RangePartitioningGroupby", + "RangePartitioning", "ExperimentalGroupbyImpl", "AsyncReadMode", "ReadSqlEngine", diff --git a/modin/config/envvars.py b/modin/config/envvars.py index 56d821baf3b..ca581c41315 100644 --- a/modin/config/envvars.py +++ b/modin/config/envvars.py @@ -770,6 +770,18 @@ def _sibling(cls) -> type[EnvWithSibilings]: ) +class RangePartitioning(EnvironmentVariable, type=bool): + """ + Set to true to use Modin's range-partitioning implementation where possible. + + Please refer to documentation for cases where enabling this options would be beneficial: + https://modin.readthedocs.io/en/stable/flow/modin/experimental/range_partitioning_groupby.html + """ + + varname = "MODIN_RANGE_PARTITIONING" + default = False + + class CIAWSSecretAccessKey(EnvironmentVariable, type=str): """Set to AWS_SECRET_ACCESS_KEY when running mock S3 tests for Modin in GitHub CI.""" diff --git a/modin/core/dataframe/pandas/dataframe/dataframe.py b/modin/core/dataframe/pandas/dataframe/dataframe.py index 4235370497f..bf2963e5806 100644 --- a/modin/core/dataframe/pandas/dataframe/dataframe.py +++ b/modin/core/dataframe/pandas/dataframe/dataframe.py @@ -3881,6 +3881,74 @@ def _compute_new_widths(): new_partitions, new_index, new_columns, new_lengths, new_widths, new_dtypes ) + def _apply_func_to_range_partitioning_broadcast( + self, right, func, key, new_index=None, new_columns=None, new_dtypes=None + ): + """ + Apply `func` against two dataframes using range-partitioning implementation. + + The method first builds range-partitioning for both dataframes using the data from + `self[key]`, after that, it applies `func` row-wise to `self` frame and + broadcasts row-parts of `right` to `self`. + + Parameters + ---------- + right : PandasDataframe + func : callable(left : pandas.DataFrame, right : pandas.DataFrame) -> pandas.DataFrame + key : list of labels + Columns to use to build range-partitioning. Must present in both dataframes. + new_index : pandas.Index, optional + Index values to write to the result's cache. + new_columns : pandas.Index, optional + Column values to write to the result's cache. + new_dtypes : pandas.Series or ModinDtypes, optional + Dtype values to write to the result's cache. + + Returns + ------- + PandasDataframe + """ + if self._partitions.shape[0] == 1: + result = self.broadcast_apply_full_axis( + axis=1, + func=func, + new_columns=new_columns, + dtypes=new_dtypes, + other=right, + ) + return result + + if not isinstance(key, list): + key = [key] + + shuffling_functions = ShuffleSortFunctions( + self, + key, + ascending=True, + ideal_num_new_partitions=self._partitions.shape[0], + ) + + # here we want to get indices of those partitions that hold the key columns + key_indices = self.columns.get_indexer_for(key) + partition_indices = np.unique( + np.digitize(key_indices, np.cumsum(self.column_widths)) + ) + + new_partitions = self._partition_mgr_cls.shuffle_partitions( + self._partitions, + partition_indices, + shuffling_functions, + func, + right_partitions=right._partitions, + ) + + return self.__constructor__( + new_partitions, + index=new_index, + columns=new_columns, + dtypes=new_dtypes, + ) + @lazy_metadata_decorator(apply_axis="both") def groupby( self, diff --git a/modin/core/dataframe/pandas/partitioning/partition_manager.py b/modin/core/dataframe/pandas/partitioning/partition_manager.py index f9a02f7dcad..0f03dabcb4a 100644 --- a/modin/core/dataframe/pandas/partitioning/partition_manager.py +++ b/modin/core/dataframe/pandas/partitioning/partition_manager.py @@ -1722,6 +1722,7 @@ def shuffle_partitions( index, shuffle_functions: "ShuffleFunctions", final_shuffle_func, + right_partitions=None, ): """ Return shuffled partitions. @@ -1736,6 +1737,9 @@ def shuffle_partitions( An object implementing the functions that we will be using to perform this shuffle. final_shuffle_func : Callable(pandas.DataFrame) -> pandas.DataFrame Function that shuffles the data within each new partition. + right_partitions : np.ndarray, optional + Partitions to broadcast to `self` partitions. If specified, the method builds range-partitioning + for `right_partitions` basing on bins calculated for `partitions`, then performs broadcasting. Returns ------- @@ -1774,18 +1778,57 @@ def shuffle_partitions( for partition in row_partitions ] ).T - # We need to convert every partition that came from the splits into a full-axis column partition. - new_partitions = [ + + if right_partitions is None: + # We need to convert every partition that came from the splits into a column partition. + return np.array( + [ + [ + cls._column_partitions_class( + row_partition, full_axis=False + ).apply(final_shuffle_func) + ] + for row_partition in split_row_partitions + ] + ) + + right_row_parts = cls.row_partitions(right_partitions) + right_split_row_partitions = np.array( + [ + partition.split( + shuffle_functions.split_fn, + num_splits=num_bins, + extract_metadata=False, + ) + for partition in right_row_parts + ] + ).T + return np.array( [ cls._column_partitions_class(row_partition, full_axis=False).apply( - final_shuffle_func + final_shuffle_func, + other_axis_partition=cls._column_partitions_class( + right_row_partitions + ), + ) + for right_row_partitions, row_partition in zip( + right_split_row_partitions, split_row_partitions ) ] - for row_partition in split_row_partitions - ] - return np.array(new_partitions) + ) + else: # If there are not pivots we can simply apply the function row-wise + if right_partitions is None: + return np.array( + [row_part.apply(final_shuffle_func) for row_part in row_partitions] + ) + right_row_parts = cls.row_partitions(right_partitions) return np.array( - [row_part.apply(final_shuffle_func) for row_part in row_partitions] + [ + row_part.apply( + final_shuffle_func, other_axis_partition=right_row_part + ) + for right_row_part, row_part in zip(right_row_parts, row_partitions) + ] ) diff --git a/modin/core/storage_formats/pandas/merge.py b/modin/core/storage_formats/pandas/merge.py new file mode 100644 index 00000000000..9a3705ce3c1 --- /dev/null +++ b/modin/core/storage_formats/pandas/merge.py @@ -0,0 +1,302 @@ +# Licensed to Modin Development Team under one or more contributor license agreements. +# See the NOTICE file distributed with this work for additional information regarding +# copyright ownership. The Modin Development Team licenses this file to you under the +# Apache License, Version 2.0 (the "License"); you may not use this file except in +# compliance with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software distributed under +# the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF +# ANY KIND, either express or implied. See the License for the specific language +# governing permissions and limitations under the License. + +"""Contains implementations for Merge/Join.""" + +import pandas +from pandas.core.dtypes.common import is_list_like +from pandas.errors import MergeError + +from modin.core.dataframe.base.dataframe.utils import join_columns +from modin.core.dataframe.pandas.metadata import ModinDtypes + +from .utils import merge_partitioning + + +# TODO: add methods for 'join' here +class MergeImpl: + """Provide implementations for merge/join.""" + + @classmethod + def range_partitioning_merge(cls, left, right, kwargs): + """ + Execute merge using range-partitioning implementation. + + Parameters + ---------- + left : PandasQueryCompiler + right : PandasQueryCompiler + kwargs : dict + Keyword arguments for ``pandas.merge()`` function. + + Returns + ------- + PandasQueryCompiler + """ + if ( + kwargs.get("left_index", False) + or kwargs.get("right_index", False) + or kwargs.get("left_on", None) is not None + or kwargs.get("left_on", None) is not None + or kwargs.get("how", "left") not in ("left", "inner") + ): + raise NotImplementedError( + f"The passed parameters are not yet supported by range-partitioning merge: {kwargs=}" + ) + + on = kwargs.get("on", None) + if on is not None and not isinstance(on, list): + on = [on] + if on is None or len(on) > 1: + raise NotImplementedError( + f"Merging on multiple columns is not yet supported by range-partitioning merge: {on=}" + ) + + if any(col not in left.columns or col not in right.columns for col in on): + raise NotImplementedError( + "Merging on an index level is not yet supported by range-partitioning merge." + ) + + def func(left, right): + return left.merge(right, **kwargs) + + new_columns, new_dtypes = cls._compute_result_metadata( + left, + right, + on, + left_on=None, + right_on=None, + suffixes=kwargs.get("suffixes", ("_x", "_y")), + ) + + return left.__constructor__( + left._modin_frame._apply_func_to_range_partitioning_broadcast( + right._modin_frame, + func=func, + key=on, + new_columns=new_columns, + new_dtypes=new_dtypes, + ) + # pandas resets the index of the result unless we were merging on an index level, + # the current implementation only supports merging on column names, so dropping + # the index unconditionally + ).reset_index(drop=True) + + @classmethod + def row_axis_merge(cls, left, right, kwargs): + """ + Execute merge using row-axis implementation. + + Parameters + ---------- + left : PandasQueryCompiler + right : PandasQueryCompiler + kwargs : dict + Keyword arguments for ``pandas.merge()`` function. + + Returns + ------- + PandasQueryCompiler + """ + how = kwargs.get("how", "inner") + on = kwargs.get("on", None) + left_on = kwargs.get("left_on", None) + right_on = kwargs.get("right_on", None) + left_index = kwargs.get("left_index", False) + right_index = kwargs.get("right_index", False) + sort = kwargs.get("sort", False) + right_to_broadcast = right._modin_frame.combine() + + if how in ["left", "inner"] and left_index is False and right_index is False: + kwargs["sort"] = False + + def should_keep_index(left, right): + keep_index = False + if left_on is not None and right_on is not None: + keep_index = any( + o in left.index.names + and o in right_on + and o in right.index.names + for o in left_on + ) + elif on is not None: + keep_index = any( + o in left.index.names and o in right.index.names for o in on + ) + return keep_index + + def map_func( + left, right, *axis_lengths, kwargs=kwargs, **service_kwargs + ): # pragma: no cover + df = pandas.merge(left, right, **kwargs) + + if kwargs["how"] == "left": + partition_idx = service_kwargs["partition_idx"] + if len(axis_lengths): + if not should_keep_index(left, right): + # Doesn't work for "inner" case, since the partition sizes of the + # left dataframe may change + start = sum(axis_lengths[:partition_idx]) + stop = sum(axis_lengths[: partition_idx + 1]) + + df.index = pandas.RangeIndex(start, stop) + + return df + + # Want to ensure that these are python lists + if left_on is not None and right_on is not None: + left_on = list(left_on) if is_list_like(left_on) else [left_on] + right_on = list(right_on) if is_list_like(right_on) else [right_on] + elif on is not None: + on = list(on) if is_list_like(on) else [on] + + new_columns, new_dtypes = cls._compute_result_metadata( + left, right, on, left_on, right_on, kwargs.get("suffixes", ("_x", "_y")) + ) + + new_left = left.__constructor__( + left._modin_frame.broadcast_apply_full_axis( + axis=1, + func=map_func, + enumerate_partitions=how == "left", + other=right_to_broadcast, + # We're going to explicitly change the shape across the 1-axis, + # so we want for partitioning to adapt as well + keep_partitioning=False, + num_splits=merge_partitioning( + left._modin_frame, right._modin_frame, axis=1 + ), + new_columns=new_columns, + sync_labels=False, + dtypes=new_dtypes, + pass_axis_lengths_to_partitions=how == "left", + ) + ) + + # Here we want to understand whether we're joining on a column or on an index level. + # It's cool if indexes are already materialized so we can easily check that, if not + # it's fine too, we can also decide that by columns, which tend to be already + # materialized quite often compared to the indexes. + keep_index = False + if left._modin_frame.has_materialized_index: + keep_index = should_keep_index(left, right) + else: + # Have to trigger columns materialization. Hope they're already available at this point. + if left_on is not None and right_on is not None: + keep_index = any( + o not in right.columns + and o in left_on + and o not in left.columns + for o in right_on + ) + elif on is not None: + keep_index = any( + o not in right.columns and o not in left.columns for o in on + ) + + if sort: + if left_on is not None and right_on is not None: + new_left = ( + new_left.sort_index(axis=0, level=left_on + right_on) + if keep_index + else new_left.sort_rows_by_column_values(left_on + right_on) + ) + elif on is not None: + new_left = ( + new_left.sort_index(axis=0, level=on) + if keep_index + else new_left.sort_rows_by_column_values(on) + ) + + return ( + new_left.reset_index(drop=True) + if not keep_index and (kwargs["how"] != "left" or sort) + else new_left + ) + else: + return left.default_to_pandas(pandas.DataFrame.merge, right, **kwargs) + + @classmethod + def _compute_result_metadata(cls, left, right, on, left_on, right_on, suffixes): + """ + Compute columns and dtypes metadata for the result of merge if possible. + + Parameters + ---------- + left : PandasQueryCompiler + right : PandasQueryCompiler + on : label, list of labels or None + `on` argument that was passed to ``pandas.merge()``. + left_on : label, list of labels or None + `left_on` argument that was passed to ``pandas.merge()``. + right_on : label, list of labels or None + `right_on` argument that was passed to ``pandas.merge()``. + suffixes : list of strings + `suffixes` argument that was passed to ``pandas.merge()``. + + Returns + ------- + new_columns : pandas.Index or None + Columns for the result of merge. ``None`` if not enought metadata to compute. + new_dtypes : ModinDtypes or None + Dtypes for the result of merge. ``None`` if not enought metadata to compute. + """ + new_columns = None + new_dtypes = None + + if not left._modin_frame.has_materialized_columns: + return new_columns, new_dtypes + + if left_on is None and right_on is None: + if on is None: + on = [c for c in left.columns if c in right.columns] + _left_on, _right_on = on, on + else: + if left_on is None or right_on is None: + raise MergeError( + "Must either pass only 'on' or 'left_on' and 'right_on', not combination of them." + ) + _left_on, _right_on = left_on, right_on + + try: + new_columns, left_renamer, right_renamer = join_columns( + left.columns, + right.columns, + _left_on, + _right_on, + suffixes, + ) + except NotImplementedError: + # This happens when one of the keys to join is an index level. Pandas behaviour + # is really complicated in this case, so we're not computing resulted columns for now. + pass + else: + # renamers may contain columns from 'index', so trying to merge index and column dtypes here + right_index_dtypes = ( + right.index.dtypes + if isinstance(right.index, pandas.MultiIndex) + else pandas.Series([right.index.dtype], index=[right.index.name]) + ) + right_dtypes = pandas.concat([right.dtypes, right_index_dtypes])[ + right_renamer.keys() + ].rename(right_renamer) + + left_index_dtypes = left._modin_frame._index_cache.maybe_get_dtypes() + left_dtypes = ( + ModinDtypes.concat([left._modin_frame._dtypes, left_index_dtypes]) + .lazy_get(left_renamer.keys()) + .set_index(list(left_renamer.values())) + ) + new_dtypes = ModinDtypes.concat([left_dtypes, right_dtypes]) + + return new_columns, new_dtypes diff --git a/modin/core/storage_formats/pandas/query_compiler.py b/modin/core/storage_formats/pandas/query_compiler.py index dffb1285eef..7beeca258dc 100644 --- a/modin/core/storage_formats/pandas/query_compiler.py +++ b/modin/core/storage_formats/pandas/query_compiler.py @@ -41,9 +41,9 @@ from pandas.core.groupby.base import transformation_kernels from pandas.core.indexes.api import ensure_index_from_sequences from pandas.core.indexing import check_bool_indexer -from pandas.errors import DataError, MergeError +from pandas.errors import DataError -from modin.config import CpuCount, RangePartitioningGroupby +from modin.config import CpuCount, RangePartitioning, RangePartitioningGroupby from modin.core.dataframe.algebra import ( Binary, Fold, @@ -57,7 +57,6 @@ GroupByDefault, SeriesGroupByDefault, ) -from modin.core.dataframe.base.dataframe.utils import join_columns from modin.core.dataframe.pandas.metadata import ( DtypesDescriptor, ModinDtypes, @@ -77,6 +76,7 @@ from .aggregations import CorrCovBuilder from .groupby import GroupbyReduceImpl +from .merge import MergeImpl from .utils import get_group_names, merge_partitioning @@ -513,170 +513,16 @@ def where_builder_series(df, cond): return self.__constructor__(new_modin_frame) def merge(self, right, **kwargs): - how = kwargs.get("how", "inner") - on = kwargs.get("on", None) - left_on = kwargs.get("left_on", None) - right_on = kwargs.get("right_on", None) - left_index = kwargs.get("left_index", False) - right_index = kwargs.get("right_index", False) - sort = kwargs.get("sort", False) - right_to_broadcast = right._modin_frame.combine() - - if how in ["left", "inner"] and left_index is False and right_index is False: - kwargs["sort"] = False - - def should_keep_index(left, right): - keep_index = False - if left_on is not None and right_on is not None: - keep_index = any( - o in left.index.names - and o in right_on - and o in right.index.names - for o in left_on - ) - elif on is not None: - keep_index = any( - o in left.index.names and o in right.index.names for o in on - ) - return keep_index - - def map_func( - left, right, *axis_lengths, kwargs=kwargs, **service_kwargs - ): # pragma: no cover - df = pandas.merge(left, right, **kwargs) - - if kwargs["how"] == "left": - partition_idx = service_kwargs["partition_idx"] - if len(axis_lengths): - if not should_keep_index(left, right): - # Doesn't work for "inner" case, since the partition sizes of the - # left dataframe may change - start = sum(axis_lengths[:partition_idx]) - stop = sum(axis_lengths[: partition_idx + 1]) - - df.index = pandas.RangeIndex(start, stop) - - return df - - # Want to ensure that these are python lists - if left_on is not None and right_on is not None: - left_on = list(left_on) if is_list_like(left_on) else [left_on] - right_on = list(right_on) if is_list_like(right_on) else [right_on] - elif on is not None: - on = list(on) if is_list_like(on) else [on] - - new_columns = None - new_dtypes = None - if self._modin_frame.has_materialized_columns: - if left_on is None and right_on is None: - if on is None: - on = [c for c in self.columns if c in right.columns] - _left_on, _right_on = on, on - else: - if left_on is None or right_on is None: - raise MergeError( - "Must either pass only 'on' or 'left_on' and 'right_on', not combination of them." - ) - _left_on, _right_on = left_on, right_on - - try: - new_columns, left_renamer, right_renamer = join_columns( - self.columns, - right.columns, - _left_on, - _right_on, - kwargs.get("suffixes", ("_x", "_y")), - ) - except NotImplementedError: - # This happens when one of the keys to join is an index level. Pandas behaviour - # is really complicated in this case, so we're not computing resulted columns for now. - pass - else: - # renamers may contain columns from 'index', so trying to merge index and column dtypes here - right_index_dtypes = ( - right.index.dtypes - if isinstance(right.index, pandas.MultiIndex) - else pandas.Series( - [right.index.dtype], index=[right.index.name] - ) - ) - right_dtypes = pandas.concat([right.dtypes, right_index_dtypes])[ - right_renamer.keys() - ].rename(right_renamer) - - left_index_dtypes = ( - self._modin_frame._index_cache.maybe_get_dtypes() - ) - left_dtypes = ( - ModinDtypes.concat( - [self._modin_frame._dtypes, left_index_dtypes] - ) - .lazy_get(left_renamer.keys()) - .set_index(list(left_renamer.values())) - ) - new_dtypes = ModinDtypes.concat([left_dtypes, right_dtypes]) - - new_self = self.__constructor__( - self._modin_frame.broadcast_apply_full_axis( - axis=1, - func=map_func, - enumerate_partitions=how == "left", - other=right_to_broadcast, - # We're going to explicitly change the shape across the 1-axis, - # so we want for partitioning to adapt as well - keep_partitioning=False, - num_splits=merge_partitioning( - self._modin_frame, right._modin_frame, axis=1 - ), - new_columns=new_columns, - sync_labels=False, - dtypes=new_dtypes, - pass_axis_lengths_to_partitions=how == "left", + if RangePartitioning.get(): + try: + return MergeImpl.range_partitioning_merge(self, right, kwargs) + except NotImplementedError as e: + message = ( + f"Can't use range-partitioning merge implementation because of: {e}" + + "\nFalling back to a row-axis implementation." ) - ) - - # Here we want to understand whether we're joining on a column or on an index level. - # It's cool if indexes are already materialized so we can easily check that, if not - # it's fine too, we can also decide that by columns, which tend to be already - # materialized quite often compared to the indexes. - keep_index = False - if self._modin_frame.has_materialized_index: - keep_index = should_keep_index(self, right) - else: - # Have to trigger columns materialization. Hope they're already available at this point. - if left_on is not None and right_on is not None: - keep_index = any( - o not in right.columns - and o in left_on - and o not in self.columns - for o in right_on - ) - elif on is not None: - keep_index = any( - o not in right.columns and o not in self.columns for o in on - ) - - if sort: - if left_on is not None and right_on is not None: - new_self = ( - new_self.sort_index(axis=0, level=left_on + right_on) - if keep_index - else new_self.sort_rows_by_column_values(left_on + right_on) - ) - elif on is not None: - new_self = ( - new_self.sort_index(axis=0, level=on) - if keep_index - else new_self.sort_rows_by_column_values(on) - ) - - return ( - new_self.reset_index(drop=True) - if not keep_index and (kwargs["how"] != "left" or sort) - else new_self - ) - else: - return self.default_to_pandas(pandas.DataFrame.merge, right, **kwargs) + get_logger().info(message) + return MergeImpl.row_axis_merge(self, right, kwargs) def join(self, right, **kwargs): on = kwargs.get("on", None) diff --git a/modin/pandas/test/dataframe/test_join_sort.py b/modin/pandas/test/dataframe/test_join_sort.py index 653e39bec0a..d2b44741544 100644 --- a/modin/pandas/test/dataframe/test_join_sort.py +++ b/modin/pandas/test/dataframe/test_join_sort.py @@ -19,7 +19,7 @@ import pytest import modin.pandas as pd -from modin.config import Engine, NPartitions, StorageFormat +from modin.config import Engine, NPartitions, RangePartitioning, StorageFormat from modin.pandas.io import to_pandas from modin.pandas.test.utils import ( arg_keys, @@ -54,6 +54,13 @@ pd.DataFrame() +def df_equals_and_sort(df1, df2): + """Sort dataframe's rows and run ``df_equals()`` for them.""" + df1 = df1.sort_values(by=df1.columns.tolist(), ignore_index=True) + df2 = df2.sort_values(by=df2.columns.tolist(), ignore_index=True) + df_equals(df1, df2) + + @pytest.mark.parametrize("data", test_data_values, ids=test_data_keys) def test_combine(data): pandas_df = pandas.DataFrame(data) @@ -214,6 +221,10 @@ def test_join_6602(): teams.set_index("league_abbreviation").join(abbreviations.rename("league_name")) +@pytest.mark.skipif( + RangePartitioning.get() and StorageFormat.get() == "Hdk", + reason="Doesn't make sense for HDK", +) @pytest.mark.parametrize( "test_data, test_data2", [ @@ -236,6 +247,10 @@ def test_join_6602(): ], ) def test_merge(test_data, test_data2): + # RangePartitioning merge always produces sorted result, so we have to sort + # pandas' result as well in order them to match + comparator = df_equals_and_sort if RangePartitioning.get() else df_equals + modin_df = pd.DataFrame( test_data, columns=["col{}".format(i) for i in range(test_data.shape[1])], @@ -268,7 +283,7 @@ def test_merge(test_data, test_data2): pandas_result = pandas_df.merge( pandas_df2, how=hows[i], on=ons[j], sort=sorts[j] ) - df_equals(modin_result, pandas_result) + comparator(modin_result, pandas_result) modin_result = modin_df.merge( modin_df2, @@ -284,7 +299,7 @@ def test_merge(test_data, test_data2): right_on="key", sort=sorts[j], ) - df_equals(modin_result, pandas_result) + comparator(modin_result, pandas_result) # Test for issue #1771 modin_df = pd.DataFrame({"name": np.arange(40)}) @@ -293,7 +308,7 @@ def test_merge(test_data, test_data2): pandas_df2 = pandas.DataFrame({"name": [39], "position": [0]}) modin_result = modin_df.merge(modin_df2, on="name", how="inner") pandas_result = pandas_df.merge(pandas_df2, on="name", how="inner") - df_equals(modin_result, pandas_result) + comparator(modin_result, pandas_result) frame_data = { "col1": [0, 1, 2, 3], @@ -314,7 +329,7 @@ def test_merge(test_data, test_data2): # Defaults modin_result = modin_df.merge(modin_df2, how=how) pandas_result = pandas_df.merge(pandas_df2, how=how) - df_equals(modin_result, pandas_result) + comparator(modin_result, pandas_result) # left_on and right_index modin_result = modin_df.merge( @@ -323,7 +338,7 @@ def test_merge(test_data, test_data2): pandas_result = pandas_df.merge( pandas_df2, how=how, left_on="col1", right_index=True ) - df_equals(modin_result, pandas_result) + comparator(modin_result, pandas_result) # left_index and right_on modin_result = modin_df.merge( @@ -332,7 +347,7 @@ def test_merge(test_data, test_data2): pandas_result = pandas_df.merge( pandas_df2, how=how, left_index=True, right_on="col1" ) - df_equals(modin_result, pandas_result) + comparator(modin_result, pandas_result) # left_on and right_on col1 modin_result = modin_df.merge( @@ -341,7 +356,7 @@ def test_merge(test_data, test_data2): pandas_result = pandas_df.merge( pandas_df2, how=how, left_on="col1", right_on="col1" ) - df_equals(modin_result, pandas_result) + comparator(modin_result, pandas_result) # left_on and right_on col2 modin_result = modin_df.merge( @@ -350,7 +365,7 @@ def test_merge(test_data, test_data2): pandas_result = pandas_df.merge( pandas_df2, how=how, left_on="col2", right_on="col2" ) - df_equals(modin_result, pandas_result) + comparator(modin_result, pandas_result) # left_index and right_index modin_result = modin_df.merge( @@ -359,7 +374,7 @@ def test_merge(test_data, test_data2): pandas_result = pandas_df.merge( pandas_df2, how=how, left_index=True, right_index=True ) - df_equals(modin_result, pandas_result) + comparator(modin_result, pandas_result) # Cannot merge a Series without a name ps = pandas.Series(frame_data2.get("col1")) @@ -368,6 +383,7 @@ def test_merge(test_data, test_data2): modin_df, pandas_df, lambda df: df.merge(ms if isinstance(df, pd.DataFrame) else ps), + comparator=comparator, ) # merge a Series with a name @@ -377,6 +393,7 @@ def test_merge(test_data, test_data2): modin_df, pandas_df, lambda df: df.merge(ms if isinstance(df, pd.DataFrame) else ps), + comparator=comparator, ) with pytest.raises(TypeError):