Skip to content

Commit a101f0f

Browse files
committed
add aggregation to odfv
Signed-off-by: hao-xu5 <hxu44@apple.com>
1 parent 3aec5d5 commit a101f0f

9 files changed

Lines changed: 318 additions & 4 deletions

File tree

sdk/python/feast/infra/compute_engines/backends/__init__.py

Whitespace-only changes.
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
from abc import ABC, abstractmethod
2+
from datetime import timedelta
3+
4+
5+
class DataFrameBackend(ABC):
6+
"""
7+
Abstract interface for DataFrame operations used by the LocalComputeEngine.
8+
9+
This interface defines the contract for implementing pluggable DataFrame backends
10+
such as Pandas, Polars, or DuckDB. Each backend must support core table operations
11+
such as joins, filtering, aggregation, conversion to/from Arrow, and deduplication.
12+
13+
The purpose of this abstraction is to allow seamless swapping of execution backends
14+
without changing DAGNode or ComputeEngine logic. All nodes operate on pyarrow.Table
15+
as the standard input/output format, while the backend defines how the computation
16+
is actually performed.
17+
18+
Expected implementations include:
19+
- PandasBackend
20+
- PolarsBackend
21+
- DuckDBBackend (future)
22+
23+
Methods
24+
-------
25+
from_arrow(table: pa.Table) -> Any
26+
Convert a pyarrow.Table to the backend-native DataFrame format.
27+
28+
to_arrow(df: Any) -> pa.Table
29+
Convert a backend-native DataFrame to pyarrow.Table.
30+
31+
join(left: Any, right: Any, on: List[str], how: str) -> Any
32+
Join two dataframes on specified keys with given join type.
33+
34+
groupby_agg(df: Any, group_keys: List[str], agg_ops: Dict[str, Tuple[str, str]]) -> Any
35+
Group and aggregate the dataframe. `agg_ops` maps output column names
36+
to (aggregation function, source column name) pairs.
37+
38+
filter(df: Any, expr: str) -> Any
39+
Apply a filter expression (string-based) to the DataFrame.
40+
41+
to_timedelta_value(delta: timedelta) -> Any
42+
Convert a Python timedelta object to a backend-compatible value
43+
that can be subtracted from a timestamp column.
44+
45+
drop_duplicates(df: Any, keys: List[str], sort_by: List[str], ascending: bool = False) -> Any
46+
Deduplicate the DataFrame by key columns, keeping the first row
47+
by descending or ascending sort order.
48+
49+
rename_columns(df: Any, columns: Dict[str, str]) -> Any
50+
Rename columns in the DataFrame according to the provided mapping.
51+
"""
52+
53+
@abstractmethod
54+
def columns(self, df): ...
55+
56+
@abstractmethod
57+
def from_arrow(self, table): ...
58+
59+
@abstractmethod
60+
def join(self, left, right, on, how): ...
61+
62+
@abstractmethod
63+
def groupby_agg(self, df, group_keys, agg_ops): ...
64+
65+
@abstractmethod
66+
def filter(self, df, expr): ...
67+
68+
@abstractmethod
69+
def to_arrow(self, df): ...
70+
71+
@abstractmethod
72+
def to_timedelta_value(self, delta: timedelta): ...
73+
74+
@abstractmethod
75+
def drop_duplicates(self, df, keys, sort_by, ascending: bool = False):
76+
pass
77+
78+
@abstractmethod
79+
def rename_columns(self, df, columns: dict[str, str]): ...
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
from typing import Optional
2+
3+
import pandas as pd
4+
import pyarrow
5+
6+
from feast.infra.compute_engines.backends.base import DataFrameBackend
7+
from feast.infra.compute_engines.backends.pandas_backend import PandasBackend
8+
9+
10+
class BackendFactory:
11+
"""
12+
Factory class for constructing DataFrameBackend implementations based on backend name
13+
or runtime entity_df type.
14+
"""
15+
16+
@staticmethod
17+
def from_name(name: str) -> DataFrameBackend:
18+
if name == "pandas":
19+
return PandasBackend()
20+
if name == "polars":
21+
return BackendFactory._get_polars_backend()
22+
raise ValueError(f"Unsupported backend name: {name}")
23+
24+
@staticmethod
25+
def infer_from_entity_df(entity_df) -> Optional[DataFrameBackend]:
26+
if (
27+
not entity_df
28+
or isinstance(entity_df, pyarrow.Table)
29+
or isinstance(entity_df, pd.DataFrame)
30+
):
31+
return PandasBackend()
32+
33+
if BackendFactory._is_polars(entity_df):
34+
return BackendFactory._get_polars_backend()
35+
return None
36+
37+
@staticmethod
38+
def _is_polars(entity_df) -> bool:
39+
try:
40+
import polars as pl
41+
except ImportError:
42+
raise ImportError(
43+
"Polars is not installed. Please install it to use Polars backend."
44+
)
45+
return isinstance(entity_df, pl.DataFrame)
46+
47+
@staticmethod
48+
def _get_polars_backend():
49+
from feast.infra.compute_engines.backends.polars_backend import (
50+
PolarsBackend,
51+
)
52+
53+
return PolarsBackend()
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
from datetime import timedelta
2+
3+
import pandas as pd
4+
import pyarrow as pa
5+
6+
from feast.infra.compute_engines.backends.base import DataFrameBackend
7+
8+
9+
class PandasBackend(DataFrameBackend):
10+
def columns(self, df):
11+
return df.columns.tolist()
12+
13+
def from_arrow(self, table):
14+
return table.to_pandas()
15+
16+
def join(self, left, right, on, how):
17+
return left.merge(right, on=on, how=how)
18+
19+
def groupby_agg(self, df, group_keys, agg_ops):
20+
return (
21+
df.groupby(group_keys)
22+
.agg(
23+
**{
24+
alias: pd.NamedAgg(column=col, aggfunc=func)
25+
for alias, (func, col) in agg_ops.items()
26+
}
27+
)
28+
.reset_index()
29+
)
30+
31+
def filter(self, df, expr):
32+
return df.query(expr)
33+
34+
def to_arrow(self, df):
35+
return pa.Table.from_pandas(df)
36+
37+
def to_timedelta_value(self, delta: timedelta):
38+
return pd.to_timedelta(delta)
39+
40+
def drop_duplicates(self, df, keys, sort_by, ascending: bool = False):
41+
return df.sort_values(by=sort_by, ascending=ascending).drop_duplicates(
42+
subset=keys
43+
)
44+
45+
def rename_columns(self, df, columns: dict[str, str]):
46+
return df.rename(columns=columns)
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
from datetime import timedelta
2+
3+
import polars as pl
4+
import pyarrow as pa
5+
6+
from feast.infra.compute_engines.backends.base import DataFrameBackend
7+
8+
9+
class PolarsBackend(DataFrameBackend):
10+
def columns(self, df: pl.DataFrame) -> list[str]:
11+
return df.columns
12+
13+
def from_arrow(self, table: pa.Table) -> pl.DataFrame:
14+
return pl.from_arrow(table)
15+
16+
def to_arrow(self, df: pl.DataFrame) -> pa.Table:
17+
return df.to_arrow()
18+
19+
def join(self, left: pl.DataFrame, right: pl.DataFrame, on, how) -> pl.DataFrame:
20+
return left.join(right, on=on, how=how)
21+
22+
def groupby_agg(self, df: pl.DataFrame, group_keys, agg_ops) -> pl.DataFrame:
23+
agg_exprs = [
24+
getattr(pl.col(col), func)().alias(alias)
25+
for alias, (func, col) in agg_ops.items()
26+
]
27+
return df.groupby(group_keys).agg(agg_exprs)
28+
29+
def filter(self, df: pl.DataFrame, expr: str) -> pl.DataFrame:
30+
return df.filter(pl.sql_expr(expr))
31+
32+
def to_timedelta_value(self, delta: timedelta):
33+
return pl.duration(milliseconds=delta.total_seconds() * 1000)
34+
35+
def drop_duplicates(
36+
self,
37+
df: pl.DataFrame,
38+
keys: list[str],
39+
sort_by: list[str],
40+
ascending: bool = False,
41+
) -> pl.DataFrame:
42+
return df.sort(by=sort_by, descending=not ascending).unique(
43+
subset=keys, keep="first"
44+
)
45+
46+
def rename_columns(self, df: pl.DataFrame, columns: dict[str, str]) -> pl.DataFrame:
47+
return df.rename(columns)

