Skip to content

Commit 4095090

Browse files
TrevorBergeronchalmerlowe
authored andcommitted
feat: Experimental transpilation of unannotated python callables (#17419)
1 parent a9cdab9 commit 4095090

14 files changed

Lines changed: 646 additions & 86 deletions

File tree

packages/bigframes/bigframes/_config/experiment_options.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ def __init__(self):
2828
self._semantic_operators: bool = False
2929
self._ai_operators: bool = False
3030
self._sql_compiler: Literal["legacy", "stable", "experimental"] = "stable"
31+
self._enable_python_transpiler: bool = False
3132

3233
@property
3334
def semantic_operators(self) -> bool:
@@ -166,3 +167,17 @@ def blob_display_height(self, value: Optional[int]):
166167
warnings.warn(msg, category=bfe.ApiDeprecationWarning)
167168

168169
bigframes.options.display.blob_display_height = value
170+
171+
@property
172+
def enable_python_transpiler(self) -> bool:
173+
return self._enable_python_transpiler
174+
175+
@enable_python_transpiler.setter
176+
def enable_python_transpiler(self, value: bool):
177+
if value:
178+
msg = bfe.format_message(
179+
"Python transpiler is an unstable, experimental feature, and not yet fully "
180+
"validated, use at your own risk."
181+
)
182+
warnings.warn(msg, category=bfe.PythonTranspilerPreviewWarning)
183+
self._enable_python_transpiler = value

packages/bigframes/bigframes/core/block_transforms.py

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,22 +14,58 @@
1414
from __future__ import annotations
1515

1616
import functools
17+
import inspect
1718
import typing
18-
from typing import Optional, Sequence
19+
from typing import Callable, Hashable, Optional, Sequence
1920

2021
import bigframes_vendored.constants as constants
2122
import pandas as pd
2223

2324
import bigframes.constants
2425
import bigframes.core as core
2526
import bigframes.core.blocks as blocks
27+
import bigframes.core.bytecode as bytecode
2628
import bigframes.core.expression as ex
2729
import bigframes.core.ordering as ordering
2830
import bigframes.core.window_spec as windows
2931
import bigframes.dtypes as dtypes
3032
import bigframes.operations as ops
3133
import bigframes.operations.aggregations as agg_ops
32-
from bigframes.core import agg_expressions
34+
from bigframes.core import agg_expressions, py_expressions
35+
36+
37+
def apply_to_block_rows(
38+
func: Callable, block: blocks.Block, *args, **kwargs
39+
) -> blocks.Block:
40+
"""
41+
Apply the given function to each row of the block.
42+
43+
The function is applied to each row of the block, and the result is returned
44+
as a new block with the same index.
45+
"""
46+
expr = bytecode._compile_bytecode_to_py_expr(func)
47+
sig = inspect.signature(func)
48+
49+
bindings: dict[Hashable, ex.Expression] = {}
50+
51+
bound_args = sig.bind(*(None, *args), **kwargs)
52+
bound_args.apply_defaults()
53+
bound_params = bound_args.arguments
54+
for name, value in bound_params.items():
55+
bindings[name] = ex.const(value)
56+
57+
expr = py_expressions.resolve_py_exprs(
58+
expr,
59+
series_arg=next(iter(sig.parameters.keys())),
60+
series_attrs={
61+
label: col_id
62+
for label in block.column_labels
63+
if (col_id := block.resolve_label_exact(label)) is not None
64+
},
65+
)
66+
expr = expr.bind_variables(bindings)
67+
68+
return block.project_exprs([expr], labels=[None], drop=True)
3369

3470

3571
def equals(block1: blocks.Block, block2: blocks.Block) -> bool:

packages/bigframes/bigframes/core/bytecode.py

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -249,15 +249,12 @@ def _compile_bytecode_to_py_expr(func: Callable) -> expression.Expression:
249249
raise ValueError("No return value found")
250250

251251

252-
def dis_to_expr(func: Callable, unpack_mode: bool = False) -> expression.Expression:
252+
def py_to_expression(func: Callable) -> expression.Expression:
253253
"""
254254
Try to convert a python function to a BigQuery expression.
255255
256-
Unpack mode is whether SQL columns are addressed as attributes of a single
257-
python argument (e.g. row.col1), or as separate arguments (e.g. col1).
258-
259256
This is "best effort" - if the function contains operations that cannot
260257
be converted to BigQuery expressions, it will raise an Exception.
261258
"""
262259
py_expr = _compile_bytecode_to_py_expr(func)
263-
return py_exprs.resolve_py_exprs(py_expr, unpack_mode=unpack_mode)
260+
return py_exprs.resolve_py_exprs(py_expr)

packages/bigframes/bigframes/core/py_expressions.py

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
import dataclasses
1818
import itertools
1919
from types import ModuleType
20-
from typing import Callable, Hashable, Mapping, Tuple
20+
from typing import Callable, Hashable, Mapping, Optional, Tuple
2121

2222
import bigframes.operations.python_op_maps as python_op_maps
2323
from bigframes import dtypes
@@ -27,6 +27,7 @@
2727
OpExpression,
2828
UnboundVariableExpression,
2929
const,
30+
deref,
3031
)
3132
from bigframes.operations import NUMPY_TO_BINOP, NUMPY_TO_OP, generic_ops, numeric_ops
3233

