Skip to content

Commit cae94f9

Browse files
feat(bigframes): Support groupby.agg/transform with udf transpiler (#17613)
1 parent 91f93bc commit cae94f9

23 files changed

Lines changed: 1140 additions & 167 deletions

File tree

packages/bigframes/bigframes/core/block_transforms.py

Lines changed: 97 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -27,22 +27,26 @@
2727
import bigframes.core.bytecode as bytecode
2828
import bigframes.core.expression as ex
2929
import bigframes.core.ordering as ordering
30-
import bigframes.core.window_spec as windows
30+
import bigframes.core.window_spec as window_specs
3131
import bigframes.dtypes as dtypes
32+
import bigframes.functions
3233
import bigframes.operations as ops
3334
import bigframes.operations.aggregations as agg_ops
35+
from bigframes._config import options
3436
from bigframes.core import agg_expressions, py_expressions
3537

3638

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-
"""
39+
def compile_udf(
40+
block: blocks.Block,
41+
func: Callable,
42+
args: tuple = (),
43+
kwargs: dict | None = None,
44+
col_series_args: typing.Mapping[str, str] | None = None,
45+
window_spec: Optional[window_specs.WindowSpec] = None,
46+
) -> ex.Expression:
47+
"""Compile a python function to a BigFrames expression in the context of a block."""
48+
if kwargs is None:
49+
kwargs = {}
4650
expr = bytecode._compile_bytecode_to_py_expr(func)
4751
sig = inspect.signature(func)
4852

@@ -54,17 +58,77 @@ def apply_to_block_rows(
5458
for name, value in bound_params.items():
5559
bindings[name] = ex.const(value)
5660

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-
)
61+
series_arg = next(iter(sig.parameters.keys()))
62+
63+
if col_series_args is not None:
64+
expr = py_expressions.resolve_py_exprs(
65+
expr,
66+
series_arg=series_arg,
67+
col_series_args=col_series_args,
68+
window_spec=window_spec,
69+
)
70+
else:
71+
series_attrs: dict = {}
72+
for i, (col_id, label) in enumerate(
73+
zip(block.value_columns, block.column_labels)
74+
):
75+
series_attrs[i] = col_id
76+
if label is not None:
77+
series_attrs[label] = col_id
78+
79+
expr = py_expressions.resolve_py_exprs(
80+
expr,
81+
series_arg=series_arg,
82+
series_attrs=series_attrs,
83+
window_spec=window_spec,
84+
)
85+
6686
expr = expr.bind_variables(bindings)
87+
return expr
88+
6789

90+
def is_transpiler_eligible(func: typing.Any) -> bool:
91+
"""Return True if func is eligible for Python transpilation."""
92+
return (
93+
options.experiments.enable_python_transpiler
94+
and callable(func)
95+
and not isinstance(func, bigframes.functions.Udf)
96+
)
97+
98+
99+
def compile_column_udf(
100+
block: blocks.Block,
101+
func: Callable,
102+
column_id: str,
103+
args: tuple = (),
104+
kwargs: dict | None = None,
105+
window_spec: Optional[window_specs.WindowSpec] = None,
106+
) -> tuple[ex.Expression, str]:
107+
"""Compile a column-wise python UDF in block context and return (expr, name)."""
108+
sig = inspect.signature(func)
109+
series_arg = next(iter(sig.parameters.keys()))
110+
expr = compile_udf(
111+
block,
112+
func,
113+
args=args,
114+
kwargs=kwargs,
115+
col_series_args={series_arg: column_id},
116+
window_spec=window_spec,
117+
)
118+
name = getattr(func, "__name__", "<lambda>")
119+
return expr, name
120+
121+
122+
def apply_to_block_rows(
123+
func: Callable, block: blocks.Block, *args, **kwargs
124+
) -> blocks.Block:
125+
"""
126+
Apply the given function to each row of the block.
127+
128+
The function is applied to each row of the block, and the result is returned
129+
as a new block with the same index.
130+
"""
131+
expr = compile_udf(block, func, args, kwargs)
68132
return block.project_exprs([expr], labels=[None], drop=True)
69133

70134

@@ -107,13 +171,13 @@ def indicate_duplicates(
107171
agg_expressions.NullaryAggregation(
108172
agg_ops.RowNumberOp(),
109173
),
110-
window=windows.unbound(grouping_keys=tuple(columns)),
174+
window=window_specs.unbound(grouping_keys=tuple(columns)),
111175
)
112176
count = agg_expressions.WindowExpression(
113177
agg_expressions.NullaryAggregation(
114178
agg_ops.SizeOp(),
115179
),
116-
window=windows.unbound(grouping_keys=tuple(columns)),
180+
window=window_specs.unbound(grouping_keys=tuple(columns)),
117181
)
118182

119183
if keep == "first":
@@ -147,7 +211,7 @@ def quantile(
147211
dropna: bool = False,
148212
) -> blocks.Block:
149213
# TODO: handle windowing and more interpolation methods
150-
window = windows.unbound(
214+
window = window_specs.unbound(
151215
grouping_keys=tuple(grouping_column_ids),
152216
)
153217
quantile_cols = []
@@ -248,8 +312,8 @@ def _interpolate_column(
248312
if interpolate_method not in ["linear", "nearest", "ffill"]:
249313
raise ValueError("interpolate method not supported")
250314
window_ordering = (ordering.OrderingExpression(ex.deref(x_values)),)
251-
backwards_window = windows.rows(end=0, ordering=window_ordering)
252-
forwards_window = windows.rows(start=0, ordering=window_ordering)
315+
backwards_window = window_specs.rows(end=0, ordering=window_ordering)
316+
forwards_window = window_specs.rows(start=0, ordering=window_ordering)
253317

254318
# Note, this method may
255319
block, notnull = block.apply_unary_op(column, ops.notnull_op)
@@ -401,7 +465,7 @@ def value_counts(
401465
)
402466
count_id = block.value_columns[0]
403467
if normalize:
404-
unbound_window = windows.unbound(grouping_keys=tuple(grouping_keys))
468+
unbound_window = window_specs.unbound(grouping_keys=tuple(grouping_keys))
405469
block, total_count_id = block.apply_window_op(
406470
count_id, agg_ops.sum_op, unbound_window
407471
)
@@ -429,7 +493,7 @@ def pct_change(block: blocks.Block, periods: int = 1) -> blocks.Block:
429493
column_labels = block.column_labels
430494

431495
# Window framing clause is not allowed for analytic function lag.
432-
window_spec = windows.unbound()
496+
window_spec = window_specs.unbound()
433497

434498
original_columns = block.value_columns
435499
exprs = []
@@ -484,9 +548,9 @@ def rank(
484548
)
485549
window_op = agg_ops.dense_rank_op if method == "dense" else agg_ops.count_op
486550
window_spec = (
487-
windows.unbound(grouping_keys=grouping_cols, ordering=window_ordering)
551+
window_specs.unbound(grouping_keys=grouping_cols, ordering=window_ordering)
488552
if method == "dense"
489-
else windows.rows(
553+
else window_specs.rows(
490554
end=0, ordering=window_ordering, grouping_keys=grouping_cols
491555
)
492556
)
@@ -498,7 +562,7 @@ def rank(
498562
result_expr,
499563
agg_expressions.WindowExpression(
500564
agg_expressions.UnaryAggregation(agg_ops.max_op, result_expr),
501-
windows.unbound(grouping_keys=grouping_cols),
565+
window_specs.unbound(grouping_keys=grouping_cols),
502566
),
503567
)
504568
# Step 2: Apply aggregate to groups of like input values.
@@ -511,7 +575,7 @@ def rank(
511575
}[method]
512576
result_expr = agg_expressions.WindowExpression(
513577
agg_expressions.UnaryAggregation(agg_op, result_expr),
514-
windows.unbound(grouping_keys=(col, *grouping_cols)),
578+
window_specs.unbound(grouping_keys=(col, *grouping_cols)),
515579
)
516580
# Pandas masks all values where any grouping column is null
517581
# Note: we use pd.NA instead of float('nan')
@@ -612,7 +676,7 @@ def nsmallest(
612676
block, counter = block.apply_window_op(
613677
column_ids[0],
614678
agg_ops.rank_op,
615-
window_spec=windows.unbound(ordering=tuple(order_refs)),
679+
window_spec=window_specs.unbound(ordering=tuple(order_refs)),
616680
)
617681
block, condition = block.project_expr(ops.le_op.as_expr(counter, ex.const(n)))
618682
block = block.filter_by_id(condition)
@@ -642,7 +706,7 @@ def nlargest(
642706
block, counter = block.apply_window_op(
643707
column_ids[0],
644708
agg_ops.rank_op,
645-
window_spec=windows.unbound(ordering=tuple(order_refs)),
709+
window_spec=window_specs.unbound(ordering=tuple(order_refs)),
646710
)
647711
block, condition = block.project_expr(ops.le_op.as_expr(counter, ex.const(n)))
648712
block = block.filter_by_id(condition)
@@ -913,7 +977,7 @@ def _idx_extrema(
913977
for idx_col in original_block.index_columns
914978
],
915979
]
916-
window_spec = windows.unbound(ordering=tuple(order_refs))
980+
window_spec = window_specs.unbound(ordering=tuple(order_refs))
917981
idx_col = original_block.index_columns[0]
918982
block, result_col = block.apply_window_op(
919983
idx_col, agg_ops.first_op, window_spec

packages/bigframes/bigframes/core/bytecode.py

Lines changed: 35 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
"//": operator.floordiv,
3232
"%": operator.mod,
3333
"**": operator.pow,
34+
"[]": operator.getitem,
3435
}
3536

3637
_COMPARE_OP_MAP = {
@@ -508,6 +509,18 @@ def _compile_bytecode_to_py_expr(func: Callable) -> expression.Expression:
508509
)
509510
)
510511

512+
case "BINARY_SUBSCR":
513+
if len(stack) < 2:
514+
raise ValueError("Stack has < 2 elements")
515+
key = stack.pop()
516+
container = stack.pop()
517+
stack.append(
518+
py_exprs.Call(
519+
py_exprs.PyObject(operator.getitem),
520+
(container, key),
521+
)
522+
)
523+
511524
case name if name in _OLD_BINARY_OP_MAP:
512525
if len(stack) < 2:
513526
raise ValueError("Stack has < 2 elements")
@@ -575,17 +588,28 @@ def _compile_bytecode_to_py_expr(func: Callable) -> expression.Expression:
575588
if len(stack) < num_args:
576589
raise ValueError(f"Stack has fewer than {num_args} elements")
577590
args = [stack.pop() for _ in range(num_args)][::-1]
578-
if len(stack) >= 2 and stack[-2] == _NULL:
579-
stack[-1], stack[-2] = stack[-2], stack[-1]
580-
if stack and stack[-1] == _NULL:
581-
stack.pop()
582-
elif (
583-
stack
584-
and stack[-1] != _NULL
585-
and isinstance(stack[-1], expression.Expression)
586-
):
587-
self_arg = stack.pop()
588-
args = [self_arg] + args
591+
592+
is_method_call = False
593+
if opname == "CALL" or opname == "CALL_METHOD":
594+
if len(stack) >= 2 and stack[-2] == _NULL:
595+
stack[-1], stack[-2] = stack[-2], stack[-1]
596+
if stack and stack[-1] == _NULL:
597+
stack.pop()
598+
is_method_call = False
599+
else:
600+
is_method_call = True
601+
elif opname == "CALL_FUNCTION":
602+
is_method_call = False
603+
604+
if is_method_call:
605+
if (
606+
stack
607+
and stack[-1] != _NULL
608+
and isinstance(stack[-1], expression.Expression)
609+
):
610+
self_arg = stack.pop()
611+
args = [self_arg] + args
612+
589613
if not stack:
590614
raise ValueError("Stack is empty")
591615
callable_expr = stack.pop()

packages/bigframes/bigframes/core/compile/ibis_compiler/scalar_op_registry.py

Lines changed: 31 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -363,14 +363,6 @@ def contains_regex_op_impl(x: ibis_types.Value, op: ops.StrContainsRegexOp):
363363
return typing.cast(ibis_types.StringValue, x).re_search(op.pat)
364364

365365

366-
@scalar_op_compiler.register_unary_op(ops.StrGetOp, pass_op=True)
367-
def strget_op_impl(x: ibis_types.Value, op: ops.StrGetOp):
368-
substr = typing.cast(
369-
ibis_types.StringValue, typing.cast(ibis_types.StringValue, x)[op.i]
370-
)
371-
return substr.nullif(ibis_types.literal(""))
372-
373-
374366
@scalar_op_compiler.register_unary_op(ops.StrPadOp, pass_op=True)
375367
def strpad_op_impl(x: ibis_types.Value, op: ops.StrPadOp):
376368
str_val = typing.cast(ibis_types.StringValue, x)
@@ -1059,13 +1051,39 @@ def array_to_string_op_impl(x: ibis_types.Value, op: ops.ArrayToStringOp):
10591051
return typing.cast(ibis_types.ArrayValue, x).join(op.delimiter)
10601052

10611053

1062-
@scalar_op_compiler.register_unary_op(ops.ArrayIndexOp, pass_op=True)
1063-
def array_index_op_impl(x: ibis_types.Value, op: ops.ArrayIndexOp):
1064-
res = typing.cast(ibis_types.ArrayValue, x)[op.index]
1065-
if x.type().is_string():
1054+
@scalar_op_compiler.register_unary_op(ops.GetItemOp, pass_op=True)
1055+
def getitem_op_impl(x: ibis_types.Value, op: ops.GetItemOp):
1056+
if x.type().is_struct():
1057+
struct_value = typing.cast(ibis_types.StructValue, x)
1058+
if isinstance(op.key, str):
1059+
name = op.key
1060+
else:
1061+
name = struct_value.names[op.key]
1062+
result = struct_value[name]
1063+
return result.cast(result.type()(nullable=True)).name(name)
1064+
elif x.type().is_array():
1065+
key = typing.cast(int, op.key)
1066+
res = typing.cast(ibis_types.ArrayValue, x)[key]
1067+
return res
1068+
elif x.type().is_string():
1069+
key = typing.cast(int, op.key)
1070+
res = typing.cast(ibis_types.StringValue, x)[key]
10661071
return _null_or_value(res, res != ibis_types.literal(""))
10671072
else:
1068-
return res
1073+
raise TypeError(f"Cannot subscript input of type {x.type()}")
1074+
1075+
1076+
@scalar_op_compiler.register_binary_op(ops.DynamicGetItemOp)
1077+
def dynamic_getitem_op_impl(left: ibis_types.Value, right: ibis_types.Value):
1078+
if left.type().is_array():
1079+
int_right = typing.cast(ibis_types.IntegerValue, right)
1080+
return typing.cast(ibis_types.ArrayValue, left)[int_right]
1081+
elif left.type().is_string():
1082+
scalar_right = typing.cast(ibis_types.IntegerScalar, right)
1083+
res = typing.cast(ibis_types.StringValue, left)[scalar_right]
1084+
return _null_or_value(res, res != ibis_types.literal(""))
1085+
else:
1086+
raise TypeError(f"Cannot dynamically subscript input of type {left.type()}")
10691087

10701088

10711089
@scalar_op_compiler.register_unary_op(ops.ArraySliceOp, pass_op=True)

packages/bigframes/bigframes/core/compile/polars/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
# The ops imports appear first so that the implementations can be registered.
2525
# polars shouldn't be needed at import time, as register is a no-op if polars
2626
# isn't installed.
27+
import bigframes.core.compile.polars.operations.array_ops # noqa: F401
2728
import bigframes.core.compile.polars.operations.generic_ops # noqa: F401
2829
import bigframes.core.compile.polars.operations.numeric_ops # noqa: F401
2930
import bigframes.core.compile.polars.operations.struct_ops # noqa: F401

0 commit comments

Comments
 (0)