Visitar URL original
fix: Rename python mode ODFV outputs inside transform_dict · feast-dev/feast@6976dbb · GitHub
Skip to content

Commit 6976dbb

Browse files
committed
fix: Rename python mode ODFV outputs inside transform_dict
Move the full feature name renaming out of _augment_response_with_on_demand_transforms and into OnDemandFeatureView.transform_dict, behind a new full_feature_names parameter, as suggested in review. It mirrors _postprocess_arrow_table, so transform_dict, transform_arrow and transform_ibis name their outputs the same way for any caller. Also cover alias + python mode + full_feature_names together: the aliased feature service test now checks "<alias>__<feature>" in pandas, python and singleton python modes, and a unit test checks transform_dict naming directly, with and without an alias. Signed-off-by: breken-ai <312387581+breken-ai@users.noreply.github.com>
1 parent 78eb277 commit 6976dbb

4 files changed

Lines changed: 98 additions & 20 deletions

File tree

‎sdk/python/feast/on_demand_feature_view.py‎

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1101,13 +1101,16 @@ def _postprocess_arrow_table(
11011101
def transform_dict(
11021102
self,
11031103
feature_dict: dict[str, Any], # type: ignore
1104+
full_feature_names: bool = False,
11041105
) -> dict[str, Any]:
11051106
"""
11061107
Transform a dictionary of features using the configured transformation.
11071108
Handles both singleton and batch transformations.
11081109
11091110
Args:
11101111
feature_dict: Dictionary containing input features
1112+
full_feature_names: Whether to return output features as
1113+
"<view>__<feature>", like transform_arrow does.
11111114
11121115
Returns:
11131116
Dictionary with transformed features
@@ -1126,12 +1129,36 @@ def transform_dict(
11261129
else:
11271130
output_dict = self.feature_transformation.transform(preprocessed_dict)
11281131

1129-
# Clean up temporary columns
1132+
# Clean up temporary columns and apply final renaming
1133+
return self._postprocess_feature_dict(
1134+
output_dict, columns_to_cleanup, full_feature_names
1135+
)
1136+
1137+
def _postprocess_feature_dict(
1138+
self,
1139+
output_dict: dict[str, Any],
1140+
columns_to_cleanup: list[str],
1141+
full_feature_names: bool,
1142+
) -> dict[str, Any]:
1143+
"""
1144+
Clean up temporary keys and apply final feature renaming.
1145+
Mirrors _postprocess_arrow_table so python and pandas modes agree.
1146+
"""
1147+
rename_columns: dict[str, str] = {}
1148+
for feature in self.features:
1149+
short_name = feature.name
1150+
long_name = self._get_projected_feature_name(feature.name)
1151+
1152+
if short_name in output_dict and full_feature_names:
1153+
rename_columns[short_name] = long_name
1154+
elif long_name in output_dict and not full_feature_names:
1155+
rename_columns[long_name] = short_name
1156+
11301157
for feature_name in columns_to_cleanup:
11311158
if feature_name in output_dict:
11321159
del output_dict[feature_name]
11331160

1134-
return output_dict
1161+
return {rename_columns.get(k, k): v for k, v in output_dict.items()}
11351162

11361163
def _preprocess_feature_dict(
11371164
self, feature_dict: dict[str, Any]

‎sdk/python/feast/utils.py‎

Lines changed: 1 addition & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -989,20 +989,8 @@ def _is_metrics_active():
989989
if k not in undeclared_columns
990990
}
991991
transformed_features_dict: Dict[str, List[Any]] = odfv.transform_dict(
992-
odfv_input_dict
992+
odfv_input_dict, full_feature_names
993993
)
994-
if full_feature_names:
995-
# The UDF returns short output names, but the requested refs
996-
# use "<view>__<feature>" (transform_arrow renames the same way).
997-
odfv_output_names = {f.name for f in odfv.features}
998-
transformed_features_dict = {
999-
(
1000-
odfv._get_projected_feature_name(name)
1001-
if name in odfv_output_names
1002-
else name
1003-
): values
1004-
for name, values in transformed_features_dict.items()
1005-
}
1006994
elif odfv.mode in {"pandas", "substrait"}:
1007995
if initial_response_arrow is None:
1008996
initial_response_arrow = initial_response.to_arrow()

‎sdk/python/tests/unit/test_on_demand_pandas_transformation.py‎

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -600,11 +600,13 @@ def aliased_guard(inputs: pd.DataFrame) -> pd.DataFrame:
600600
assert response["saw_undeclared"] == [False]
601601

602602

603-
@pytest.mark.parametrize("mode", ["pandas", "python"])
603+
@pytest.mark.parametrize("mode", ["pandas", "python", "python_singleton"])
604604
def test_feature_service_serves_aliased_odfv_features(mode):
605605
"""An ODFV added to a FeatureService under ``with_name`` must still be
606606
computed. Its feature refs use the alias, so the transform lookup has to be
607-
keyed by the alias too, not only by the ODFV's registered name."""
607+
keyed by the alias too, not only by the ODFV's registered name. With
608+
``full_feature_names`` the outputs must come back as ``<alias>__<feature>``
609+
in every mode, python mode included."""
608610
with tempfile.TemporaryDirectory() as data_dir:
609611
store, driver, src, fv1, fv2, driver_df = _two_fv_store(data_dir)
610612

@@ -620,7 +622,7 @@ def scaled(inputs: pd.DataFrame) -> pd.DataFrame:
620622
out["conv_rate_x10"] = inputs["conv_rate"] * 10
621623
return out
622624

623-
else:
625+
elif mode == "python":
624626

625627
@on_demand_feature_view(
626628
sources=[fv1],
@@ -630,6 +632,17 @@ def scaled(inputs: pd.DataFrame) -> pd.DataFrame:
630632
def scaled(inputs: dict) -> dict:
631633
return {"conv_rate_x10": [v * 10 for v in inputs["conv_rate"]]}
632634

635+
else:
636+
637+
@on_demand_feature_view(
638+
sources=[fv1],
639+
schema=[Field(name="conv_rate_x10", dtype=Float64)],
640+
mode="python",
641+
singleton=True,
642+
)
643+
def scaled(inputs: dict) -> dict:
644+
return {"conv_rate_x10": inputs["conv_rate"] * 10}
645+
633646
service = FeatureService(
634647
name="aliased_service",
635648
features=[fv1, scaled.with_name("scaled_alias")],
@@ -645,13 +658,16 @@ def scaled(inputs: dict) -> dict:
645658
pytest.approx(response["conv_rate"][0] * 10)
646659
]
647660

648-
if mode != "pandas":
649-
return
650661
response = store.get_online_features(
651662
entity_rows=[{"driver_id": 1001}],
652663
features=store.get_feature_service("aliased_service"),
653664
full_feature_names=True,
654665
).to_dict()
666+
assert sorted(response.keys()) == [
667+
"driver_id",
668+
"fv1__conv_rate",
669+
"scaled_alias__conv_rate_x10",
670+
]
655671
assert response["scaled_alias__conv_rate_x10"] == [
656672
pytest.approx(response["fv1__conv_rate"][0] * 10)
657673
]

‎sdk/python/tests/unit/test_on_demand_python_transformation.py‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -805,6 +805,53 @@ def test_python_transformation_returning_all_data_types(self):
805805
assert result["achieved_ranks"] == expected_ranks
806806

807807

808+
@pytest.mark.parametrize("singleton", [False, True])
809+
def test_transform_dict_full_feature_names(singleton):
810+
"""transform_dict names its outputs like transform_arrow: short names by
811+
default, ``<view>__<feature>`` (alias included) with full_feature_names."""
812+
request_source = RequestSource(
813+
name="request_source", schema=[Field(name="val", dtype=Int64)]
814+
)
815+
816+
if singleton:
817+
818+
@on_demand_feature_view(
819+
sources=[request_source],
820+
schema=[Field(name="val_plus_one", dtype=Int64)],
821+
mode="python",
822+
singleton=True,
823+
)
824+
def plus_one(inputs: dict[str, Any]) -> dict[str, Any]:
825+
return {"val_plus_one": inputs["val"] + 1}
826+
827+
else:
828+
829+
@on_demand_feature_view(
830+
sources=[request_source],
831+
schema=[Field(name="val_plus_one", dtype=Int64)],
832+
mode="python",
833+
)
834+
def plus_one(inputs: dict[str, Any]) -> dict[str, Any]:
835+
return {"val_plus_one": [v + 1 for v in inputs["val"]]}
836+
837+
inputs: dict[str, Any] = {"val": [1]} if singleton else {"val": [1, 2]}
838+
expected = 2 if singleton else [2, 3]
839+
840+
short = plus_one.transform_dict(inputs)
841+
assert short["val_plus_one"] == expected
842+
assert "plus_one__val_plus_one" not in short
843+
844+
full = plus_one.transform_dict(inputs, full_feature_names=True)
845+
assert full["plus_one__val_plus_one"] == expected
846+
assert "val_plus_one" not in full
847+
848+
aliased = plus_one.with_name("alias").transform_dict(
849+
inputs, full_feature_names=True
850+
)
851+
assert aliased["alias__val_plus_one"] == expected
852+
assert "val_plus_one" not in aliased
853+
854+
808855
def test_invalid_python_transformation_raises_type_error_on_apply():
809856
with tempfile.TemporaryDirectory() as data_dir:
810857
store = FeatureStore(

0 commit comments

Comments
 (0)