sdk/python/feast/infra/compute_engines/local/compute.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@
1414
from feast.infra.common.retrieval_task import HistoricalRetrievalTask
1515
from feast.infra.compute_engines.base import ComputeEngine
1616
from feast.infra.compute_engines.dag.context import ExecutionContext
17-
from feast.infra.compute_engines.local.backends.base import DataFrameBackend
18-
from feast.infra.compute_engines.local.backends.factory import BackendFactory
17+
from feast.infra.compute_engines.backends.base import DataFrameBackend
18+
from feast.infra.compute_engines.backends.factory import BackendFactory
1919
from feast.infra.compute_engines.local.feature_builder import LocalFeatureBuilder
2020
from feast.infra.compute_engines.local.job import (
2121
LocalMaterializationJob,

sdk/python/feast/infra/compute_engines/local/feature_builder.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from feast.infra.common.materialization_job import MaterializationTask
44
from feast.infra.common.retrieval_task import HistoricalRetrievalTask
55
from feast.infra.compute_engines.feature_builder import FeatureBuilder
6-
from feast.infra.compute_engines.local.backends.base import DataFrameBackend
6+
from feast.infra.compute_engines.backends.base import DataFrameBackend
77
from feast.infra.compute_engines.local.nodes import (
88
LocalAggregationNode,
99
LocalDedupNode,

sdk/python/feast/infra/compute_engines/local/nodes.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from feast.infra.compute_engines.dag.model import DAGFormat
1010
from feast.infra.compute_engines.dag.node import DAGNode
1111
from feast.infra.compute_engines.local.arrow_table_value import ArrowTableValue
12-
from feast.infra.compute_engines.local.backends.base import DataFrameBackend
12+
from feast.infra.compute_engines.backends.base import DataFrameBackend
1313
from feast.infra.compute_engines.local.local_node import LocalNode
1414
from feast.infra.compute_engines.utils import (
1515
create_offline_store_retrieval_job,

sdk/python/feast/utils.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
RequestDataNotFoundInEntityRowsException,
3434
)
3535
from feast.field import Field
36+
from feast.infra.compute_engines.backends.pandas_backend import PandasBackend
3637
from feast.infra.key_encoding_utils import deserialize_entity_key
3738
from feast.protos.feast.serving.ServingService_pb2 import (
3839
FieldStatus,
@@ -561,6 +562,72 @@ def construct_response_feature_vector(
561562
)
562563

563564

565+
def _get_aggregate_operations(agg_specs) -> dict:
566+
"""
567+
Convert Aggregation specs to agg_ops format for PandasBackend.
568+
569+
Reused from LocalFeatureBuilder logic.
570+
TODO: This logic is duplicated from LocalFeatureBuilder._get_aggregate_operations().
571+
Consider refactoring to a shared utility module in the future.
572+
"""
573+
agg_ops = {}
574+
for agg in agg_specs:
575+
if agg.time_window is not None:
576+
raise ValueError(
577+
"Time window aggregation is not supported in online serving."
578+
)
579+
alias = f"{agg.function}_{agg.column}"
580+
agg_ops[alias] = (agg.function, agg.column)
581+
return agg_ops
582+
583+
584+
def _apply_aggregations_to_response(
585+
response_data: Union[pyarrow.Table, Dict[str, List[Any]]],
586+
aggregations,
587+
group_keys: List[str],
588+
mode: str,
589+
) -> Union[pyarrow.Table, Dict[str, List[Any]]]:
590+
"""
591+
Apply aggregations using PandasBackend.
592+
593+
Args:
594+
response_data: Either a pyarrow.Table or dict of lists containing the data
595+
aggregations: List of Aggregation objects to apply
596+
group_keys: List of column names to group by
597+
mode: Transformation mode ("python", "pandas", or "substrait")
598+
599+
Returns:
600+
Aggregated data in the same format as input
601+
602+
TODO: Consider refactoring to support backends other than pandas in the future.
603+
"""
604+
if not aggregations:
605+
return response_data
606+
607+
backend = PandasBackend()
608+
609+
# Convert to pandas DataFrame
610+
if isinstance(response_data, dict):
611+
df = pd.DataFrame(response_data)
612+
else: # pyarrow.Table
613+
df = backend.from_arrow(response_data)
614+
615+
if df.empty:
616+
return response_data
617+
618+
# Convert aggregations to agg_ops format
619+
agg_ops = _get_aggregate_operations(aggregations)
620+
621+
# Apply aggregations using PandasBackend
622+
result_df = backend.groupby_agg(df, group_keys, agg_ops)
623+
624+
# Convert back to original format
625+
if mode == "python":
626+
return {col: result_df[col].tolist() for col in result_df.columns}
627+
else: # pandas or substrait
628+
return backend.to_arrow(result_df)
629+
630+
564631
def _augment_response_with_on_demand_transforms(
565632
online_features_response: GetOnlineFeaturesResponse,
566633
feature_refs: List[str],
@@ -605,6 +672,28 @@ def _augment_response_with_on_demand_transforms(
605672
for odfv_name, _feature_refs in odfv_feature_refs.items():
606673
odfv = requested_odfv_map[odfv_name]
607674
if not odfv.write_to_online_store:
675+
# Apply aggregations BEFORE transformation if defined
676+
if odfv.aggregations:
677+
if odfv.mode == "python":
678+
if initial_response_dict is None:
679+
initial_response_dict = initial_response.to_dict()
680+
initial_response_dict = _apply_aggregations_to_response(
681+
initial_response_dict,
682+
odfv.aggregations,
683+
odfv.entities,
684+
odfv.mode,
685+
)
686+
elif odfv.mode in {"pandas", "substrait"}:
687+
if initial_response_arrow is None:
688+
initial_response_arrow = initial_response.to_arrow()
689+
initial_response_arrow = _apply_aggregations_to_response(
690+
initial_response_arrow,
691+
odfv.aggregations,
692+
odfv.entities,
693+
odfv.mode,
694+
)
695+
696+
# Apply transformation
608697
if odfv.mode == "python":
609698
if initial_response_dict is None:
610699
initial_response_dict = initial_response.to_dict()

0 commit comments

Comments
 (0)