From 30aaae554b5a0272b4d2900e7bd2f9530aa546b6 Mon Sep 17 00:00:00 2001 From: Shobhit Singh Date: Wed, 31 Jul 2024 16:48:07 -0700 Subject: [PATCH 01/10] chore: reduce the `remote_function` cleanup rate (#873) * chore: reduce the `remote_function` cleanup rate * minor comment change --- tests/system/conftest.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/system/conftest.py b/tests/system/conftest.py index 6bd7bf9348..83c8baac39 100644 --- a/tests/system/conftest.py +++ b/tests/system/conftest.py @@ -49,9 +49,11 @@ # We are running pytest with "-n 20". For a rough estimation, let's say all # parallel sessions run in parallel. So that allows 1000/20 = 50 mutations per # minute. One session takes about 1 minute to create a remote function. This -# would allow 50-1 = 49 deletions per session. As a heuristic let's use half of -# that potential for the clean up. -MAX_NUM_FUNCTIONS_TO_DELETE_PER_SESSION = 25 +# would allow 50-1 = 49 deletions per session. +# However, because of b/356217175 the service may throw ResourceExhausted("Too +# many operations are currently being executed, try again later."), so we peg +# the cleanup to a more controlled rate. +MAX_NUM_FUNCTIONS_TO_DELETE_PER_SESSION = 15 CURRENT_DIR = pathlib.Path(__file__).parent DATA_DIR = CURRENT_DIR.parent / "data" From e9b05571123cf13079772856317ca3cd3d564c5a Mon Sep 17 00:00:00 2001 From: Garrett Wu <6505921+GarrettWu@users.noreply.github.com> Date: Thu, 1 Aug 2024 13:35:36 -0700 Subject: [PATCH 02/10] docs: update streaming notebook (#875) --- notebooks/streaming/streaming_dataframe.ipynb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/notebooks/streaming/streaming_dataframe.ipynb b/notebooks/streaming/streaming_dataframe.ipynb index a2da30720d..d4cc255fa5 100644 --- a/notebooks/streaming/streaming_dataframe.ipynb +++ b/notebooks/streaming/streaming_dataframe.ipynb @@ -5,12 +5,12 @@ "metadata": {}, "source": [ "### BigFrames StreamingDataFrame\n", - "bigframes.streaming.StreamingDataFrame is a special DataFrame type that allows simple operations and can create steaming jobs to BigTable and PubSub.\n", + "bigframes.streaming.StreamingDataFrame is a special DataFrame type that allows simple operations and can create streaming jobs to process real-time data and reverse ETL output to Bigtable and Pub/Sub using [BigQuery continuous queries](https://cloud.google.com/bigquery/docs/continuous-queries-introduction).\n", "\n", "In this notebook, we will:\n", "* Create a StreamingDataFrame from a BigQuery table\n", - "* Do some opeartions like select, filter and preview the content\n", - "* Create and manage streaming jobs to both BigTable and Pubsub" + "* Do some operations like select, filter and preview the content\n", + "* Create and manage streaming jobs to both Bigtable and Pub/Sub" ] }, { From 042db4b3d4e4142dabca305e706c78d7766697ef Mon Sep 17 00:00:00 2001 From: "gcf-owl-bot[bot]" <78513119+gcf-owl-bot[bot]@users.noreply.github.com> Date: Thu, 1 Aug 2024 15:12:15 -0700 Subject: [PATCH 03/10] chore(python): fix docs build (#871) Source-Link: https://github.com/googleapis/synthtool/commit/bef813d194de29ddf3576eda60148b6b3dcc93d9 Post-Processor: gcr.io/cloud-devrel-public-resources/owlbot-python:latest@sha256:94bb690db96e6242b2567a4860a94d48fa48696d092e51b0884a1a2c0a79a407 Co-authored-by: Owl Bot --- .github/.OwlBot.lock.yaml | 4 ++-- .kokoro/docker/docs/Dockerfile | 9 ++++----- .kokoro/publish-docs.sh | 23 ++++++++++------------- 3 files changed, 16 insertions(+), 20 deletions(-) diff --git a/.github/.OwlBot.lock.yaml b/.github/.OwlBot.lock.yaml index f30cb3775a..6d064ddb9b 100644 --- a/.github/.OwlBot.lock.yaml +++ b/.github/.OwlBot.lock.yaml @@ -13,5 +13,5 @@ # limitations under the License. docker: image: gcr.io/cloud-devrel-public-resources/owlbot-python:latest - digest: sha256:52210e0e0559f5ea8c52be148b33504022e1faef4e95fbe4b32d68022af2fa7e -# created: 2024-07-08T19:25:35.862283192Z + digest: sha256:94bb690db96e6242b2567a4860a94d48fa48696d092e51b0884a1a2c0a79a407 +# created: 2024-07-31T14:52:44.926548819Z diff --git a/.kokoro/docker/docs/Dockerfile b/.kokoro/docker/docs/Dockerfile index 5205308b33..e5410e296b 100644 --- a/.kokoro/docker/docs/Dockerfile +++ b/.kokoro/docker/docs/Dockerfile @@ -72,19 +72,18 @@ RUN tar -xvf Python-3.10.14.tgz RUN ./Python-3.10.14/configure --enable-optimizations RUN make altinstall -RUN python3.10 -m venv /venv -ENV PATH /venv/bin:$PATH +ENV PATH /usr/local/bin/python3.10:$PATH ###################### Install pip RUN wget -O /tmp/get-pip.py 'https://bootstrap.pypa.io/get-pip.py' \ - && python3 /tmp/get-pip.py \ + && python3.10 /tmp/get-pip.py \ && rm /tmp/get-pip.py # Test pip -RUN python3 -m pip +RUN python3.10 -m pip # Install build requirements COPY requirements.txt /requirements.txt -RUN python3 -m pip install --require-hashes -r requirements.txt +RUN python3.10 -m pip install --require-hashes -r requirements.txt CMD ["python3.10"] diff --git a/.kokoro/publish-docs.sh b/.kokoro/publish-docs.sh index da9ce803dd..233205d580 100755 --- a/.kokoro/publish-docs.sh +++ b/.kokoro/publish-docs.sh @@ -21,18 +21,18 @@ export PYTHONUNBUFFERED=1 export PATH="${HOME}/.local/bin:${PATH}" # Install nox -python3 -m pip install --require-hashes -r .kokoro/requirements.txt -python3 -m nox --version +python3.10 -m pip install --require-hashes -r .kokoro/requirements.txt +python3.10 -m nox --version # build docs nox -s docs # create metadata -python3 -m docuploader create-metadata \ +python3.10 -m docuploader create-metadata \ --name=$(jq --raw-output '.name // empty' .repo-metadata.json) \ - --version=$(python3 setup.py --version) \ + --version=$(python3.10 setup.py --version) \ --language=$(jq --raw-output '.language // empty' .repo-metadata.json) \ - --distribution-name=$(python3 setup.py --name) \ + --distribution-name=$(python3.10 setup.py --name) \ --product-page=$(jq --raw-output '.product_documentation // empty' .repo-metadata.json) \ --github-repository=$(jq --raw-output '.repo // empty' .repo-metadata.json) \ --issue-tracker=$(jq --raw-output '.issue_tracker // empty' .repo-metadata.json) @@ -40,26 +40,23 @@ python3 -m docuploader create-metadata \ cat docs.metadata # upload docs -python3 -m docuploader upload docs/_build/html --metadata-file docs.metadata --staging-bucket "${STAGING_BUCKET}" +python3.10 -m docuploader upload docs/_build/html --metadata-file docs.metadata --staging-bucket "${STAGING_BUCKET}" # docfx yaml files nox -s docfx # create metadata. -python3 -m docuploader create-metadata \ +python3.10 -m docuploader create-metadata \ --name=$(jq --raw-output '.name // empty' .repo-metadata.json) \ - --version=$(python3 setup.py --version) \ + --version=$(python3.10 setup.py --version) \ --language=$(jq --raw-output '.language // empty' .repo-metadata.json) \ - --distribution-name=$(python3 setup.py --name) \ + --distribution-name=$(python3.10 setup.py --name) \ --product-page=$(jq --raw-output '.product_documentation // empty' .repo-metadata.json) \ --github-repository=$(jq --raw-output '.repo // empty' .repo-metadata.json) \ --issue-tracker=$(jq --raw-output '.issue_tracker // empty' .repo-metadata.json) cat docs.metadata -# Replace toc.yml template file -mv docs/templates/toc.yml docs/_build/html/docfx_yaml/toc.yml - # upload docs -python3 -m docuploader upload docs/_build/html/docfx_yaml --metadata-file docs.metadata --destination-prefix docfx --staging-bucket "${V2_STAGING_BUCKET}" +python3.10 -m docuploader upload docs/_build/html/docfx_yaml --metadata-file docs.metadata --destination-prefix docfx --staging-bucket "${V2_STAGING_BUCKET}" From 9959fc8fcba93441fdd3d9c17e8fdbe6e6a7b504 Mon Sep 17 00:00:00 2001 From: TrevorBergeron Date: Thu, 1 Aug 2024 16:55:24 -0700 Subject: [PATCH 04/10] fix: Fix issue with invalid sql generated by ml distance functions (#865) --- bigframes/core/compile/scalar_op_compiler.py | 29 ++++ bigframes/ml/core.py | 131 ++++++++---------- bigframes/ml/metrics/pairwise.py | 47 ++++--- bigframes/ml/sql.py | 51 +++---- bigframes/operations/__init__.py | 11 ++ bigframes/operations/type.py | 19 +++ .../system/small/ml/test_metrics_pairwise.py | 41 ++++++ tests/unit/ml/test_sql.py | 41 ++---- 8 files changed, 216 insertions(+), 154 deletions(-) diff --git a/bigframes/core/compile/scalar_op_compiler.py b/bigframes/core/compile/scalar_op_compiler.py index 0bc9f2e370..06e9481d17 100644 --- a/bigframes/core/compile/scalar_op_compiler.py +++ b/bigframes/core/compile/scalar_op_compiler.py @@ -1380,6 +1380,30 @@ def minimum_impl( return ibis.case().when(upper.isnull() | (value > upper), upper).else_(value).end() +@scalar_op_compiler.register_binary_op(ops.cosine_distance_op) +def cosine_distance_impl( + vector1: ibis_types.Value, + vector2: ibis_types.Value, +): + return vector_distance(vector1, vector2, "COSINE") + + +@scalar_op_compiler.register_binary_op(ops.euclidean_distance_op) +def euclidean_distance_impl( + vector1: ibis_types.Value, + vector2: ibis_types.Value, +): + return vector_distance(vector1, vector2, "EUCLIDEAN") + + +@scalar_op_compiler.register_binary_op(ops.manhattan_distance_op) +def manhattan_distance_impl( + vector1: ibis_types.Value, + vector2: ibis_types.Value, +): + return vector_distance(vector1, vector2, "MANHATTAN") + + @scalar_op_compiler.register_binary_op(ops.BinaryRemoteFunctionOp, pass_op=True) def binary_remote_function_op_impl( x: ibis_types.Value, y: ibis_types.Value, op: ops.BinaryRemoteFunctionOp @@ -1501,3 +1525,8 @@ def json_set( json_obj: ibis_dtypes.JSON, json_path: ibis_dtypes.str, json_value ) -> ibis_dtypes.JSON: """Produces a new SQL JSON value with the specified JSON data inserted or replaced.""" + + +@ibis.udf.scalar.builtin(name="ML.DISTANCE") +def vector_distance(vector1, vector2, type: str) -> ibis_dtypes.Float64: + """Computes the distance between two vectors using specified type ("EUCLIDEAN", "MANHATTAN", or "COSINE")""" diff --git a/bigframes/ml/core.py b/bigframes/ml/core.py index f1b36651f4..d570945f16 100644 --- a/bigframes/ml/core.py +++ b/bigframes/ml/core.py @@ -17,7 +17,7 @@ from __future__ import annotations import datetime -from typing import Callable, cast, Iterable, Literal, Mapping, Optional, Union +from typing import Callable, cast, Iterable, Mapping, Optional, Union import uuid from google.cloud import bigquery @@ -35,11 +35,27 @@ def __init__(self, session: bigframes.Session): self._session = session self._base_sql_generator = ml_sql.BaseSqlGenerator() - def _apply_sql( + +class BqmlModel(BaseBqml): + """Represents an existing BQML model in BigQuery. + + Wraps the BQML API and SQL interface to expose the functionality needed for + BigQuery DataFrames ML. + """ + + def __init__(self, session: bigframes.Session, model: bigquery.Model): + self._session = session + self._model = model + self._model_manipulation_sql_generator = ml_sql.ModelManipulationSqlGenerator( + self.model_name + ) + + def _apply_ml_tvf( self, input_data: bpd.DataFrame, - func: Callable[[bpd.DataFrame], str], + apply_sql_tvf: Callable[[str], str], ) -> bpd.DataFrame: + # Used for predict, transform, distance """Helper to wrap a dataframe in a SQL query, keeping the index intact. Args: @@ -50,67 +66,28 @@ def _apply_sql( the dataframe to be wrapped func (function): - a function that will accept a SQL string and produce a new SQL - string from which to construct the output dataframe. It must - include the index columns of the input SQL. + Takes an input sql table value and applies a prediction tvf. The + resulting table value must include all input columns, with new + columns appended to the end. """ - _, index_col_ids, index_labels = input_data._to_sql_query(include_index=True) - - sql = func(input_data) - df = self._session.read_gbq(sql, index_col=index_col_ids) - df.index.names = index_labels - - return df - - def distance( - self, - x: bpd.DataFrame, - y: bpd.DataFrame, - type: Literal["EUCLIDEAN", "MANHATTAN", "COSINE"], - name: str, - ) -> bpd.DataFrame: - """Calculate ML.DISTANCE from DataFrame inputs. - - Args: - x: - input DataFrame - y: - input DataFrame - type: - Distance types, accept values are "EUCLIDEAN", "MANHATTAN", "COSINE". - name: - name of the output result column - """ - assert len(x.columns) == 1 and len(y.columns) == 1 - - input_data = x.join(y, how="outer").cache() - x_column_id, y_column_id = x._block.value_columns[0], y._block.value_columns[0] - - return self._apply_sql( - input_data, - lambda source_df: self._base_sql_generator.ml_distance( - x_column_id, - y_column_id, - type=type, - source_df=source_df, - name=name, - ), + # TODO: Preserve ordering information? + input_sql, index_col_ids, index_labels = input_data._to_sql_query( + include_index=True ) - -class BqmlModel(BaseBqml): - """Represents an existing BQML model in BigQuery. - - Wraps the BQML API and SQL interface to expose the functionality needed for - BigQuery DataFrames ML. - """ - - def __init__(self, session: bigframes.Session, model: bigquery.Model): - self._session = session - self._model = model - self._model_manipulation_sql_generator = ml_sql.ModelManipulationSqlGenerator( - self.model_name + result_sql = apply_sql_tvf(input_sql) + df = self._session.read_gbq(result_sql, index_col=index_col_ids) + df.index.names = index_labels + # Restore column labels + df.rename( + columns={ + label: original_label + for label, original_label in zip( + df.columns.values, input_data.columns.values + ) + } ) + return df def _keys(self): return (self._session, self._model) @@ -137,13 +114,13 @@ def model(self) -> bigquery.Model: return self._model def predict(self, input_data: bpd.DataFrame) -> bpd.DataFrame: - return self._apply_sql( + return self._apply_ml_tvf( input_data, self._model_manipulation_sql_generator.ml_predict, ) def transform(self, input_data: bpd.DataFrame) -> bpd.DataFrame: - return self._apply_sql( + return self._apply_ml_tvf( input_data, self._model_manipulation_sql_generator.ml_transform, ) @@ -153,10 +130,10 @@ def generate_text( input_data: bpd.DataFrame, options: Mapping[str, int | float], ) -> bpd.DataFrame: - return self._apply_sql( + return self._apply_ml_tvf( input_data, - lambda source_df: self._model_manipulation_sql_generator.ml_generate_text( - source_df=source_df, + lambda source_sql: self._model_manipulation_sql_generator.ml_generate_text( + source_sql=source_sql, struct_options=options, ), ) @@ -166,10 +143,10 @@ def generate_embedding( input_data: bpd.DataFrame, options: Mapping[str, int | float], ) -> bpd.DataFrame: - return self._apply_sql( + return self._apply_ml_tvf( input_data, - lambda source_df: self._model_manipulation_sql_generator.ml_generate_embedding( - source_df=source_df, + lambda source_sql: self._model_manipulation_sql_generator.ml_generate_embedding( + source_sql=source_sql, struct_options=options, ), ) @@ -179,10 +156,10 @@ def detect_anomalies( ) -> bpd.DataFrame: assert self._model.model_type in ("PCA", "KMEANS", "ARIMA_PLUS") - return self._apply_sql( + return self._apply_ml_tvf( input_data, - lambda source_df: self._model_manipulation_sql_generator.ml_detect_anomalies( - source_df=source_df, + lambda source_sql: self._model_manipulation_sql_generator.ml_detect_anomalies( + source_sql=source_sql, struct_options=options, ), ) @@ -192,7 +169,9 @@ def forecast(self, options: Mapping[str, int | float]) -> bpd.DataFrame: return self._session.read_gbq(sql, index_col="forecast_timestamp").reset_index() def evaluate(self, input_data: Optional[bpd.DataFrame] = None): - sql = self._model_manipulation_sql_generator.ml_evaluate(input_data) + sql = self._model_manipulation_sql_generator.ml_evaluate( + input_data.sql if (input_data is not None) else None + ) return self._session.read_gbq(sql) @@ -202,7 +181,7 @@ def llm_evaluate( task_type: Optional[str] = None, ): sql = self._model_manipulation_sql_generator.ml_llm_evaluate( - input_data, task_type + input_data.sql, task_type ) return self._session.read_gbq(sql) @@ -336,7 +315,7 @@ def create_model( model_ref = self._create_model_ref(session._anonymous_dataset) sql = self._model_creation_sql_generator.create_model( - source_df=input_data, + source_sql=input_data.sql, model_ref=model_ref, transforms=transforms, options=options, @@ -374,7 +353,7 @@ def create_llm_remote_model( model_ref = self._create_model_ref(session._anonymous_dataset) sql = self._model_creation_sql_generator.create_llm_remote_model( - source_df=input_data, + source_sql=input_data.sql, model_ref=model_ref, options=options, connection_name=connection_name, @@ -407,7 +386,7 @@ def create_time_series_model( model_ref = self._create_model_ref(session._anonymous_dataset) sql = self._model_creation_sql_generator.create_model( - source_df=input_data, + source_sql=input_data.sql, model_ref=model_ref, transforms=transforms, options=options, diff --git a/bigframes/ml/metrics/pairwise.py b/bigframes/ml/metrics/pairwise.py index bdbe4a682d..0e43412b21 100644 --- a/bigframes/ml/metrics/pairwise.py +++ b/bigframes/ml/metrics/pairwise.py @@ -17,19 +17,24 @@ import bigframes_vendored.sklearn.metrics.pairwise as vendored_metrics_pairwise -from bigframes.ml import core, utils +from bigframes.ml import utils +import bigframes.operations as ops import bigframes.pandas as bpd def paired_cosine_distances( X: Union[bpd.DataFrame, bpd.Series], Y: Union[bpd.DataFrame, bpd.Series] ) -> bpd.DataFrame: - X, Y = utils.convert_to_dataframe(X, Y) - if len(X.columns) != 1 or len(Y.columns) != 1: - raise ValueError("Inputs X and Y can only contain 1 column.") + X, Y = utils.convert_to_series(X, Y) + joined_block, _ = X._block.join(Y._block, how="outer") - base_bqml = core.BaseBqml(session=X._session) - return base_bqml.distance(X, Y, type="COSINE", name="cosine_distance") + result_block, _ = joined_block.project_expr( + ops.cosine_distance_op.as_expr( + joined_block.value_columns[0], joined_block.value_columns[1] + ), + label="cosine_distance", + ) + return bpd.DataFrame(result_block) paired_cosine_distances.__doc__ = inspect.getdoc( @@ -40,12 +45,16 @@ def paired_cosine_distances( def paired_manhattan_distance( X: Union[bpd.DataFrame, bpd.Series], Y: Union[bpd.DataFrame, bpd.Series] ) -> bpd.DataFrame: - X, Y = utils.convert_to_dataframe(X, Y) - if len(X.columns) != 1 or len(Y.columns) != 1: - raise ValueError("Inputs X and Y can only contain 1 column.") + X, Y = utils.convert_to_series(X, Y) + joined_block, _ = X._block.join(Y._block, how="outer") - base_bqml = core.BaseBqml(session=X._session) - return base_bqml.distance(X, Y, type="MANHATTAN", name="manhattan_distance") + result_block, _ = joined_block.project_expr( + ops.manhattan_distance_op.as_expr( + joined_block.value_columns[0], joined_block.value_columns[1] + ), + label="manhattan_distance", + ) + return bpd.DataFrame(result_block) paired_manhattan_distance.__doc__ = inspect.getdoc( @@ -56,12 +65,16 @@ def paired_manhattan_distance( def paired_euclidean_distances( X: Union[bpd.DataFrame, bpd.Series], Y: Union[bpd.DataFrame, bpd.Series] ) -> bpd.DataFrame: - X, Y = utils.convert_to_dataframe(X, Y) - if len(X.columns) != 1 or len(Y.columns) != 1: - raise ValueError("Inputs X and Y can only contain 1 column.") - - base_bqml = core.BaseBqml(session=X._session) - return base_bqml.distance(X, Y, type="EUCLIDEAN", name="euclidean_distance") + X, Y = utils.convert_to_series(X, Y) + joined_block, _ = X._block.join(Y._block, how="outer") + + result_block, _ = joined_block.project_expr( + ops.euclidean_distance_op.as_expr( + joined_block.value_columns[0], joined_block.value_columns[1] + ), + label="euclidean_distance", + ) + return bpd.DataFrame(result_block) paired_euclidean_distances.__doc__ = inspect.getdoc( diff --git a/bigframes/ml/sql.py b/bigframes/ml/sql.py index 0399db3a10..d14627f590 100644 --- a/bigframes/ml/sql.py +++ b/bigframes/ml/sql.py @@ -21,9 +21,9 @@ import google.cloud.bigquery import bigframes.constants as constants -import bigframes.pandas as bpd +# TODO: Add proper escaping logic from core/compile module class BaseSqlGenerator: """Generate base SQL strings for ML. Model name isn't needed in this class.""" @@ -170,12 +170,11 @@ def ml_distance( col_x: str, col_y: str, type: Literal["EUCLIDEAN", "MANHATTAN", "COSINE"], - source_df: bpd.DataFrame, + source_sql: str, name: str, ) -> str: """Encode ML.DISTANCE for BQML. https://cloud.google.com/bigquery/docs/reference/standard-sql/bigqueryml-syntax-distance""" - source_sql, _, _ = source_df._to_sql_query(include_index=True) return f"""SELECT *, ML.DISTANCE({col_x}, {col_y}, '{type}') AS {name} FROM ({source_sql})""" @@ -191,14 +190,12 @@ def _model_id_sql( # Model create and alter def create_model( self, - source_df: bpd.DataFrame, + source_sql: str, model_ref: google.cloud.bigquery.ModelReference, options: Mapping[str, Union[str, int, float, Iterable[str]]] = {}, transforms: Optional[Iterable[str]] = None, ) -> str: """Encode the CREATE OR REPLACE MODEL statement for BQML""" - source_sql = source_df.sql - parts = [f"CREATE OR REPLACE MODEL {self._model_id_sql(model_ref)}"] if transforms: parts.append(self.transform(*transforms)) @@ -209,14 +206,12 @@ def create_model( def create_llm_remote_model( self, - source_df: bpd.DataFrame, + source_sql: str, connection_name: str, model_ref: google.cloud.bigquery.ModelReference, options: Mapping[str, Union[str, int, float, Iterable[str]]] = {}, ) -> str: """Encode the CREATE OR REPLACE MODEL statement for BQML""" - source_sql = source_df.sql - parts = [f"CREATE OR REPLACE MODEL {self._model_id_sql(model_ref)}"] parts.append(self.connection(connection_name)) if options: @@ -280,11 +275,6 @@ class ModelManipulationSqlGenerator(BaseSqlGenerator): def __init__(self, model_name: str): self._model_name = model_name - def _source_sql(self, source_df: bpd.DataFrame) -> str: - """Return DataFrame sql with index columns.""" - _source_sql, _, _ = source_df._to_sql_query(include_index=True) - return _source_sql - # Alter model def alter_model( self, @@ -298,10 +288,10 @@ def alter_model( return "\n".join(parts) # ML prediction TVFs - def ml_predict(self, source_df: bpd.DataFrame) -> str: + def ml_predict(self, source_sql: str) -> str: """Encode ML.PREDICT for BQML""" return f"""SELECT * FROM ML.PREDICT(MODEL `{self._model_name}`, - ({self._source_sql(source_df)}))""" + ({source_sql}))""" def ml_forecast(self, struct_options: Mapping[str, Union[int, float]]) -> str: """Encode ML.FORECAST for BQML""" @@ -310,38 +300,32 @@ def ml_forecast(self, struct_options: Mapping[str, Union[int, float]]) -> str: {struct_options_sql})""" def ml_generate_text( - self, source_df: bpd.DataFrame, struct_options: Mapping[str, Union[int, float]] + self, source_sql: str, struct_options: Mapping[str, Union[int, float]] ) -> str: """Encode ML.GENERATE_TEXT for BQML""" struct_options_sql = self.struct_options(**struct_options) return f"""SELECT * FROM ML.GENERATE_TEXT(MODEL `{self._model_name}`, - ({self._source_sql(source_df)}), {struct_options_sql})""" + ({source_sql}), {struct_options_sql})""" def ml_generate_embedding( - self, source_df: bpd.DataFrame, struct_options: Mapping[str, Union[int, float]] + self, source_sql: str, struct_options: Mapping[str, Union[int, float]] ) -> str: """Encode ML.GENERATE_EMBEDDING for BQML""" struct_options_sql = self.struct_options(**struct_options) return f"""SELECT * FROM ML.GENERATE_EMBEDDING(MODEL `{self._model_name}`, - ({self._source_sql(source_df)}), {struct_options_sql})""" + ({source_sql}), {struct_options_sql})""" def ml_detect_anomalies( - self, source_df: bpd.DataFrame, struct_options: Mapping[str, Union[int, float]] + self, source_sql: str, struct_options: Mapping[str, Union[int, float]] ) -> str: """Encode ML.DETECT_ANOMALIES for BQML""" struct_options_sql = self.struct_options(**struct_options) return f"""SELECT * FROM ML.DETECT_ANOMALIES(MODEL `{self._model_name}`, - {struct_options_sql}, ({self._source_sql(source_df)}))""" + {struct_options_sql}, ({source_sql}))""" # ML evaluation TVFs - def ml_evaluate(self, source_df: Optional[bpd.DataFrame] = None) -> str: + def ml_evaluate(self, source_sql: Optional[str] = None) -> str: """Encode ML.EVALUATE for BQML""" - if source_df is None: - source_sql = None - else: - # Note: don't need index as evaluate returns a new table - source_sql, _, _ = source_df._to_sql_query(include_index=False) - if source_sql is None: return f"""SELECT * FROM ML.EVALUATE(MODEL `{self._model_name}`)""" else: @@ -353,12 +337,9 @@ def ml_arima_coefficients(self) -> str: return f"""SELECT * FROM ML.ARIMA_COEFFICIENTS(MODEL `{self._model_name}`)""" # ML evaluation TVFs - def ml_llm_evaluate( - self, source_df: bpd.DataFrame, task_type: Optional[str] = None - ) -> str: + def ml_llm_evaluate(self, source_sql: str, task_type: Optional[str] = None) -> str: """Encode ML.EVALUATE for BQML""" # Note: don't need index as evaluate returns a new table - source_sql, _, _ = source_df._to_sql_query(include_index=False) return f"""SELECT * FROM ML.EVALUATE(MODEL `{self._model_name}`, ({source_sql}), STRUCT("{task_type}" AS task_type))""" @@ -383,7 +364,7 @@ def ml_principal_component_info(self) -> str: ) # ML transform TVF, that require a transform_only type model - def ml_transform(self, source_df: bpd.DataFrame) -> str: + def ml_transform(self, source_sql: str) -> str: """Encode ML.TRANSFORM for BQML""" return f"""SELECT * FROM ML.TRANSFORM(MODEL `{self._model_name}`, - ({self._source_sql(source_df)}))""" + ({source_sql}))""" diff --git a/bigframes/operations/__init__.py b/bigframes/operations/__init__.py index 145c415ca0..23f2a50a95 100644 --- a/bigframes/operations/__init__.py +++ b/bigframes/operations/__init__.py @@ -690,6 +690,17 @@ def output_type(self, *input_types): ge_op = create_binary_op(name="ge", type_signature=op_typing.COMPARISON) +cosine_distance_op = create_binary_op( + name="ml_cosine_distance", type_signature=op_typing.VECTOR_METRIC +) +manhattan_distance_op = create_binary_op( + name="ml_manhattan_distance", type_signature=op_typing.VECTOR_METRIC +) +euclidean_distance_op = create_binary_op( + name="ml_euclidean_distance", type_signature=op_typing.VECTOR_METRIC +) + + ## String Ops @dataclasses.dataclass(frozen=True) class StrConcatOp(BinaryOp): diff --git a/bigframes/operations/type.py b/bigframes/operations/type.py index f469070805..ce37b8da55 100644 --- a/bigframes/operations/type.py +++ b/bigframes/operations/type.py @@ -190,6 +190,24 @@ def output_type( return left_type +@dataclasses.dataclass +class VectorMetric(BinaryTypeSignature): + """Type signature for logical operators like AND, OR and NOT.""" + + def output_type( + self, left_type: ExpressionType, right_type: ExpressionType + ) -> ExpressionType: + if not bigframes.dtypes.is_array_like(left_type): + raise TypeError(f"Type {left_type} is not array-like") + if not bigframes.dtypes.is_array_like(right_type): + raise TypeError(f"Type {right_type} is not array-like") + if left_type != right_type: + raise TypeError( + "Vector op operands {left_type} and {right_type} do not match" + ) + return bigframes.dtypes.FLOAT_DTYPE + + # Common type signatures UNARY_NUMERIC = TypePreserving(bigframes.dtypes.is_numeric, description="numeric") UNARY_REAL_NUMERIC = UnaryRealNumeric() @@ -212,3 +230,4 @@ def output_type( TIMELIKE_ACCESSOR = FixedOutputType( bigframes.dtypes.is_time_like, bigframes.dtypes.INT_DTYPE, description="time-like" ) +VECTOR_METRIC = VectorMetric() diff --git a/tests/system/small/ml/test_metrics_pairwise.py b/tests/system/small/ml/test_metrics_pairwise.py index 717f32667f..d3798f7cae 100644 --- a/tests/system/small/ml/test_metrics_pairwise.py +++ b/tests/system/small/ml/test_metrics_pairwise.py @@ -35,6 +35,47 @@ def test_paired_cosine_distances(): ) +def test_paired_cosine_distances_multiindex(): + x_col = [np.array([4.1, 0.5, 1.0])] + y_col = [np.array([3.0, 0.0, 2.5])] + data = bpd.read_pandas( + pd.DataFrame( + {("DATA", "X"): x_col, ("DATA", "Y"): y_col}, + ) + ) + + result = metrics.pairwise.paired_cosine_distances( + data[("DATA", "X")], data[("DATA", "Y")] + ) + expected_pd_df = pd.DataFrame( + { + ("DATA", "X"): x_col, + ("DATA", "Y"): y_col, + ("cosine_distance", ""): [0.108199], + } + ) + + pd.testing.assert_frame_equal( + result.to_pandas(), expected_pd_df, check_dtype=False, check_index_type=False + ) + + +def test_paired_cosine_distances_single_frame(): + x_col = [np.array([4.1, 0.5, 1.0])] + y_col = [np.array([3.0, 0.0, 2.5])] + input = bpd.read_pandas(pd.DataFrame({"X": x_col})) + input["Y"] = y_col # type: ignore + + result = metrics.pairwise.paired_cosine_distances(input.X, input.Y) + expected_pd_df = pd.DataFrame( + {"X": x_col, "Y": y_col, "cosine_distance": [0.108199]} + ) + + pd.testing.assert_frame_equal( + result.to_pandas(), expected_pd_df, check_dtype=False, check_index_type=False + ) + + def test_paired_manhattan_distance(): x_col = [np.array([4.1, 0.5, 1.0])] y_col = [np.array([3.0, 0.0, 2.5])] diff --git a/tests/unit/ml/test_sql.py b/tests/unit/ml/test_sql.py index e90146565d..cdf2d0b2e4 100644 --- a/tests/unit/ml/test_sql.py +++ b/tests/unit/ml/test_sql.py @@ -152,23 +152,12 @@ def test_polynomial_expand( assert sql == "ML.POLYNOMIAL_EXPAND(STRUCT(col_a, col_b), 2) AS poly_exp" -def test_distance_correct( - base_sql_generator: ml_sql.BaseSqlGenerator, - mock_df: bpd.DataFrame, -): - sql = base_sql_generator.ml_distance("col_a", "col_b", "COSINE", mock_df, "cosine") - assert ( - sql - == "SELECT *, ML.DISTANCE(col_a, col_b, 'COSINE') AS cosine FROM (input_X_sql)" - ) - - def test_create_model_correct( model_creation_sql_generator: ml_sql.ModelCreationSqlGenerator, mock_df: bpd.DataFrame, ): sql = model_creation_sql_generator.create_model( - source_df=mock_df, + source_sql=mock_df.sql, model_ref=bigquery.ModelReference.from_string( "test-proj._anonXYZ.create_model_correct_sql" ), @@ -189,7 +178,7 @@ def test_create_model_transform_correct( mock_df: bpd.DataFrame, ): sql = model_creation_sql_generator.create_model( - source_df=mock_df, + source_sql=mock_df.sql, model_ref=bigquery.ModelReference.from_string( "test-proj._anonXYZ.create_model_transform" ), @@ -217,7 +206,7 @@ def test_create_llm_remote_model_correct( mock_df: bpd.DataFrame, ): sql = model_creation_sql_generator.create_llm_remote_model( - source_df=mock_df, + source_sql=mock_df.sql, connection_name="my_project.us.my_connection", model_ref=bigquery.ModelReference.from_string( "test-proj._anonXYZ.create_remote_model" @@ -342,11 +331,11 @@ def test_ml_predict_correct( model_manipulation_sql_generator: ml_sql.ModelManipulationSqlGenerator, mock_df: bpd.DataFrame, ): - sql = model_manipulation_sql_generator.ml_predict(source_df=mock_df) + sql = model_manipulation_sql_generator.ml_predict(source_sql=mock_df.sql) assert ( sql == """SELECT * FROM ML.PREDICT(MODEL `my_project_id.my_dataset_id.my_model_id`, - (input_X_sql))""" + (input_X_y_sql))""" ) @@ -355,12 +344,12 @@ def test_ml_llm_evaluate_correct( mock_df: bpd.DataFrame, ): sql = model_manipulation_sql_generator.ml_llm_evaluate( - source_df=mock_df, task_type="CLASSIFICATION" + source_sql=mock_df.sql, task_type="CLASSIFICATION" ) assert ( sql == """SELECT * FROM ML.EVALUATE(MODEL `my_project_id.my_dataset_id.my_model_id`, - (input_X_sql), STRUCT("CLASSIFICATION" AS task_type))""" + (input_X_y_sql), STRUCT("CLASSIFICATION" AS task_type))""" ) @@ -368,11 +357,11 @@ def test_ml_evaluate_correct( model_manipulation_sql_generator: ml_sql.ModelManipulationSqlGenerator, mock_df: bpd.DataFrame, ): - sql = model_manipulation_sql_generator.ml_evaluate(source_df=mock_df) + sql = model_manipulation_sql_generator.ml_evaluate(source_sql=mock_df.sql) assert ( sql == """SELECT * FROM ML.EVALUATE(MODEL `my_project_id.my_dataset_id.my_model_id`, - (input_X_sql))""" + (input_X_y_sql))""" ) @@ -429,13 +418,13 @@ def test_ml_generate_text_correct( mock_df: bpd.DataFrame, ): sql = model_manipulation_sql_generator.ml_generate_text( - source_df=mock_df, + source_sql=mock_df.sql, struct_options={"option_key1": 1, "option_key2": 2.2}, ) assert ( sql == """SELECT * FROM ML.GENERATE_TEXT(MODEL `my_project_id.my_dataset_id.my_model_id`, - (input_X_sql), STRUCT( + (input_X_y_sql), STRUCT( 1 AS option_key1, 2.2 AS option_key2))""" ) @@ -446,13 +435,13 @@ def test_ml_generate_embedding_correct( mock_df: bpd.DataFrame, ): sql = model_manipulation_sql_generator.ml_generate_embedding( - source_df=mock_df, + source_sql=mock_df.sql, struct_options={"option_key1": 1, "option_key2": 2.2}, ) assert ( sql == """SELECT * FROM ML.GENERATE_EMBEDDING(MODEL `my_project_id.my_dataset_id.my_model_id`, - (input_X_sql), STRUCT( + (input_X_y_sql), STRUCT( 1 AS option_key1, 2.2 AS option_key2))""" ) @@ -463,7 +452,7 @@ def test_ml_detect_anomalies_correct_sql( mock_df: bpd.DataFrame, ): sql = model_manipulation_sql_generator.ml_detect_anomalies( - source_df=mock_df, + source_sql=mock_df.sql, struct_options={"option_key1": 1, "option_key2": 2.2}, ) assert ( @@ -471,7 +460,7 @@ def test_ml_detect_anomalies_correct_sql( == """SELECT * FROM ML.DETECT_ANOMALIES(MODEL `my_project_id.my_dataset_id.my_model_id`, STRUCT( 1 AS option_key1, - 2.2 AS option_key2), (input_X_sql))""" + 2.2 AS option_key2), (input_X_y_sql))""" ) From 2158818e53e09e55c87ffd574e3ebc2e201285fb Mon Sep 17 00:00:00 2001 From: Shobhit Singh Date: Fri, 2 Aug 2024 10:02:13 -0700 Subject: [PATCH 05/10] feat: `df.apply(axis=1)` to support remote function with mutiple params (#851) * feat: extend `df.apply(axis=1)` to support remote function with mutiple params * add doctest, make small test remote function sticky * handle single param non-row-processing functions * reword the documentation a bit * handle missing input dtype in read_gbq_function * restore input types as tuple in read_gbq_function * clear previous remote function attributes * reword documentation for clarity * add/update comments to explain force reproject * make doctest example remote function with 3 params --- bigframes/core/compile/scalar_op_compiler.py | 26 ++- bigframes/dataframe.py | 148 ++++++++++------- bigframes/exceptions.py | 4 + bigframes/functions/remote_function.py | 40 ++++- bigframes/operations/__init__.py | 13 ++ bigframes/series.py | 11 +- bigframes/session/__init__.py | 4 +- tests/system/large/test_remote_function.py | 154 +++++++++++++++++- tests/system/small/test_remote_function.py | 80 +++++---- .../bigframes_vendored/pandas/core/frame.py | 43 ++++- 10 files changed, 417 insertions(+), 106 deletions(-) diff --git a/bigframes/core/compile/scalar_op_compiler.py b/bigframes/core/compile/scalar_op_compiler.py index 06e9481d17..67d0dac436 100644 --- a/bigframes/core/compile/scalar_op_compiler.py +++ b/bigframes/core/compile/scalar_op_compiler.py @@ -191,19 +191,27 @@ def normalized_impl(args: typing.Sequence[ibis_types.Value], op: ops.RowOp): return decorator - def register_nary_op(self, op_ref: typing.Union[ops.NaryOp, type[ops.NaryOp]]): + def register_nary_op( + self, op_ref: typing.Union[ops.NaryOp, type[ops.NaryOp]], pass_op: bool = False + ): """ Decorator to register a nary op implementation. Args: op_ref (NaryOp or NaryOp type): Class or instance of operator that is implemented by the decorated function. + pass_op (bool): + Set to true if implementation takes the operator object as the last argument. + This is needed for parameterized ops where parameters are part of op object. """ key = typing.cast(str, op_ref.name) def decorator(impl: typing.Callable[..., ibis_types.Value]): def normalized_impl(args: typing.Sequence[ibis_types.Value], op: ops.RowOp): - return impl(*args) + if pass_op: + return impl(*args, op=op) + else: + return impl(*args) self._register(key, normalized_impl) return impl @@ -1468,6 +1476,7 @@ def clip_op( ) +# N-ary Operations @scalar_op_compiler.register_nary_op(ops.case_when_op) def case_when_op(*cases_and_outputs: ibis_types.Value) -> ibis_types.Value: # ibis can handle most type coercions, but we need to force bool -> int @@ -1487,6 +1496,19 @@ def case_when_op(*cases_and_outputs: ibis_types.Value) -> ibis_types.Value: return case_val.end() +@scalar_op_compiler.register_nary_op(ops.NaryRemoteFunctionOp, pass_op=True) +def nary_remote_function_op_impl( + *operands: ibis_types.Value, op: ops.NaryRemoteFunctionOp +): + ibis_node = getattr(op.func, "ibis_node", None) + if ibis_node is None: + raise TypeError( + f"only a bigframes remote function is supported as a callable. {constants.FEEDBACK_LINK}" + ) + result = ibis_node(*operands) + return result + + # Helpers def is_null(value) -> bool: # float NaN/inf should be treated as distinct from 'true' null values diff --git a/bigframes/dataframe.py b/bigframes/dataframe.py index 9789c7cf9f..9d3b153d3a 100644 --- a/bigframes/dataframe.py +++ b/bigframes/dataframe.py @@ -3433,9 +3433,9 @@ def map(self, func, na_action: Optional[str] = None) -> DataFrame: raise ValueError(f"na_action={na_action} not supported") # TODO(shobs): Support **kwargs - # Reproject as workaround to applying filter too late. This forces the filter - # to be applied before passing data to remote function, protecting from bad - # inputs causing errors. + # Reproject as workaround to applying filter too late. This forces the + # filter to be applied before passing data to remote function, + # protecting from bad inputs causing errors. reprojected_df = DataFrame(self._block._force_reproject()) return reprojected_df._apply_unary_op( ops.RemoteFunctionOp(func=func, apply_on_null=(na_action is None)) @@ -3448,65 +3448,99 @@ def apply(self, func, *, axis=0, args: typing.Tuple = (), **kwargs): category=bigframes.exceptions.PreviewWarning, ) - # Early check whether the dataframe dtypes are currently supported - # in the remote function - # NOTE: Keep in sync with the value converters used in the gcf code - # generated in remote_function_template.py - remote_function_supported_dtypes = ( - bigframes.dtypes.INT_DTYPE, - bigframes.dtypes.FLOAT_DTYPE, - bigframes.dtypes.BOOL_DTYPE, - bigframes.dtypes.BYTES_DTYPE, - bigframes.dtypes.STRING_DTYPE, - ) - supported_dtypes_types = tuple( - type(dtype) - for dtype in remote_function_supported_dtypes - if not isinstance(dtype, pandas.ArrowDtype) - ) - # Check ArrowDtype separately since multiple BigQuery types map to - # ArrowDtype, including BYTES and TIMESTAMP. - supported_arrow_types = tuple( - dtype.pyarrow_dtype - for dtype in remote_function_supported_dtypes - if isinstance(dtype, pandas.ArrowDtype) - ) - supported_dtypes_hints = tuple( - str(dtype) for dtype in remote_function_supported_dtypes - ) - - for dtype in self.dtypes: - if ( - # Not one of the pandas/numpy types. - not isinstance(dtype, supported_dtypes_types) - # And not one of the arrow types. - and not ( - isinstance(dtype, pandas.ArrowDtype) - and any( - dtype.pyarrow_dtype.equals(arrow_type) - for arrow_type in supported_arrow_types - ) - ) - ): - raise NotImplementedError( - f"DataFrame has a column of dtype '{dtype}' which is not supported with axis=1." - f" Supported dtypes are {supported_dtypes_hints}." - ) - # Check if the function is a remote function if not hasattr(func, "bigframes_remote_function"): raise ValueError("For axis=1 a remote function must be used.") - # Serialize the rows as json values - block = self._get_block() - rows_as_json_series = bigframes.series.Series( - block._get_rows_as_json_values() - ) + is_row_processor = getattr(func, "is_row_processor") + if is_row_processor: + # Early check whether the dataframe dtypes are currently supported + # in the remote function + # NOTE: Keep in sync with the value converters used in the gcf code + # generated in remote_function_template.py + remote_function_supported_dtypes = ( + bigframes.dtypes.INT_DTYPE, + bigframes.dtypes.FLOAT_DTYPE, + bigframes.dtypes.BOOL_DTYPE, + bigframes.dtypes.BYTES_DTYPE, + bigframes.dtypes.STRING_DTYPE, + ) + supported_dtypes_types = tuple( + type(dtype) + for dtype in remote_function_supported_dtypes + if not isinstance(dtype, pandas.ArrowDtype) + ) + # Check ArrowDtype separately since multiple BigQuery types map to + # ArrowDtype, including BYTES and TIMESTAMP. + supported_arrow_types = tuple( + dtype.pyarrow_dtype + for dtype in remote_function_supported_dtypes + if isinstance(dtype, pandas.ArrowDtype) + ) + supported_dtypes_hints = tuple( + str(dtype) for dtype in remote_function_supported_dtypes + ) - # Apply the function - result_series = rows_as_json_series._apply_unary_op( - ops.RemoteFunctionOp(func=func, apply_on_null=True) - ) + for dtype in self.dtypes: + if ( + # Not one of the pandas/numpy types. + not isinstance(dtype, supported_dtypes_types) + # And not one of the arrow types. + and not ( + isinstance(dtype, pandas.ArrowDtype) + and any( + dtype.pyarrow_dtype.equals(arrow_type) + for arrow_type in supported_arrow_types + ) + ) + ): + raise NotImplementedError( + f"DataFrame has a column of dtype '{dtype}' which is not supported with axis=1." + f" Supported dtypes are {supported_dtypes_hints}." + ) + + # Serialize the rows as json values + block = self._get_block() + rows_as_json_series = bigframes.series.Series( + block._get_rows_as_json_values() + ) + + # Apply the function + result_series = rows_as_json_series._apply_unary_op( + ops.RemoteFunctionOp(func=func, apply_on_null=True) + ) + else: + # This is a special case where we are providing not-pandas-like + # extension. If the remote function can take one or more params + # then we assume that here the user intention is to use the + # column values of the dataframe as arguments to the function. + # For this to work the following condition must be true: + # 1. The number or input params in the function must be same + # as the number of columns in the dataframe + # 2. The dtypes of the columns in the dataframe must be + # compatible with the data types of the input params + # 3. The order of the columns in the dataframe must correspond + # to the order of the input params in the function + udf_input_dtypes = getattr(func, "input_dtypes") + if len(udf_input_dtypes) != len(self.columns): + raise ValueError( + f"Remote function takes {len(udf_input_dtypes)} arguments but DataFrame has {len(self.columns)} columns." + ) + if udf_input_dtypes != tuple(self.dtypes.to_list()): + raise ValueError( + f"Remote function takes arguments of types {udf_input_dtypes} but DataFrame dtypes are {tuple(self.dtypes)}." + ) + + series_list = [self[col] for col in self.columns] + # Reproject as workaround to applying filter too late. This forces the + # filter to be applied before passing data to remote function, + # protecting from bad inputs causing errors. + reprojected_series = bigframes.series.Series( + series_list[0]._block._force_reproject() + ) + result_series = reprojected_series._apply_nary_op( + ops.NaryRemoteFunctionOp(func=func), series_list[1:] + ) result_series.name = None # Return Series with materialized result so that any error in the remote diff --git a/bigframes/exceptions.py b/bigframes/exceptions.py index 1d31749760..6c5b66bc47 100644 --- a/bigframes/exceptions.py +++ b/bigframes/exceptions.py @@ -57,3 +57,7 @@ class QueryComplexityError(RuntimeError): class TimeTravelDisabledWarning(Warning): """A query was reattempted without time travel.""" + + +class UnknownDataTypeWarning(Warning): + """Data type is unknown.""" diff --git a/bigframes/functions/remote_function.py b/bigframes/functions/remote_function.py index d84fbcdbab..b3c6aee1b3 100644 --- a/bigframes/functions/remote_function.py +++ b/bigframes/functions/remote_function.py @@ -66,6 +66,7 @@ from bigframes import clients import bigframes.constants as constants import bigframes.core.compile.ibis_types +import bigframes.dtypes import bigframes.functions.remote_function_template logger = logging.getLogger(__name__) @@ -895,8 +896,8 @@ def remote_function( reuse (bool, Optional): Reuse the remote function if already exists. `True` by default, which will result in reusing an existing remote - function and corresponding cloud function (if any) that was - previously created for the same udf. + function and corresponding cloud function that was previously + created (if any) for the same udf. Please note that for an unnamed (i.e. created without an explicit `name` argument) remote function, the BigQuery DataFrames session id is attached in the cloud artifacts names. So for the @@ -1174,7 +1175,9 @@ def try_delattr(attr): try_delattr("bigframes_cloud_function") try_delattr("bigframes_remote_function") + try_delattr("input_dtypes") try_delattr("output_dtype") + try_delattr("is_row_processor") try_delattr("ibis_node") ( @@ -1216,12 +1219,20 @@ def try_delattr(attr): rf_name ) ) - + func.input_dtypes = tuple( + [ + bigframes.core.compile.ibis_types.ibis_dtype_to_bigframes_dtype( + input_type + ) + for input_type in ibis_signature.input_types + ] + ) func.output_dtype = ( bigframes.core.compile.ibis_types.ibis_dtype_to_bigframes_dtype( ibis_signature.output_type ) ) + func.is_row_processor = is_row_processor func.ibis_node = node # If a new remote function was created, update the cloud artifacts @@ -1305,6 +1316,29 @@ def func(*ignored_args, **ignored_kwargs): signature=(ibis_signature.input_types, ibis_signature.output_type), ) func.bigframes_remote_function = str(routine_ref) # type: ignore + + # set input bigframes data types + has_unknown_dtypes = False + function_input_dtypes = [] + for ibis_type in ibis_signature.input_types: + input_dtype = cast(bigframes.dtypes.Dtype, bigframes.dtypes.DEFAULT_DTYPE) + if ibis_type is None: + has_unknown_dtypes = True + else: + input_dtype = ( + bigframes.core.compile.ibis_types.ibis_dtype_to_bigframes_dtype( + ibis_type + ) + ) + function_input_dtypes.append(input_dtype) + if has_unknown_dtypes: + warnings.warn( + "The function has one or more missing input data types." + f" BigQuery DataFrames will assume default data type {bigframes.dtypes.DEFAULT_DTYPE} for them.", + category=bigframes.exceptions.UnknownDataTypeWarning, + ) + func.input_dtypes = tuple(function_input_dtypes) # type: ignore + func.output_dtype = bigframes.core.compile.ibis_types.ibis_dtype_to_bigframes_dtype( # type: ignore ibis_signature.output_type ) diff --git a/bigframes/operations/__init__.py b/bigframes/operations/__init__.py index 23f2a50a95..523882c14e 100644 --- a/bigframes/operations/__init__.py +++ b/bigframes/operations/__init__.py @@ -659,6 +659,19 @@ def output_type(self, *input_types): raise AttributeError("output_dtype not defined") +@dataclasses.dataclass(frozen=True) +class NaryRemoteFunctionOp(NaryOp): + name: typing.ClassVar[str] = "nary_remote_function" + func: typing.Callable + + def output_type(self, *input_types): + # This property should be set to a valid Dtype by the @remote_function decorator or read_gbq_function method + if hasattr(self.func, "output_dtype"): + return self.func.output_dtype + else: + raise AttributeError("output_dtype not defined") + + add_op = AddOp() sub_op = SubOp() mul_op = create_binary_op(name="mul", type_signature=op_typing.BINARY_NUMERIC) diff --git a/bigframes/series.py b/bigframes/series.py index 1a5661529c..9e33801834 100644 --- a/bigframes/series.py +++ b/bigframes/series.py @@ -1442,9 +1442,6 @@ def apply( ) -> Series: # TODO(shobs, b/274645634): Support convert_dtype, args, **kwargs # is actually a ternary op - # Reproject as workaround to applying filter too late. This forces the filter - # to be applied before passing data to remote function, protecting from bad - # inputs causing errors. if by_row not in ["compat", False]: raise ValueError("Param by_row must be one of 'compat' or False") @@ -1474,7 +1471,10 @@ def apply( ex.message += f"\n{_remote_function_recommendation_message}" raise - # We are working with remote function at this point + # We are working with remote function at this point. + # Reproject as workaround to applying filter too late. This forces the + # filter to be applied before passing data to remote function, + # protecting from bad inputs causing errors. reprojected_series = Series(self._block._force_reproject()) result_series = reprojected_series._apply_unary_op( ops.RemoteFunctionOp(func=func, apply_on_null=True) @@ -1507,6 +1507,9 @@ def combine( ex.message += f"\n{_remote_function_recommendation_message}" raise + # Reproject as workaround to applying filter too late. This forces the + # filter to be applied before passing data to remote function, + # protecting from bad inputs causing errors. reprojected_series = Series(self._block._force_reproject()) result_series = reprojected_series._apply_binary_op( other, ops.BinaryRemoteFunctionOp(func=func) diff --git a/bigframes/session/__init__.py b/bigframes/session/__init__.py index 98cba867f2..233e6ef930 100644 --- a/bigframes/session/__init__.py +++ b/bigframes/session/__init__.py @@ -1661,8 +1661,8 @@ def remote_function( reuse (bool, Optional): Reuse the remote function if already exists. `True` by default, which will result in reusing an existing remote - function and corresponding cloud function (if any) that was - previously created for the same udf. + function and corresponding cloud function that was previously + created (if any) for the same udf. Please note that for an unnamed (i.e. created without an explicit `name` argument) remote function, the BigQuery DataFrames session id is attached in the cloud artifacts names. So for the diff --git a/tests/system/large/test_remote_function.py b/tests/system/large/test_remote_function.py index 303c74f1fd..095f7059cd 100644 --- a/tests/system/large/test_remote_function.py +++ b/tests/system/large/test_remote_function.py @@ -28,6 +28,9 @@ import test_utils.prefixer import bigframes +import bigframes.dataframe +import bigframes.dtypes +import bigframes.exceptions import bigframes.functions.remote_function as bigframes_rf import bigframes.pandas as bpd import bigframes.series @@ -363,7 +366,8 @@ def test_remote_function_input_types(session, scalars_dfs, input_types): def add_one(x): return x + 1 - remote_add_one = session.remote_function(input_types, int)(add_one) + remote_add_one = session.remote_function(input_types, int, reuse=False)(add_one) + assert remote_add_one.input_dtypes == (bigframes.dtypes.INT_DTYPE,) scalars_df, scalars_pandas_df = scalars_dfs @@ -1589,6 +1593,8 @@ def serialize_row(row): bigframes.series.Series, str, reuse=False )(serialize_row) + assert getattr(serialize_row_remote, "is_row_processor") + bf_result = scalars_df[columns].apply(serialize_row_remote, axis=1).to_pandas() pd_result = scalars_pandas_df[columns].apply(serialize_row, axis=1) @@ -1622,7 +1628,11 @@ def analyze(row): } ) - analyze_remote = session.remote_function(bigframes.series.Series, str)(analyze) + analyze_remote = session.remote_function( + bigframes.series.Series, str, reuse=False + )(analyze) + + assert getattr(analyze_remote, "is_row_processor") bf_result = ( scalars_df[columns].dropna().apply(analyze_remote, axis=1).to_pandas() @@ -1727,6 +1737,8 @@ def serialize_row(row): bigframes.series.Series, str, reuse=False )(serialize_row) + assert getattr(serialize_row_remote, "is_row_processor") + bf_result = bf_df.apply(serialize_row_remote, axis=1).to_pandas() pd_result = pd_df.apply(serialize_row, axis=1) @@ -1787,6 +1799,8 @@ def float_parser(row): bigframes.series.Series, float, reuse=False )(float_parser) + assert getattr(float_parser_remote, "is_row_processor") + pd_result = pd_df.apply(float_parser, axis=1) bf_result = bf_df.apply(float_parser_remote, axis=1).to_pandas() @@ -1913,7 +1927,7 @@ def test_remote_function_named_perists_w_session_cleanup(): name = test_utils.prefixer.Prefixer("bigframes", "").create_prefix() # create an unnamed remote function in the session - @session.remote_function(name=name) + @session.remote_function(reuse=False, name=name) def foo(x: int) -> int: return x + 1 @@ -2004,3 +2018,137 @@ def foo_named(x: int) -> int: cleanup_remote_function_assets( session.bqclient, session.cloudfunctionsclient, foo_named ) + + +def test_df_apply_axis_1_multiple_params(session): + bf_df = bigframes.dataframe.DataFrame( + { + "Id": [1, 2, 3], + "Age": [22.5, 23, 23.5], + "Name": ["alpha", "beta", "gamma"], + } + ) + + expected_dtypes = ( + bigframes.dtypes.INT_DTYPE, + bigframes.dtypes.FLOAT_DTYPE, + bigframes.dtypes.STRING_DTYPE, + ) + + # Assert the dataframe dtypes + assert tuple(bf_df.dtypes) == expected_dtypes + + try: + + @session.remote_function([int, float, str], str, reuse=False) + def foo(x, y, z): + return f"I got {x}, {y} and {z}" + + assert getattr(foo, "is_row_processor") is False + assert getattr(foo, "input_dtypes") == expected_dtypes + + # Fails to apply on dataframe with incompatible number of columns + with pytest.raises( + ValueError, + match="^Remote function takes 3 arguments but DataFrame has 2 columns\\.$", + ): + bf_df[["Id", "Age"]].apply(foo, axis=1) + with pytest.raises( + ValueError, + match="^Remote function takes 3 arguments but DataFrame has 4 columns\\.$", + ): + bf_df.assign(Country="lalaland").apply(foo, axis=1) + + # Fails to apply on dataframe with incompatible column datatypes + with pytest.raises( + ValueError, + match="^Remote function takes arguments of types .* but DataFrame dtypes are .*", + ): + bf_df.assign(Age=bf_df["Age"].astype("Int64")).apply(foo, axis=1) + + # Successfully applies to dataframe with matching number of columns + # and their datatypes + bf_result = bf_df.apply(foo, axis=1).to_pandas() + + # Since this scenario is not pandas-like, let's handcraft the + # expected result + expected_result = pandas.Series( + [ + "I got 1, 22.5 and alpha", + "I got 2, 23 and beta", + "I got 3, 23.5 and gamma", + ] + ) + + pandas.testing.assert_series_equal( + expected_result, bf_result, check_dtype=False, check_index_type=False + ) + finally: + # clean up the gcp assets created for the remote function + cleanup_remote_function_assets( + session.bqclient, session.cloudfunctionsclient, foo + ) + + +def test_df_apply_axis_1_single_param_non_series(session): + bf_df = bigframes.dataframe.DataFrame( + { + "Id": [1, 2, 3], + } + ) + + expected_dtypes = (bigframes.dtypes.INT_DTYPE,) + + # Assert the dataframe dtypes + assert tuple(bf_df.dtypes) == expected_dtypes + + try: + + @session.remote_function([int], str, reuse=False) + def foo(x): + return f"I got {x}" + + assert getattr(foo, "is_row_processor") is False + assert getattr(foo, "input_dtypes") == expected_dtypes + + # Fails to apply on dataframe with incompatible number of columns + with pytest.raises( + ValueError, + match="^Remote function takes 1 arguments but DataFrame has 0 columns\\.$", + ): + bf_df[[]].apply(foo, axis=1) + with pytest.raises( + ValueError, + match="^Remote function takes 1 arguments but DataFrame has 2 columns\\.$", + ): + bf_df.assign(Country="lalaland").apply(foo, axis=1) + + # Fails to apply on dataframe with incompatible column datatypes + with pytest.raises( + ValueError, + match="^Remote function takes arguments of types .* but DataFrame dtypes are .*", + ): + bf_df.assign(Id=bf_df["Id"].astype("Float64")).apply(foo, axis=1) + + # Successfully applies to dataframe with matching number of columns + # and their datatypes + bf_result = bf_df.apply(foo, axis=1).to_pandas() + + # Since this scenario is not pandas-like, let's handcraft the + # expected result + expected_result = pandas.Series( + [ + "I got 1", + "I got 2", + "I got 3", + ] + ) + + pandas.testing.assert_series_equal( + expected_result, bf_result, check_dtype=False, check_index_type=False + ) + finally: + # clean up the gcp assets created for the remote function + cleanup_remote_function_assets( + session.bqclient, session.cloudfunctionsclient, foo + ) diff --git a/tests/system/small/test_remote_function.py b/tests/system/small/test_remote_function.py index c07a0afb44..8ecf9eb368 100644 --- a/tests/system/small/test_remote_function.py +++ b/tests/system/small/test_remote_function.py @@ -21,6 +21,7 @@ import pytest import bigframes +import bigframes.dtypes import bigframes.exceptions from bigframes.functions import remote_function as rf from tests.system.utils import assert_pandas_df_equal @@ -708,6 +709,8 @@ def test_read_gbq_function_reads_udfs(session, bigquery_client, dataset_id): # It should point to the named routine and yield the expected results. assert square.bigframes_remote_function == str(routine.reference) + assert square.input_dtypes == (bigframes.dtypes.INT_DTYPE,) + assert square.output_dtype == bigframes.dtypes.INT_DTYPE src = {"x": [-5, -4, -3, -2, -1, 0, 1, 2, 3, 4, 5]} @@ -776,10 +779,14 @@ def test_read_gbq_function_enforces_explicit_types( str(both_types_specified.reference), session=session, ) - rf.read_gbq_function( - str(only_return_type_specified.reference), - session=session, - ) + with pytest.warns( + bigframes.exceptions.UnknownDataTypeWarning, + match="missing input data types.*assume default data type", + ): + rf.read_gbq_function( + str(only_return_type_specified.reference), + session=session, + ) with pytest.raises(ValueError): rf.read_gbq_function( str(only_arg_type_specified.reference), @@ -919,36 +926,41 @@ def add_ints(row): scalars_df[columns].apply(add_ints, axis=1) -@pytest.mark.parametrize( - ("column"), - [ - pytest.param("date_col"), - pytest.param("datetime_col"), - pytest.param("geography_col"), - pytest.param("numeric_col"), - pytest.param("time_col"), - pytest.param("timestamp_col"), - ], -) -def test_df_apply_axis_1_unsupported_dtype(scalars_dfs, column): - scalars_df, scalars_pandas_df = scalars_dfs - - # It doesn't matter if it is a remote function or not, the dtype check - # is done even before the function type check with axis=1 - def echo(row): - return row[column] +@pytest.mark.flaky(retries=2, delay=120) +def test_df_apply_axis_1_unsupported_dtype(session, scalars_dfs, dataset_id_permanent): + columns_with_not_supported_dtypes = [ + "date_col", + "datetime_col", + "geography_col", + "numeric_col", + "time_col", + "timestamp_col", + ] - # pandas works - scalars_pandas_df[[column]].apply(echo, axis=1) + scalars_df, scalars_pandas_df = scalars_dfs - dtype = scalars_df[column].dtype + def echo_len(row): + return len(row) - with pytest.raises( - NotImplementedError, - match=re.escape( - f"DataFrame has a column of dtype '{dtype}' which is not supported with axis=1. Supported dtypes are (" - ), - ), pytest.warns( - bigframes.exceptions.PreviewWarning, match="axis=1 scenario is in preview." - ): - scalars_df[[column]].apply(echo, axis=1) + echo_len_remote = session.remote_function( + bigframes.series.Series, + float, + dataset_id_permanent, + name=get_rf_name(echo_len, is_row_processor=True), + )(echo_len) + + for column in columns_with_not_supported_dtypes: + # pandas works + scalars_pandas_df[[column]].apply(echo_len, axis=1) + + dtype = scalars_df[column].dtype + + with pytest.raises( + NotImplementedError, + match=re.escape( + f"DataFrame has a column of dtype '{dtype}' which is not supported with axis=1. Supported dtypes are (" + ), + ), pytest.warns( + bigframes.exceptions.PreviewWarning, match="axis=1 scenario is in preview." + ): + scalars_df[[column]].apply(echo_len_remote, axis=1) diff --git a/third_party/bigframes_vendored/pandas/core/frame.py b/third_party/bigframes_vendored/pandas/core/frame.py index 7048d9c6dd..10565a2552 100644 --- a/third_party/bigframes_vendored/pandas/core/frame.py +++ b/third_party/bigframes_vendored/pandas/core/frame.py @@ -4361,9 +4361,50 @@ def apply(self, func, *, axis=0, args=(), **kwargs): 1 19 dtype: Int64 + You could also apply a remote function which accepts multiple parameters + to every row of a DataFrame by using it with `axis=1` if the DataFrame + has matching number of columns and data types. Note: This feature is + currently in **preview**. + + >>> df = bpd.DataFrame({ + ... 'col1': [1, 2], + ... 'col2': [3, 4], + ... 'col3': [5, 5] + ... }) + >>> df + col1 col2 col3 + 0 1 3 5 + 1 2 4 5 + + [2 rows x 3 columns] + + >>> @bpd.remote_function(reuse=False) + ... def foo(x: int, y: int, z: int) -> float: + ... result = 1 + ... result += x + ... result += y/z + ... return result + + >>> df.apply(foo, axis=1) + 0 2.6 + 1 3.8 + dtype: Float64 + Args: func (function): - Function to apply to each column or row. + Function to apply to each column or row. To apply to each row + (i.e. when `axis=1` is specified) the function can be of one of + the two types: + + (1). It accepts a single input parameter of type `Series`, in + which case each row is delivered to the function as a pandas + Series. + + (2). It accept one or more parameters, in which case column values + are delivered to the function as separate arguments (mapping + to those parameters) for each row. For this to work the + `DataFrame` must have same number of columns and matching + data types. axis ({index (0), columns (1)}): Axis along which the function is applied. Specify 0 or 'index' to apply function to each column. Specify 1 or 'columns' to From 8753bdd1e44701e56eae914ebc0e91d9b1a6adf1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Swe=C3=B1a=20=28Swast=29?= Date: Fri, 2 Aug 2024 12:37:25 -0500 Subject: [PATCH 06/10] feat: create a separate OrderingModePartialPreviewWarning for more fine-grained warning filters (#879) --- bigframes/exceptions.py | 4 ++++ bigframes/session/__init__.py | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/bigframes/exceptions.py b/bigframes/exceptions.py index 6c5b66bc47..b1af96c9c4 100644 --- a/bigframes/exceptions.py +++ b/bigframes/exceptions.py @@ -47,6 +47,10 @@ class NullIndexError(ValueError): """Object has no index.""" +class OrderingModePartialPreviewWarning(PreviewWarning): + """Ordering mode 'partial' is in preview.""" + + class OrderRequiredError(ValueError): """Operation requires total row ordering to be enabled.""" diff --git a/bigframes/session/__init__.py b/bigframes/session/__init__.py index 233e6ef930..f449b52fbf 100644 --- a/bigframes/session/__init__.py +++ b/bigframes/session/__init__.py @@ -302,7 +302,7 @@ def __init__( if not self._strictly_ordered: warnings.warn( "Partial ordering mode is a preview feature and is subject to change.", - bigframes.exceptions.PreviewWarning, + bigframes.exceptions.OrderingModePartialPreviewWarning, ) # Sequential index needs total ordering to generate, so use null index with unstrict ordering. From 9606dac3303e4cb97dc679295db6576f644f438a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Swe=C3=B1a=20=28Swast=29?= Date: Fri, 2 Aug 2024 12:41:30 -0500 Subject: [PATCH 07/10] chore: move OrderingMode to enums module (#870) --- bigframes/_config/bigquery_options.py | 18 ++++++------------ bigframes/enums.py | 10 ++++++++++ 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/bigframes/_config/bigquery_options.py b/bigframes/_config/bigquery_options.py index 0506f1841e..34b9a3128f 100644 --- a/bigframes/_config/bigquery_options.py +++ b/bigframes/_config/bigquery_options.py @@ -16,7 +16,6 @@ from __future__ import annotations -from enum import Enum from typing import Literal, Optional import warnings @@ -25,14 +24,9 @@ import jellyfish import bigframes.constants +import bigframes.enums import bigframes.exceptions - -class OrderingMode(Enum): - STRICT = "strict" - PARTIAL = "partial" - - SESSION_STARTED_MESSAGE = ( "Cannot change '{attribute}' once a session has started. " "Call bigframes.pandas.close_session() first, if you are using the bigframes.pandas API." @@ -64,11 +58,11 @@ def _validate_location(value: Optional[str]): ) -def _validate_ordering_mode(value: str) -> OrderingMode: - if value.casefold() == OrderingMode.STRICT.value.casefold(): - return OrderingMode.STRICT - if value.casefold() == OrderingMode.PARTIAL.value.casefold(): - return OrderingMode.PARTIAL +def _validate_ordering_mode(value: str) -> bigframes.enums.OrderingMode: + if value.casefold() == bigframes.enums.OrderingMode.STRICT.value.casefold(): + return bigframes.enums.OrderingMode.STRICT + if value.casefold() == bigframes.enums.OrderingMode.PARTIAL.value.casefold(): + return bigframes.enums.OrderingMode.PARTIAL raise ValueError("Ordering mode must be one of 'strict' or 'partial'.") diff --git a/bigframes/enums.py b/bigframes/enums.py index 9501d3f13e..fd7b5545bb 100644 --- a/bigframes/enums.py +++ b/bigframes/enums.py @@ -20,6 +20,16 @@ import enum +class OrderingMode(enum.Enum): + """[Preview] Values used to determine the ordering mode. + + Default is 'strict'. + """ + + STRICT = "strict" + PARTIAL = "partial" + + class DefaultIndexKind(enum.Enum): """Sentinel values used to override default indexing behavior.""" From c415eb91eb71dea53d245ba2bce416062e3f02f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Swe=C3=B1a=20=28Swast=29?= Date: Mon, 5 Aug 2024 12:29:38 -0500 Subject: [PATCH 08/10] docs: create sample notebook using `ordering_mode="partial"` (#880) --- notebooks/dataframes/pypi.ipynb | 335 ++++++++++++++++++++++++++++++++ 1 file changed, 335 insertions(+) create mode 100644 notebooks/dataframes/pypi.ipynb diff --git a/notebooks/dataframes/pypi.ipynb b/notebooks/dataframes/pypi.ipynb new file mode 100644 index 0000000000..3022dc7173 --- /dev/null +++ b/notebooks/dataframes/pypi.ipynb @@ -0,0 +1,335 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [], + "source": [ + "# Copyright 2024 Google LLC\n", + "#\n", + "# Licensed under the Apache License, Version 2.0 (the \"License\");\n", + "# you may not use this file except in compliance with the License.\n", + "# You may obtain a copy of the License at\n", + "#\n", + "# https://www.apache.org/licenses/LICENSE-2.0\n", + "#\n", + "# Unless required by applicable law or agreed to in writing, software\n", + "# distributed under the License is distributed on an \"AS IS\" BASIS,\n", + "# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n", + "# See the License for the specific language governing permissions and\n", + "# limitations under the License." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Analyzing Python dependencies with BigQuery DataFrames\n", + "\n", + "In this notebook, you'll use the [PyPI public dataset](https://console.cloud.google.com/marketplace/product/gcp-public-data-pypi/pypi) and the [deps.dev public dataset](https://deps.dev/) to visualize Python package downloads for a package and its dependencies.\n", + "\n", + "> **âš  Important**\n", + ">\n", + "> You'll use features that are currently in [preview](https://cloud.google.com/blog/products/gcp/google-cloud-gets-simplified-product-launch-stages): `ordering_mode=\"partial\"` and \"NULL\" indexes. There may be breaking changes to this functionality in future versions of the BigQuery DataFrames package.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [], + "source": [ + "import bigframes.pandas as bpd\n", + "\n", + "# Preview feature warning:\n", + "# Use `ordering_mode=\"partial\"` for more efficient query generation, but\n", + "# some pandas-compatible methods may not be possible without a total ordering.\n", + "bpd.options.bigquery.ordering_mode = \"partial\"" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Filter out the relevant warnings for preview features used." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": {}, + "outputs": [], + "source": [ + "import warnings\n", + "\n", + "import bigframes.exceptions\n", + "\n", + "warnings.simplefilter(\"ignore\", category=bigframes.exceptions.NullIndexPreviewWarning)\n", + "warnings.simplefilter(\"ignore\", category=bigframes.exceptions.OrderingModePartialPreviewWarning)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Counting downloads and tracking dependencies\n", + "\n", + "The [PyPI `file_downloads`](https://console.cloud.google.com/bigquery?ws=!1m5!1m4!4m3!1sbigquery-public-data!2spypi!3sfile_downloads) table contains a row for each time there is a download request for a package. The [deps.dev Dependencies](https://console.cloud.google.com/bigquery?ws=!1m5!1m4!4m3!1sbigquery-public-data!2sdeps_dev_v1!3sDependencies) table contains a row for each dependency of each package.\n", + "\n", + "When `ordering_mode = \"partial\"`, `read_gbq_table` creates a DataFrame representing the table, but the DataFrame has no native ordering or index." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [], + "source": [ + "import bigframes.enums\n", + "\n", + "# Without ordering_mode = \"partial\" it is recommended that you set\n", + "# the \"filters\" parameter to limit the number of rows subsequent queries\n", + "# have to read.\n", + "pypi = bpd.read_gbq_table(\n", + " \"bigquery-public-data.pypi.file_downloads\",\n", + "\n", + " # Using ordering_mode = \"partial\" changes the default index to a \"NULL\"\n", + " # index, meaning no index is available for implicit joins.\n", + " #\n", + " # Setting this explicitly avoids a DefaultIndexWarning.\n", + " index_col=bigframes.enums.DefaultIndexKind.NULL,\n", + ")\n", + "deps = bpd.read_gbq_table(\n", + " \"bigquery-public-data.deps_dev_v1.Dependencies\",\n", + " index_col=bigframes.enums.DefaultIndexKind.NULL,\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Limit to the most recent 30 days of data\n", + "\n", + "The PyPI and deps.dev tables are partitioned by date. Query only the most recent 30 days of data to reduce the number of bytes scanned.\n", + "\n", + "Just as with the default ordering mode, filters can be describe in a pandas-compatible way by passing a Boolean Series to the DataFrame's `__getitem__` accessor." + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "metadata": {}, + "outputs": [], + "source": [ + "import datetime\n", + "\n", + "last_30_days = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=30)\n", + "pypi = pypi[pypi[\"timestamp\"] > last_30_days]\n", + "deps = deps[(deps[\"SnapshotAt\"] > last_30_days) & (deps[\"System\"] == \"PYPI\")]" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "\n", + "**âš  Warning**\n", + "\n", + "Without `ordering_mode = \"partial\"`, these filters do not change the number of bytes scanned. Instead, add column and row filters at \"read\" time. For example,\n", + "\n", + "```\n", + "import datetime\n", + "\n", + "last_30_days = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=30)\n", + "\n", + "# Without ordering_mode = \"partial\", one must limit the data at \"read\" time to reduce bytes scanned.\n", + "pypi = bpd.read_gbq_table(\n", + " \"bigquery-public-data.pypi.file_downloads\",\n", + " columns=[\"timestamp\", \"project\"],\n", + " filters=[(\"timestamp\", \">\", last_30_days)],\n", + ")\n", + "```" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Find dependencies for pandas\n", + "\n", + "Use assign to add columns to the DataFrame after a scalar operations, such as extracting a sub-field from a `STRUCT` column.\n", + "\n", + "Because the DataFrame has no index, this does not work if the new column belongs to a different table expression." + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "metadata": {}, + "outputs": [], + "source": [ + "deps = deps.assign(DependencyName=deps[\"Dependency\"].struct.field(\"Name\"))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Use an aggregation to identify the unique `DependencyName`s for the `pandas` package. Note: `drop_duplicates()` is not supported, as the order-based behavior such as `keep=\"first\"` is not applicable when using `ordering_mode = \"partial\"`.\n", + "\n", + "A DataFrame with no index still supports aggregation operations. Set `as_index=False` to keep the GROUP BY keys as regular columns, instead of turning them into an index." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "pandas_deps = deps[deps[\"Name\"] == \"pandas\"].groupby([\"Name\", \"DependencyName\"], as_index=False).size()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Count downloads for pandas and its dependencies\n", + "\n", + "The previous step created `pandas_deps` with all the dependencies of `pandas` but not pandas itself.\n", + "\n", + "Combine two DataFrames with the same column names with the `bigframes.pandas.concat` function." + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "metadata": {}, + "outputs": [], + "source": [ + "pandas_and_deps = bpd.concat(\n", + " [\n", + " pandas_deps.drop(columns=[\"Name\", \"size\"]).rename(columns={\"DependencyName\": \"Name\"}),\n", + " bpd.DataFrame({\"Name\": [\"pandas\"]}),\n", + " ],\n", + "\n", + " # To join DataFrames that have a NULL index, set ignore_index = True.\n", + " ignore_index=True,\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Since there is no index to implicitly join on, use the `merge` method to join two DataFrames by column name." + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "metadata": {}, + "outputs": [], + "source": [ + "pandas_pypi = pandas_and_deps.merge(pypi, how=\"inner\", left_on=\"Name\", right_on=\"project\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Create a time series to visualize by grouping by the date, extracted from the `timestamp` column." + ] + }, + { + "cell_type": "code", + "execution_count": 19, + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "Query job 5aa35b9c-459a-4b46-b70c-36e6418b61eb is DONE. 920.8 GB processed. Open Job" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "# When BigQuery DataFrames aggregates over columns, those columns provide a\n", + "# unique key post-aggregation that is used for ordering. By aggregating over\n", + "# a time series, the line plots will render in the expexted order.\n", + "pandas_pypi = pandas_pypi.assign(date=pandas_pypi[\"timestamp\"].dt.date)\n", + "downloads_per_day = pandas_pypi.groupby([\"date\", \"project\"]).size()\n", + "\n", + "# Convert to a pandas DataFrame for further transformation and visualization.\n", + "pd_df = downloads_per_day.to_pandas()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Once you've downloaded the time series with the `to_pandas()` method, you can use typical pandas methods to visualize the data." + ] + }, + { + "cell_type": "code", + "execution_count": 20, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 20, + "metadata": {}, + "output_type": "execute_result" + }, + { + "data": { + "image/png": "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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "pd_timeseries = pd_df.unstack()\n", + "pd_timeseries.plot.line(rot=45, ylabel=\"daily downloads\", ylim=(0, 2e7))" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "venv", + "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.9" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} From ca26fe5f9edec519788c276a09eaff33ecd87434 Mon Sep 17 00:00:00 2001 From: TrevorBergeron Date: Mon, 5 Aug 2024 10:37:27 -0700 Subject: [PATCH 09/10] feat: Allow windowing in 'partial' ordering mode (#861) --- bigframes/core/__init__.py | 26 +++++++++-- bigframes/core/blocks.py | 4 ++ bigframes/core/compile/compiled.py | 2 +- bigframes/core/groupby/__init__.py | 38 ++++++++-------- bigframes/core/indexes/base.py | 8 ++-- bigframes/core/nodes.py | 42 +++++++++++++++++ bigframes/core/validations.py | 17 ++++++- bigframes/dataframe.py | 54 +++++++++++----------- bigframes/exceptions.py | 4 ++ bigframes/series.py | 52 ++++++++++----------- bigframes/session/__init__.py | 5 ++ tests/system/small/test_dataframe.py | 2 +- tests/system/small/test_unordered.py | 68 +++++++++++++++------------- 13 files changed, 205 insertions(+), 117 deletions(-) diff --git a/bigframes/core/__init__.py b/bigframes/core/__init__.py index aa66129572..2e9b5fa994 100644 --- a/bigframes/core/__init__.py +++ b/bigframes/core/__init__.py @@ -194,8 +194,17 @@ def promote_offsets(self, col_id: str) -> ArrayValue: """ Convenience function to promote copy of column offsets to a value column. Can be used to reset index. """ - if self.node.order_ambiguous and not self.session._strictly_ordered: - raise ValueError("Generating offsets not supported in unordered mode") + if self.node.order_ambiguous and not (self.session._strictly_ordered): + if not self.session._allows_ambiguity: + raise ValueError( + "Generating offsets not supported in partial ordering mode" + ) + else: + warnings.warn( + "Window ordering may be ambiguous, this can cause unstable results.", + bigframes.exceptions.AmbiguousWindowWarning, + ) + return ArrayValue(nodes.PromoteOffsetsNode(child=self.node, col_id=col_id)) def concat(self, other: typing.Sequence[ArrayValue]) -> ArrayValue: @@ -347,9 +356,16 @@ def project_window_op( # TODO: Support non-deterministic windowing if window_spec.row_bounded or not op.order_independent: if self.node.order_ambiguous and not self.session._strictly_ordered: - raise ValueError( - "Order-dependent windowed ops not supported in unordered mode" - ) + if not self.session._allows_ambiguity: + raise ValueError( + "Generating offsets not supported in partial ordering mode" + ) + else: + warnings.warn( + "Window ordering may be ambiguous, this can cause unstable results.", + bigframes.exceptions.AmbiguousWindowWarning, + ) + return ArrayValue( nodes.WindowOpNode( child=self.node, diff --git a/bigframes/core/blocks.py b/bigframes/core/blocks.py index 1b7b231403..65a89b4516 100644 --- a/bigframes/core/blocks.py +++ b/bigframes/core/blocks.py @@ -280,6 +280,10 @@ def index_name_to_col_id(self) -> typing.Mapping[Label, typing.Sequence[str]]: mapping[label] = (*mapping.get(label, ()), id) return mapping + @property + def explicitly_ordered(self) -> bool: + return self.expr.node.explicitly_ordered + def cols_matching_label(self, partial_label: Label) -> typing.Sequence[str]: """ Unlike label_to_col_id, this works with partial labels for multi-index. diff --git a/bigframes/core/compile/compiled.py b/bigframes/core/compile/compiled.py index c822dd331c..538789f9d7 100644 --- a/bigframes/core/compile/compiled.py +++ b/bigframes/core/compile/compiled.py @@ -263,7 +263,7 @@ def to_sql( ordered: bool = False, ) -> str: if offset_column or ordered: - raise ValueError("Cannot produce sorted sql in unordered mode") + raise ValueError("Cannot produce sorted sql in partial ordering mode") sql = ibis_bigquery.Backend().compile( self._to_ibis_expr( col_id_overrides=col_id_overrides, diff --git a/bigframes/core/groupby/__init__.py b/bigframes/core/groupby/__init__.py index 02bf201ca0..2b80d0389e 100644 --- a/bigframes/core/groupby/__init__.py +++ b/bigframes/core/groupby/__init__.py @@ -109,7 +109,7 @@ def __getitem__( dropna=self._dropna, ) - @validations.requires_strict_ordering() + @validations.requires_ordering() def head(self, n: int = 5) -> df.DataFrame: block = self._block if self._dropna: @@ -235,25 +235,25 @@ def count(self) -> df.DataFrame: def nunique(self) -> df.DataFrame: return self._aggregate_all(agg_ops.nunique_op) - @validations.requires_strict_ordering() + @validations.requires_ordering() def cumsum(self, *args, numeric_only: bool = False, **kwargs) -> df.DataFrame: if not numeric_only: self._raise_on_non_numeric("cumsum") return self._apply_window_op(agg_ops.sum_op, numeric_only=True) - @validations.requires_strict_ordering() + @validations.requires_ordering() def cummin(self, *args, numeric_only: bool = False, **kwargs) -> df.DataFrame: return self._apply_window_op(agg_ops.min_op, numeric_only=numeric_only) - @validations.requires_strict_ordering() + @validations.requires_ordering() def cummax(self, *args, numeric_only: bool = False, **kwargs) -> df.DataFrame: return self._apply_window_op(agg_ops.max_op, numeric_only=numeric_only) - @validations.requires_strict_ordering() + @validations.requires_ordering() def cumprod(self, *args, **kwargs) -> df.DataFrame: return self._apply_window_op(agg_ops.product_op, numeric_only=True) - @validations.requires_strict_ordering() + @validations.requires_ordering() def shift(self, periods=1) -> series.Series: window = window_specs.rows( grouping_keys=tuple(self._by_col_ids), @@ -262,7 +262,7 @@ def shift(self, periods=1) -> series.Series: ) return self._apply_window_op(agg_ops.ShiftOp(periods), window=window) - @validations.requires_strict_ordering() + @validations.requires_ordering() def diff(self, periods=1) -> series.Series: window = window_specs.rows( grouping_keys=tuple(self._by_col_ids), @@ -271,7 +271,7 @@ def diff(self, periods=1) -> series.Series: ) return self._apply_window_op(agg_ops.DiffOp(periods), window=window) - @validations.requires_strict_ordering() + @validations.requires_ordering() def rolling(self, window: int, min_periods=None) -> windows.Window: # To get n size window, need current row and n-1 preceding rows. window_spec = window_specs.rows( @@ -287,7 +287,7 @@ def rolling(self, window: int, min_periods=None) -> windows.Window: block, window_spec, self._selected_cols, drop_null_groups=self._dropna ) - @validations.requires_strict_ordering() + @validations.requires_ordering() def expanding(self, min_periods: int = 1) -> windows.Window: window_spec = window_specs.cumulative_rows( grouping_keys=tuple(self._by_col_ids), @@ -532,7 +532,7 @@ def __init__( def _session(self) -> core.Session: return self._block.session - @validations.requires_strict_ordering() + @validations.requires_ordering() def head(self, n: int = 5) -> series.Series: block = self._block if self._dropna: @@ -650,31 +650,31 @@ def agg(self, func=None) -> typing.Union[df.DataFrame, series.Series]: aggregate = agg - @validations.requires_strict_ordering() + @validations.requires_ordering() def cumsum(self, *args, **kwargs) -> series.Series: return self._apply_window_op( agg_ops.sum_op, ) - @validations.requires_strict_ordering() + @validations.requires_ordering() def cumprod(self, *args, **kwargs) -> series.Series: return self._apply_window_op( agg_ops.product_op, ) - @validations.requires_strict_ordering() + @validations.requires_ordering() def cummax(self, *args, **kwargs) -> series.Series: return self._apply_window_op( agg_ops.max_op, ) - @validations.requires_strict_ordering() + @validations.requires_ordering() def cummin(self, *args, **kwargs) -> series.Series: return self._apply_window_op( agg_ops.min_op, ) - @validations.requires_strict_ordering() + @validations.requires_ordering() def cumcount(self, *args, **kwargs) -> series.Series: return ( self._apply_window_op( @@ -684,7 +684,7 @@ def cumcount(self, *args, **kwargs) -> series.Series: - 1 ) - @validations.requires_strict_ordering() + @validations.requires_ordering() def shift(self, periods=1) -> series.Series: """Shift index by desired number of periods.""" window = window_specs.rows( @@ -694,7 +694,7 @@ def shift(self, periods=1) -> series.Series: ) return self._apply_window_op(agg_ops.ShiftOp(periods), window=window) - @validations.requires_strict_ordering() + @validations.requires_ordering() def diff(self, periods=1) -> series.Series: window = window_specs.rows( grouping_keys=tuple(self._by_col_ids), @@ -703,7 +703,7 @@ def diff(self, periods=1) -> series.Series: ) return self._apply_window_op(agg_ops.DiffOp(periods), window=window) - @validations.requires_strict_ordering() + @validations.requires_ordering() def rolling(self, window: int, min_periods=None) -> windows.Window: # To get n size window, need current row and n-1 preceding rows. window_spec = window_specs.rows( @@ -723,7 +723,7 @@ def rolling(self, window: int, min_periods=None) -> windows.Window: is_series=True, ) - @validations.requires_strict_ordering() + @validations.requires_ordering() def expanding(self, min_periods: int = 1) -> windows.Window: window_spec = window_specs.cumulative_rows( grouping_keys=tuple(self._by_col_ids), diff --git a/bigframes/core/indexes/base.py b/bigframes/core/indexes/base.py index 8b039707c2..0376e37f96 100644 --- a/bigframes/core/indexes/base.py +++ b/bigframes/core/indexes/base.py @@ -184,7 +184,7 @@ def empty(self) -> bool: return self.shape[0] == 0 @property - @validations.requires_strict_ordering() + @validations.requires_ordering() def is_monotonic_increasing(self) -> bool: """ Return a boolean if the values are equal or increasing. @@ -198,7 +198,7 @@ def is_monotonic_increasing(self) -> bool: ) @property - @validations.requires_strict_ordering() + @validations.requires_ordering() def is_monotonic_decreasing(self) -> bool: """ Return a boolean if the values are equal or decreasing. @@ -348,7 +348,7 @@ def max(self) -> typing.Any: def min(self) -> typing.Any: return self._apply_aggregation(agg_ops.min_op) - @validations.requires_strict_ordering() + @validations.requires_ordering() def argmax(self) -> int: block, row_nums = self._block.promote_offsets() block = block.order_by( @@ -361,7 +361,7 @@ def argmax(self) -> int: return typing.cast(int, series.Series(block.select_column(row_nums)).iloc[0]) - @validations.requires_strict_ordering() + @validations.requires_ordering() def argmin(self) -> int: block, row_nums = self._block.promote_offsets() block = block.order_by( diff --git a/bigframes/core/nodes.py b/bigframes/core/nodes.py index a979e07972..30edc7740a 100644 --- a/bigframes/core/nodes.py +++ b/bigframes/core/nodes.py @@ -135,6 +135,14 @@ def order_ambiguous(self) -> bool: """ ... + @property + @abc.abstractmethod + def explicitly_ordered(self) -> bool: + """ + Whether row ordering is potentially ambiguous. For example, ReadTable (without a primary key) could be ordered in different ways. + """ + ... + @functools.cached_property def total_variables(self) -> int: return self.variables_introduced + sum( @@ -180,6 +188,10 @@ def child_nodes(self) -> typing.Sequence[BigFrameNode]: def schema(self) -> schemata.ArraySchema: return self.child.schema + @property + def explicitly_ordered(self) -> bool: + return self.child.explicitly_ordered + def transform_children( self, t: Callable[[BigFrameNode], BigFrameNode] ) -> BigFrameNode: @@ -212,6 +224,10 @@ def child_nodes(self) -> typing.Sequence[BigFrameNode]: def order_ambiguous(self) -> bool: return True + @property + def explicitly_ordered(self) -> bool: + return False + def __hash__(self): return self._node_hash @@ -267,6 +283,10 @@ def child_nodes(self) -> typing.Sequence[BigFrameNode]: def order_ambiguous(self) -> bool: return any(child.order_ambiguous for child in self.children) + @property + def explicitly_ordered(self) -> bool: + return all(child.explicitly_ordered for child in self.children) + def __hash__(self): return self._node_hash @@ -317,6 +337,10 @@ def variables_introduced(self) -> int: def order_ambiguous(self) -> bool: return False + @property + def explicitly_ordered(self) -> bool: + return True + def transform_children( self, t: Callable[[BigFrameNode], BigFrameNode] ) -> BigFrameNode: @@ -378,6 +402,10 @@ def relation_ops_created(self) -> int: def order_ambiguous(self) -> bool: return len(self.total_order_cols) == 0 + @property + def explicitly_ordered(self) -> bool: + return len(self.total_order_cols) > 0 + @functools.cached_property def variables_introduced(self) -> int: return len(self.schema.items) + 1 @@ -449,6 +477,12 @@ def hidden_columns(self) -> typing.Tuple[str, ...]: def order_ambiguous(self) -> bool: return not isinstance(self.ordering, orderings.TotalOrdering) + @property + def explicitly_ordered(self) -> bool: + return (self.ordering is not None) and len( + self.ordering.all_ordering_columns + ) > 0 + def transform_children( self, t: Callable[[BigFrameNode], BigFrameNode] ) -> BigFrameNode: @@ -523,6 +557,10 @@ def relation_ops_created(self) -> int: # Doesnt directly create any relational operations return 0 + @property + def explicitly_ordered(self) -> bool: + return True + @dataclass(frozen=True) class ReversedNode(UnaryNode): @@ -636,6 +674,10 @@ def variables_introduced(self) -> int: def order_ambiguous(self) -> bool: return False + @property + def explicitly_ordered(self) -> bool: + return True + @dataclass(frozen=True) class WindowOpNode(UnaryNode): diff --git a/bigframes/core/validations.py b/bigframes/core/validations.py index c5761f4e09..9c03ddb930 100644 --- a/bigframes/core/validations.py +++ b/bigframes/core/validations.py @@ -24,6 +24,7 @@ if TYPE_CHECKING: from bigframes import Session + from bigframes.core.blocks import Block class HasSession(Protocol): @@ -31,8 +32,12 @@ class HasSession(Protocol): def _session(self) -> Session: ... + @property + def _block(self) -> Block: + ... + -def requires_strict_ordering(suggestion: Optional[str] = None): +def requires_ordering(suggestion: Optional[str] = None): def decorator(meth): @functools.wraps(meth) def guarded_meth(object: HasSession, *args, **kwargs): @@ -47,8 +52,16 @@ def guarded_meth(object: HasSession, *args, **kwargs): def enforce_ordered( object: HasSession, opname: str, suggestion: Optional[str] = None ) -> None: - if not object._session._strictly_ordered: + session = object._session + if session._strictly_ordered or not object._block.expr.node.order_ambiguous: + # No ambiguity for how to calculate ordering, so no error or warning + return None + if not session._allows_ambiguity: suggestion_substr = suggestion + " " if suggestion else "" raise bigframes.exceptions.OrderRequiredError( f"Op {opname} not supported when strict ordering is disabled. {suggestion_substr}{bigframes.constants.FEEDBACK_LINK}" ) + if not object._block.explicitly_ordered: + raise bigframes.exceptions.OrderRequiredError( + f"Op {opname} requires an ordering. Use .sort_values or .sort_index to provide an ordering. {bigframes.constants.FEEDBACK_LINK}" + ) diff --git a/bigframes/dataframe.py b/bigframes/dataframe.py index 9d3b153d3a..649b097e92 100644 --- a/bigframes/dataframe.py +++ b/bigframes/dataframe.py @@ -282,12 +282,12 @@ def loc(self) -> indexers.LocDataFrameIndexer: return indexers.LocDataFrameIndexer(self) @property - @validations.requires_strict_ordering() + @validations.requires_ordering() def iloc(self) -> indexers.ILocDataFrameIndexer: return indexers.ILocDataFrameIndexer(self) @property - @validations.requires_strict_ordering() + @validations.requires_ordering() def iat(self) -> indexers.IatDataFrameIndexer: return indexers.IatDataFrameIndexer(self) @@ -344,12 +344,12 @@ def _has_index(self) -> bool: return len(self._block.index_columns) > 0 @property - @validations.requires_strict_ordering() + @validations.requires_ordering() def T(self) -> DataFrame: return DataFrame(self._get_block().transpose()) @requires_index - @validations.requires_strict_ordering() + @validations.requires_ordering() def transpose(self) -> DataFrame: return self.T @@ -1296,11 +1296,11 @@ def _compute_dry_run(self) -> bigquery.QueryJob: def copy(self) -> DataFrame: return DataFrame(self._block) - @validations.requires_strict_ordering(bigframes.constants.SUGGEST_PEEK_PREVIEW) + @validations.requires_ordering(bigframes.constants.SUGGEST_PEEK_PREVIEW) def head(self, n: int = 5) -> DataFrame: return typing.cast(DataFrame, self.iloc[:n]) - @validations.requires_strict_ordering() + @validations.requires_ordering() def tail(self, n: int = 5) -> DataFrame: return typing.cast(DataFrame, self.iloc[-n:]) @@ -1540,7 +1540,7 @@ def rename_axis( labels = [mapper] return DataFrame(self._block.with_index_labels(labels)) - @validations.requires_strict_ordering() + @validations.requires_ordering() def equals(self, other: typing.Union[bigframes.series.Series, DataFrame]) -> bool: # Must be same object type, same column dtypes, and same label values if not isinstance(other, DataFrame): @@ -1938,7 +1938,7 @@ def _reindex_columns(self, columns): def reindex_like(self, other: DataFrame, *, validate: typing.Optional[bool] = None): return self.reindex(index=other.index, columns=other.columns, validate=validate) - @validations.requires_strict_ordering() + @validations.requires_ordering() @requires_index def interpolate(self, method: str = "linear") -> DataFrame: if method == "pad": @@ -1964,12 +1964,12 @@ def replace( lambda x: x.replace(to_replace=to_replace, value=value, regex=regex) ) - @validations.requires_strict_ordering() + @validations.requires_ordering() def ffill(self, *, limit: typing.Optional[int] = None) -> DataFrame: window = window_spec.rows(preceding=limit, following=0) return self._apply_window_op(agg_ops.LastNonNullOp(), window) - @validations.requires_strict_ordering() + @validations.requires_ordering() def bfill(self, *, limit: typing.Optional[int] = None) -> DataFrame: window = window_spec.rows(preceding=0, following=limit) return self._apply_window_op(agg_ops.FirstNonNullOp(), window) @@ -2235,16 +2235,16 @@ def agg( aggregate.__doc__ = inspect.getdoc(vendored_pandas_frame.DataFrame.agg) @requires_index - @validations.requires_strict_ordering() + @validations.requires_ordering() def idxmin(self) -> bigframes.series.Series: return bigframes.series.Series(block_ops.idxmin(self._block)) @requires_index - @validations.requires_strict_ordering() + @validations.requires_ordering() def idxmax(self) -> bigframes.series.Series: return bigframes.series.Series(block_ops.idxmax(self._block)) - @validations.requires_strict_ordering() + @validations.requires_ordering() def melt( self, id_vars: typing.Optional[typing.Iterable[typing.Hashable]] = None, @@ -2349,7 +2349,7 @@ def _pivot( return DataFrame(pivot_block) @requires_index - @validations.requires_strict_ordering() + @validations.requires_ordering() def pivot( self, *, @@ -2364,7 +2364,7 @@ def pivot( return self._pivot(columns=columns, index=index, values=values) @requires_index - @validations.requires_strict_ordering() + @validations.requires_ordering() def pivot_table( self, values: typing.Optional[ @@ -2464,7 +2464,7 @@ def _stack_multi(self, level: LevelsType = -1): return DataFrame(block) @requires_index - @validations.requires_strict_ordering() + @validations.requires_ordering() def unstack(self, level: LevelsType = -1): if not utils.is_list_like(level): level = [level] @@ -2675,7 +2675,7 @@ def _perform_join_by_index( block, _ = self._block.join(other._block, how=how, block_identity_join=True) return DataFrame(block) - @validations.requires_strict_ordering() + @validations.requires_ordering() def rolling(self, window: int, min_periods=None) -> bigframes.core.window.Window: # To get n size window, need current row and n-1 preceding rows. window_def = window_spec.rows( @@ -2685,7 +2685,7 @@ def rolling(self, window: int, min_periods=None) -> bigframes.core.window.Window self._block, window_def, self._block.value_columns ) - @validations.requires_strict_ordering() + @validations.requires_ordering() def expanding(self, min_periods: int = 1) -> bigframes.core.window.Window: window = window_spec.cumulative_rows(min_periods=min_periods) return bigframes.core.window.Window( @@ -2788,7 +2788,7 @@ def notna(self) -> DataFrame: notnull = notna notnull.__doc__ = inspect.getdoc(vendored_pandas_frame.DataFrame.notna) - @validations.requires_strict_ordering() + @validations.requires_ordering() def cumsum(self): is_numeric_types = [ (dtype in bigframes.dtypes.NUMERIC_BIGFRAMES_TYPES_PERMISSIVE) @@ -2801,7 +2801,7 @@ def cumsum(self): window_spec.cumulative_rows(), ) - @validations.requires_strict_ordering() + @validations.requires_ordering() def cumprod(self) -> DataFrame: is_numeric_types = [ (dtype in bigframes.dtypes.NUMERIC_BIGFRAMES_TYPES_PERMISSIVE) @@ -2814,21 +2814,21 @@ def cumprod(self) -> DataFrame: window_spec.cumulative_rows(), ) - @validations.requires_strict_ordering() + @validations.requires_ordering() def cummin(self) -> DataFrame: return self._apply_window_op( agg_ops.min_op, window_spec.cumulative_rows(), ) - @validations.requires_strict_ordering() + @validations.requires_ordering() def cummax(self) -> DataFrame: return self._apply_window_op( agg_ops.max_op, window_spec.cumulative_rows(), ) - @validations.requires_strict_ordering() + @validations.requires_ordering() def shift(self, periods: int = 1) -> DataFrame: window = window_spec.rows( preceding=periods if periods > 0 else None, @@ -2836,7 +2836,7 @@ def shift(self, periods: int = 1) -> DataFrame: ) return self._apply_window_op(agg_ops.ShiftOp(periods), window) - @validations.requires_strict_ordering() + @validations.requires_ordering() def diff(self, periods: int = 1) -> DataFrame: window = window_spec.rows( preceding=periods if periods > 0 else None, @@ -2844,7 +2844,7 @@ def diff(self, periods: int = 1) -> DataFrame: ) return self._apply_window_op(agg_ops.DiffOp(periods), window) - @validations.requires_strict_ordering() + @validations.requires_ordering() def pct_change(self, periods: int = 1) -> DataFrame: # Future versions of pandas will not perfrom ffill automatically df = self.ffill() @@ -2862,7 +2862,7 @@ def _apply_window_op( ) return DataFrame(block.select_columns(result_ids)) - @validations.requires_strict_ordering() + @validations.requires_ordering() def sample( self, n: Optional[int] = None, @@ -3678,7 +3678,7 @@ def _optimize_query_complexity(self): _DataFrameOrSeries = typing.TypeVar("_DataFrameOrSeries") - @validations.requires_strict_ordering() + @validations.requires_ordering() def dot(self, other: _DataFrameOrSeries) -> _DataFrameOrSeries: if not isinstance(other, (DataFrame, bf_series.Series)): raise NotImplementedError( diff --git a/bigframes/exceptions.py b/bigframes/exceptions.py index b1af96c9c4..00abb887b0 100644 --- a/bigframes/exceptions.py +++ b/bigframes/exceptions.py @@ -63,5 +63,9 @@ class TimeTravelDisabledWarning(Warning): """A query was reattempted without time travel.""" +class AmbiguousWindowWarning(Warning): + """A query may produce nondeterministic results as the window may be ambiguously ordered.""" + + class UnknownDataTypeWarning(Warning): """Data type is unknown.""" diff --git a/bigframes/series.py b/bigframes/series.py index 9e33801834..d41553d0d7 100644 --- a/bigframes/series.py +++ b/bigframes/series.py @@ -93,12 +93,12 @@ def loc(self) -> bigframes.core.indexers.LocSeriesIndexer: return bigframes.core.indexers.LocSeriesIndexer(self) @property - @validations.requires_strict_ordering() + @validations.requires_ordering() def iloc(self) -> bigframes.core.indexers.IlocSeriesIndexer: return bigframes.core.indexers.IlocSeriesIndexer(self) @property - @validations.requires_strict_ordering() + @validations.requires_ordering() def iat(self) -> bigframes.core.indexers.IatSeriesIndexer: return bigframes.core.indexers.IatSeriesIndexer(self) @@ -163,7 +163,7 @@ def struct(self) -> structs.StructAccessor: return structs.StructAccessor(self._block) @property - @validations.requires_strict_ordering() + @validations.requires_ordering() def T(self) -> Series: return self.transpose() @@ -175,7 +175,7 @@ def _info_axis(self) -> indexes.Index: def _session(self) -> bigframes.Session: return self._get_block().expr.session - @validations.requires_strict_ordering() + @validations.requires_ordering() def transpose(self) -> Series: return self @@ -271,7 +271,7 @@ def equals( return False return block_ops.equals(self._block, other._block) - @validations.requires_strict_ordering() + @validations.requires_ordering() def reset_index( self, *, @@ -459,13 +459,13 @@ def case_when(self, caselist) -> Series: ignore_self=True, ) - @validations.requires_strict_ordering() + @validations.requires_ordering() def cumsum(self) -> Series: return self._apply_window_op( agg_ops.sum_op, bigframes.core.window_spec.cumulative_rows() ) - @validations.requires_strict_ordering() + @validations.requires_ordering() def ffill(self, *, limit: typing.Optional[int] = None) -> Series: window = bigframes.core.window_spec.rows(preceding=limit, following=0) return self._apply_window_op(agg_ops.LastNonNullOp(), window) @@ -473,30 +473,30 @@ def ffill(self, *, limit: typing.Optional[int] = None) -> Series: pad = ffill pad.__doc__ = inspect.getdoc(vendored_pandas_series.Series.ffill) - @validations.requires_strict_ordering() + @validations.requires_ordering() def bfill(self, *, limit: typing.Optional[int] = None) -> Series: window = bigframes.core.window_spec.rows(preceding=0, following=limit) return self._apply_window_op(agg_ops.FirstNonNullOp(), window) - @validations.requires_strict_ordering() + @validations.requires_ordering() def cummax(self) -> Series: return self._apply_window_op( agg_ops.max_op, bigframes.core.window_spec.cumulative_rows() ) - @validations.requires_strict_ordering() + @validations.requires_ordering() def cummin(self) -> Series: return self._apply_window_op( agg_ops.min_op, bigframes.core.window_spec.cumulative_rows() ) - @validations.requires_strict_ordering() + @validations.requires_ordering() def cumprod(self) -> Series: return self._apply_window_op( agg_ops.product_op, bigframes.core.window_spec.cumulative_rows() ) - @validations.requires_strict_ordering() + @validations.requires_ordering() def shift(self, periods: int = 1) -> Series: window = bigframes.core.window_spec.rows( preceding=periods if periods > 0 else None, @@ -504,7 +504,7 @@ def shift(self, periods: int = 1) -> Series: ) return self._apply_window_op(agg_ops.ShiftOp(periods), window) - @validations.requires_strict_ordering() + @validations.requires_ordering() def diff(self, periods: int = 1) -> Series: window = bigframes.core.window_spec.rows( preceding=periods if periods > 0 else None, @@ -512,13 +512,13 @@ def diff(self, periods: int = 1) -> Series: ) return self._apply_window_op(agg_ops.DiffOp(periods), window) - @validations.requires_strict_ordering() + @validations.requires_ordering() def pct_change(self, periods: int = 1) -> Series: # Future versions of pandas will not perfrom ffill automatically series = self.ffill() return Series(block_ops.pct_change(series._block, periods=periods)) - @validations.requires_strict_ordering() + @validations.requires_ordering() def rank( self, axis=0, @@ -610,7 +610,7 @@ def _mapping_replace(self, mapping: dict[typing.Hashable, typing.Hashable]): ) return Series(block.select_column(result)) - @validations.requires_strict_ordering() + @validations.requires_ordering() @requires_index def interpolate(self, method: str = "linear") -> Series: if method == "pad": @@ -633,11 +633,11 @@ def dropna( result = result.reset_index() return Series(result) - @validations.requires_strict_ordering(bigframes.constants.SUGGEST_PEEK_PREVIEW) + @validations.requires_ordering(bigframes.constants.SUGGEST_PEEK_PREVIEW) def head(self, n: int = 5) -> Series: return typing.cast(Series, self.iloc[0:n]) - @validations.requires_strict_ordering() + @validations.requires_ordering() def tail(self, n: int = 5) -> Series: return typing.cast(Series, self.iloc[-n:]) @@ -1138,7 +1138,7 @@ def clip(self, lower, upper): ) return Series(block.select_column(result_id).with_column_labels([self.name])) - @validations.requires_strict_ordering() + @validations.requires_ordering() def argmax(self) -> int: block, row_nums = self._block.promote_offsets() block = block.order_by( @@ -1151,7 +1151,7 @@ def argmax(self) -> int: scalars.Scalar, Series(block.select_column(row_nums)).iloc[0] ) - @validations.requires_strict_ordering() + @validations.requires_ordering() def argmin(self) -> int: block, row_nums = self._block.promote_offsets() block = block.order_by( @@ -1217,14 +1217,14 @@ def idxmin(self) -> blocks.Label: return indexes.Index(block).to_pandas()[0] @property - @validations.requires_strict_ordering() + @validations.requires_ordering() def is_monotonic_increasing(self) -> bool: return typing.cast( bool, self._block.is_monotonic_increasing(self._value_column) ) @property - @validations.requires_strict_ordering() + @validations.requires_ordering() def is_monotonic_decreasing(self) -> bool: return typing.cast( bool, self._block.is_monotonic_decreasing(self._value_column) @@ -1332,7 +1332,7 @@ def sort_index(self, *, axis=0, ascending=True, na_position="last") -> Series: block = block.order_by(ordering) return Series(block) - @validations.requires_strict_ordering() + @validations.requires_ordering() def rolling(self, window: int, min_periods=None) -> bigframes.core.window.Window: # To get n size window, need current row and n-1 preceding rows. window_spec = bigframes.core.window_spec.rows( @@ -1342,7 +1342,7 @@ def rolling(self, window: int, min_periods=None) -> bigframes.core.window.Window self._block, window_spec, self._block.value_columns, is_series=True ) - @validations.requires_strict_ordering() + @validations.requires_ordering() def expanding(self, min_periods: int = 1) -> bigframes.core.window.Window: window_spec = bigframes.core.window_spec.cumulative_rows( min_periods=min_periods @@ -1615,7 +1615,7 @@ def drop_duplicates(self, *, keep: str = "first") -> Series: block = block_ops.drop_duplicates(self._block, (self._value_column,), keep) return Series(block) - @validations.requires_strict_ordering() + @validations.requires_ordering() def unique(self) -> Series: return self.drop_duplicates() @@ -1806,7 +1806,7 @@ def map( result_df = self_df.join(map_df, on="series") return result_df[self.name] - @validations.requires_strict_ordering() + @validations.requires_ordering() def sample( self, n: Optional[int] = None, diff --git a/bigframes/session/__init__.py b/bigframes/session/__init__.py index f449b52fbf..dc1da488a1 100644 --- a/bigframes/session/__init__.py +++ b/bigframes/session/__init__.py @@ -314,6 +314,7 @@ def __init__( self._compiler = bigframes.core.compile.SQLCompiler( strict=self._strictly_ordered ) + self._allow_ambiguity = not self._strictly_ordered self._remote_function_session = bigframes_rf._RemoteFunctionSession() @@ -378,6 +379,10 @@ def slot_millis_sum(self): """The sum of all slot time used by bigquery jobs in this session.""" return self._slot_millis_sum + @property + def _allows_ambiguity(self) -> bool: + return self._allow_ambiguity + def _add_bytes_processed(self, amount: int): """Increment bytes_processed_sum by amount.""" self._bytes_processed_sum += amount diff --git a/tests/system/small/test_dataframe.py b/tests/system/small/test_dataframe.py index 3a7eff621f..d838251dca 100644 --- a/tests/system/small/test_dataframe.py +++ b/tests/system/small/test_dataframe.py @@ -2273,7 +2273,7 @@ def test_series_binop_add_different_table( def test_join_same_table(scalars_dfs_maybe_ordered, how): bf_df, pd_df = scalars_dfs_maybe_ordered if not bf_df._session._strictly_ordered and how == "cross": - pytest.skip("Cross join not supported in unordered mode.") + pytest.skip("Cross join not supported in partial ordering mode.") bf_df_a = bf_df.set_index("int64_too")[["string_col", "int64_col"]] bf_df_a = bf_df_a.sort_index() diff --git a/tests/system/small/test_unordered.py b/tests/system/small/test_unordered.py index 7d7097ceb3..9f85ec99f9 100644 --- a/tests/system/small/test_unordered.py +++ b/tests/system/small/test_unordered.py @@ -11,6 +11,8 @@ # 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 warnings + import pandas as pd import pyarrow as pa import pytest @@ -99,7 +101,6 @@ def test_unordered_mode_read_gbq(unordered_session): [ pytest.param( "first", - marks=pytest.mark.xfail(raises=bigframes.exceptions.OrderRequiredError), ), pytest.param( False, @@ -138,37 +139,6 @@ def test_unordered_merge(unordered_session): assert_pandas_df_equal(bf_result.to_pandas(), pd_result, ignore_order=True) -@pytest.mark.parametrize( - ("function"), - [ - pytest.param( - lambda x: x.cumsum(), - id="cumsum", - ), - pytest.param( - lambda x: x.idxmin(), - id="idxmin", - ), - pytest.param( - lambda x: x.a.iloc[1::2], - id="series_iloc", - ), - pytest.param( - lambda x: x.head(3), - id="head", - ), - ], -) -def test_unordered_mode_blocks_windowing(unordered_session, function): - pd_df = pd.DataFrame({"a": [1, 2, 3], "b": [4, 5, 6]}, dtype=pd.Int64Dtype()) - df = bpd.DataFrame(pd_df, session=unordered_session) - with pytest.raises( - bigframes.exceptions.OrderRequiredError, - match=r"Op.*not supported when strict ordering is disabled", - ): - function(df) - - def test_unordered_mode_cache_preserves_order(unordered_session): pd_df = pd.DataFrame( {"a": [1, 2, 3, 4, 5, 6], "b": [4, 5, 9, 3, 1, 6]}, dtype=pd.Int64Dtype() @@ -181,3 +151,37 @@ def test_unordered_mode_cache_preserves_order(unordered_session): # B is unique so unstrict order mode result here should be equivalent to strictly ordered assert_pandas_df_equal(bf_result, pd_result, ignore_order=False) + + +def test_unordered_mode_no_ordering_error(unordered_session): + pd_df = pd.DataFrame( + {"a": [1, 2, 3, 4, 5, 1], "b": [4, 5, 9, 3, 1, 6]}, dtype=pd.Int64Dtype() + ) + pd_df.index = pd_df.index.astype(pd.Int64Dtype()) + df = bpd.DataFrame(pd_df, session=unordered_session) + + with pytest.raises(bigframes.exceptions.OrderRequiredError): + df.merge(df, on="a").head(3) + + +def test_unordered_mode_ambiguity_warning(unordered_session): + pd_df = pd.DataFrame( + {"a": [1, 2, 3, 4, 5, 1], "b": [4, 5, 9, 3, 1, 6]}, dtype=pd.Int64Dtype() + ) + pd_df.index = pd_df.index.astype(pd.Int64Dtype()) + df = bpd.DataFrame(pd_df, session=unordered_session) + + with pytest.warns(bigframes.exceptions.AmbiguousWindowWarning): + df.merge(df, on="a").sort_values("b_x").head(3) + + +def test_unordered_mode_no_ambiguity_warning(unordered_session): + pd_df = pd.DataFrame( + {"a": [1, 2, 3, 4, 5, 1], "b": [4, 5, 9, 3, 1, 6]}, dtype=pd.Int64Dtype() + ) + pd_df.index = pd_df.index.astype(pd.Int64Dtype()) + df = bpd.DataFrame(pd_df, session=unordered_session) + + with warnings.catch_warnings(): + warnings.simplefilter("error") + df.groupby("a").head(3) From 5317327f8bf7751688f3ad4cc0c96f719cf2b062 Mon Sep 17 00:00:00 2001 From: "release-please[bot]" <55107282+release-please[bot]@users.noreply.github.com> Date: Mon, 5 Aug 2024 15:43:08 -0700 Subject: [PATCH 10/10] chore(main): release 1.13.0 (#876) Co-authored-by: release-please[bot] <55107282+release-please[bot]@users.noreply.github.com> --- CHANGELOG.md | 20 ++++++++++++++++++++ bigframes/version.py | 2 +- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 354c356c7c..3209391f44 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,26 @@ [1]: https://pypi.org/project/bigframes/#history +## [1.13.0](https://github.com/googleapis/python-bigquery-dataframes/compare/v1.12.0...v1.13.0) (2024-08-05) + + +### Features + +* `df.apply(axis=1)` to support remote function with mutiple params ([#851](https://github.com/googleapis/python-bigquery-dataframes/issues/851)) ([2158818](https://github.com/googleapis/python-bigquery-dataframes/commit/2158818e53e09e55c87ffd574e3ebc2e201285fb)) +* Allow windowing in 'partial' ordering mode ([#861](https://github.com/googleapis/python-bigquery-dataframes/issues/861)) ([ca26fe5](https://github.com/googleapis/python-bigquery-dataframes/commit/ca26fe5f9edec519788c276a09eaff33ecd87434)) +* Create a separate OrderingModePartialPreviewWarning for more fine-grained warning filters ([#879](https://github.com/googleapis/python-bigquery-dataframes/issues/879)) ([8753bdd](https://github.com/googleapis/python-bigquery-dataframes/commit/8753bdd1e44701e56eae914ebc0e91d9b1a6adf1)) + + +### Bug Fixes + +* Fix issue with invalid sql generated by ml distance functions ([#865](https://github.com/googleapis/python-bigquery-dataframes/issues/865)) ([9959fc8](https://github.com/googleapis/python-bigquery-dataframes/commit/9959fc8fcba93441fdd3d9c17e8fdbe6e6a7b504)) + + +### Documentation + +* Create sample notebook using `ordering_mode="partial"` ([#880](https://github.com/googleapis/python-bigquery-dataframes/issues/880)) ([c415eb9](https://github.com/googleapis/python-bigquery-dataframes/commit/c415eb91eb71dea53d245ba2bce416062e3f02f8)) +* Update streaming notebook ([#875](https://github.com/googleapis/python-bigquery-dataframes/issues/875)) ([e9b0557](https://github.com/googleapis/python-bigquery-dataframes/commit/e9b05571123cf13079772856317ca3cd3d564c5a)) + ## [1.12.0](https://github.com/googleapis/python-bigquery-dataframes/compare/v1.11.1...v1.12.0) (2024-07-31) diff --git a/bigframes/version.py b/bigframes/version.py index 29cf036f42..b474f021d4 100644 --- a/bigframes/version.py +++ b/bigframes/version.py @@ -12,4 +12,4 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "1.12.0" +__version__ = "1.13.0"