@@ -310,7 +311,11 @@ def bind_refs(
310311

311312

312313
# TODO: Mode that resolves free variable attrs as columns
313-
def resolve_py_exprs(expression: Expression, unpack_mode: bool = False) -> Expression:
314+
def resolve_py_exprs(
315+
expression: Expression,
316+
series_arg: Optional[str] = None,
317+
series_attrs: Mapping[Hashable, str] | None = None,
318+
) -> Expression:
314319
"""Replace all PyObject, attribute, call expressions. Bottom-up."""
315320

316321
def resolve_expr_if_call(expression: Expression) -> Expression:
@@ -325,10 +330,15 @@ def resolve_attrs(expression: Expression) -> Expression:
325330
if isinstance(expression.input, Module):
326331
# resolves things like Math.pi
327332
return PyObject(getattr(expression.input.module, expression.attr))
328-
if not unpack_mode and isinstance(
329-
expression.input, UnboundVariableExpression
333+
# TODO: Resolve some series methods
334+
if (
335+
series_arg is not None
336+
and series_attrs is not None
337+
and isinstance(expression.input, UnboundVariableExpression)
338+
and expression.input.id == series_arg
339+
and expression.attr in series_attrs
330340
):
331-
return UnboundVariableExpression(expression.attr)
341+
return deref(series_attrs[expression.attr])
332342
return expression
333343

334344
def resolve_pyobjs(expression: Expression) -> Expression:

packages/bigframes/bigframes/dataframe.py

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4716,13 +4716,17 @@ def _prepare_export(
47164716
return array_value, id_overrides
47174717

47184718
def map(self, func, na_action: Optional[str] = None) -> DataFrame:
4719-
if not isinstance(func, bigframes.functions.Udf):
4719+
from bigframes._config import options
4720+
4721+
if not isinstance(func, bigframes.functions.Udf) and not (
4722+
options.experiments.enable_python_transpiler and callable(func)
4723+
):
47204724
raise TypeError("the first argument must be callable")
47214725

47224726
if na_action not in {None, "ignore"}:
47234727
raise ValueError(f"na_action={na_action} not supported")
47244728

4725-
expr = ops.func_to_op(func).as_expr(ex.free_var("input"))
4729+
expr = ops.func_to_expr(func).apply(ex.free_var("input"))
47264730
if na_action == "ignore":
47274731
# True case, predicate, False case
47284732
expr = ops.where_op.as_expr(
@@ -4742,11 +4746,25 @@ def apply(self, func, *, axis=0, args: typing.Tuple = (), **kwargs):
47424746
)
47434747
warnings.warn(msg, category=bfe.FunctionAxisOnePreviewWarning)
47444748

4745-
if not isinstance(func, bigframes.functions.Udf):
4749+
from bigframes._config import options
4750+
4751+
if not isinstance(func, bigframes.functions.Udf) and not (
4752+
options.experiments.enable_python_transpiler and callable(func)
4753+
):
47464754
raise ValueError(
47474755
"For axis=1 a BigFrames BigQuery function must be used."
47484756
)
47494757

4758+
if (
4759+
not isinstance(func, bigframes.functions.Udf)
4760+
and options.experiments.enable_python_transpiler
4761+
and callable(func)
4762+
):
4763+
result_block = block_ops.apply_to_block_rows(
4764+
func, self._block, *args, **kwargs
4765+
)
4766+
return bigframes.series.Series(result_block)
4767+
47504768
if func.udf_def.signature.is_row_processor:
47514769
# Early check whether the dataframe dtypes are currently supported
47524770
# in the bigquery function
@@ -4800,8 +4818,14 @@ def apply(self, func, *, axis=0, args: typing.Tuple = (), **kwargs):
48004818
)
48014819

48024820
# Apply the function
4821+
expr = ops.func_to_expr(func).expr
4822+
if not (
4823+
isinstance(expr, ex.OpExpression)
4824+
and isinstance(expr.op, ops.NaryOp)
4825+
):
4826+
raise TypeError(f"Expected OpExpression with NaryOp, got {expr}")
48034827
result_series = rows_as_json_series._apply_nary_op(
4804-
ops.func_to_op(func),
4828+
expr.op,
48054829
list(args),
48064830
)
48074831

@@ -4861,8 +4885,8 @@ def apply(self, func, *, axis=0, args: typing.Tuple = (), **kwargs):
48614885

48624886
series_list = [self[col] for col in self.columns]
48634887
op_list = series_list[1:] + list(args)
4864-
result_series = series_list[0]._apply_nary_op(
4865-
ops.func_to_op(func), op_list
4888+
result_series = series_list[0]._apply_callable_expr(
4889+
ops.func_to_expr(func), op_list
48664890
)
48674891
result_series.name = None
48684892

packages/bigframes/bigframes/exceptions.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,10 @@ class MaximumResultRowsExceeded(RuntimeError):
7575
"""Maximum number of rows in the result was exceeded."""
7676

7777

78+
class TranspilationError(RuntimeError):
79+
"""Failed to transpile a Python function to BigFrames Expression."""
80+
81+
7882
class TimeTravelDisabledWarning(Warning):
7983
"""A query was reattempted without time travel."""
8084

@@ -126,6 +130,10 @@ class FunctionPackageVersionWarning(PreviewWarning):
126130
"""
127131

128132

133+
class PythonTranspilerPreviewWarning(PreviewWarning):
134+
"""Python Transpiler is a preview feature."""
135+
136+
129137
def format_message(message: str, fill: bool = True):
130138
"""[Private] Formats a warning message.
131139

packages/bigframes/bigframes/operations/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -230,7 +230,7 @@
230230
timestamp_add_op,
231231
timestamp_sub_op,
232232
)
233-
from bigframes.operations.to_op import func_to_op
233+
from bigframes.operations.to_op import func_to_expr
234234

235235
__all__ = [
236236
# Base ops
@@ -439,7 +439,7 @@
439439
"AIScore",
440440
"AISimilarity",
441441
# Helper functions
442-
"func_to_op",
442+
"func_to_expr",
443443
# Numpy ops mapping
444444
"NUMPY_TO_BINOP",
445445
"NUMPY_TO_OP",

0 commit comments

Comments
 (0)