Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions docs/getting-started/concepts/feature-view.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,32 @@ Feature names must be unique within a [feature view](feature-view.md#feature-vie

Each field can have additional metadata associated with it, specified as key-value [tags](https://rtd.feast.dev/en/master/feast.html#feast.field.Field).

### Default values

A field can declare a typed default that Feast substitutes when the feature is missing or null, so every consumer sees the same value instead of imputing its own:

```python
from feast import Field
from feast.types import Int64

Field(
name="transaction_count",
dtype=Int64,
default_value=0,
)
```

The default is applied during online retrieval and after the historical point-in-time join, and it is applied before on demand feature views run, so a transformation receives the same input in training and serving.

Some details worth knowing:

* Defaults are opt-in. A field without `default_value` behaves exactly as before, returning null.
* The default must be representable as the field's `dtype`. A value the type cannot hold, and a float that an integer type would truncate such as `1.5` for an `Int64`, are rejected when the `Field` is created rather than during serving. Values the type *can* hold are coerced and stored in their converted form, so `Float32` with `0.1` reads back as `0.10000000149011612` and `Int64` with `"5"` reads back as `5`.
* Feature statuses are not changed. A missing feature still reports `NOT_FOUND` even when a default is returned, so monitoring continues to reveal upstream data loss. If you need to distinguish "genuinely zero" from "missing" in a model, define a separate indicator feature.
* Values outside the feature view's TTL keep their real value and `OUTSIDE_MAX_AGE` status. A default never replaces a stale value.

Because older Feast runtimes ignore the field, upgrade your serving deployments before relying on a configured default.

## \[Alpha\] Versioning

Feature views support automatic version tracking. Every time `feast apply` detects a schema or UDF change, a versioned snapshot is saved to the registry. This enables auditing what changed, reverting to a prior version, querying specific versions via `@v<N>` syntax, and staging new versions without promoting them.
Expand Down
32 changes: 32 additions & 0 deletions go/internal/feast/model/basefeatureview.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,46 @@ package model

import (
"fmt"
"sync"

"github.com/feast-dev/feast/go/protos/feast/core"
"github.com/feast-dev/feast/go/protos/feast/types"
)

type BaseFeatureView struct {
Name string
Features []*Field
Projection *FeatureViewProjection
// Built on first use rather than at construction, because BaseFeatureView is also
// assembled as a struct literal; a constructor-only map would silently be empty.
featureDefaultsOnce sync.Once
featureDefaults map[string]*types.Value
}

// GetDefaultValue returns the configured default for a feature, or nil. Serving calls
// this per feature per request, so the scan happens once rather than on every request.
func (fv *BaseFeatureView) GetDefaultValue(featureName string) *types.Value {
fv.featureDefaultsOnce.Do(func() {
// Prefer the projection: a feature service can project a different set of
// fields than the base view carries, and Python resolves defaults the same way.
features := fv.Features
if fv.Projection != nil && len(fv.Projection.Features) > 0 {
features = fv.Projection.Features
}
for _, feature := range features {
if feature.DefaultValue == nil {
continue
}
if fv.featureDefaults == nil {
fv.featureDefaults = make(map[string]*types.Value, len(features))
}
fv.featureDefaults[feature.Name] = feature.DefaultValue
}
})
if fv.featureDefaults == nil {
return nil
}
return fv.featureDefaults[featureName]
}

func NewBaseFeatureView(name string, featureProtos []*core.FeatureSpecV2) *BaseFeatureView {
Expand Down
7 changes: 5 additions & 2 deletions go/internal/feast/model/field.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,14 @@ import (
type Field struct {
Name string
Dtype types.ValueType_Enum
// Substituted when the feature is missing or null. Nil means no default.
DefaultValue *types.Value
}

func NewFieldFromProto(proto *core.FeatureSpecV2) *Field {
return &Field{
Name: proto.Name,
Dtype: proto.ValueType,
Name: proto.Name,
Dtype: proto.ValueType,
DefaultValue: proto.DefaultValue,
}
}
29 changes: 28 additions & 1 deletion go/internal/feast/onlineserving/serving.go
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,23 @@ func ValidateFeatureRefs(requestedFeatures []*FeatureViewAndRefs, fullFeatureNam
return nil
}

// defaultValueForFeature returns the feature's configured default, or nil. The map
// lookup on BaseFeatureView keeps this off the critical path when nothing declares one.
func defaultValueForFeature(
fvs map[string]*model.FeatureView,
groupRef *GroupedFeaturesPerEntitySet,
featureIndex int) *prototypes.Value {

if featureIndex >= len(groupRef.FeatureViewNames) || featureIndex >= len(groupRef.FeatureNames) {
return nil
}
fv, ok := fvs[groupRef.FeatureViewNames[featureIndex]]
if !ok || fv.Base == nil {
return nil
}
return fv.Base.GetDefaultValue(groupRef.FeatureNames[featureIndex])
}

func TransposeFeatureRowsIntoColumns(featureData2D [][]onlinestore.FeatureData,
groupRef *GroupedFeaturesPerEntitySet,
requestedFeatureViews []*FeatureViewAndRefs,
Expand Down Expand Up @@ -377,6 +394,10 @@ func TransposeFeatureRowsIntoColumns(featureData2D [][]onlinestore.FeatureData,
vectors = append(vectors, currentVector)
protoValues := make([]*prototypes.Value, numRows)

// Resolved per feature rather than per row: a missing row carries no
// Reference to look the feature view up by.
defaultValue := defaultValueForFeature(fvs, groupRef, featureIndex)

for rowEntityIndex, outputIndexes := range groupRef.Indices {
if featureData2D[rowEntityIndex] == nil {
value = nil
Expand All @@ -398,8 +419,14 @@ func TransposeFeatureRowsIntoColumns(featureData2D [][]onlinestore.FeatureData,
status = serving.FieldStatus_PRESENT
}
}
// Only the value is swapped; the status is left as computed above. An
// empty Value counts as missing, matching the Python paths.
outValue := value
if defaultValue != nil && (outValue == nil || outValue.Val == nil) {
outValue = defaultValue
}
for _, rowIndex := range outputIndexes {
protoValues[rowIndex] = value
protoValues[rowIndex] = outValue
currentVector.Statuses[rowIndex] = status
currentVector.Timestamps[rowIndex] = eventTimeStamp
}
Expand Down
88 changes: 88 additions & 0 deletions go/internal/feast/onlineserving/serving_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,18 @@ package onlineserving

import (
"testing"
"time"

"github.com/apache/arrow/go/v17/arrow/array"
"github.com/apache/arrow/go/v17/arrow/memory"
"github.com/stretchr/testify/assert"
"google.golang.org/protobuf/types/known/durationpb"
"google.golang.org/protobuf/types/known/timestamppb"

"github.com/feast-dev/feast/go/internal/feast/model"
"github.com/feast-dev/feast/go/internal/feast/onlinestore"
"github.com/feast-dev/feast/go/protos/feast/core"
"github.com/feast-dev/feast/go/protos/feast/serving"
"github.com/feast-dev/feast/go/protos/feast/types"
)

Expand Down Expand Up @@ -332,3 +337,86 @@ func TestUnpackFeatureViewsByReferences(t *testing.T) {

assertCorrectUnpacking(t, fvs, odfvs, err)
}

func defaultValueTestView(defaultValue *types.Value) *model.FeatureView {
return &model.FeatureView{
Base: &model.BaseFeatureView{
Name: "driver_stats",
Features: []*model.Field{
{Name: "conv_rate", Dtype: types.ValueType_INT64, DefaultValue: defaultValue},
},
Projection: &model.FeatureViewProjection{Name: "driver_stats"},
},
Ttl: durationpb.New(time.Hour * 24 * 365 * 100),
}
}

func defaultValueTestGroupRef() *GroupedFeaturesPerEntitySet {
return &GroupedFeaturesPerEntitySet{
FeatureNames: []string{"conv_rate"},
FeatureViewNames: []string{"driver_stats"},
AliasedFeatureNames: []string{"driver_stats__conv_rate"},
Indices: [][]int{{0}},
}
}

func transposeSingle(t *testing.T, view *model.FeatureView, featureData2D [][]onlinestore.FeatureData) *FeatureVector {
t.Helper()
vectors, err := TransposeFeatureRowsIntoColumns(
featureData2D,
defaultValueTestGroupRef(),
[]*FeatureViewAndRefs{{View: view, FeatureRefs: []string{"conv_rate"}}},
memory.NewGoAllocator(),
1,
)
assert.Nil(t, err)
assert.Len(t, vectors, 1)
return vectors[0]
}

func TestTransposeSubstitutesDefaultForMissingRow(t *testing.T) {
view := defaultValueTestView(&types.Value{Val: &types.Value_Int64Val{Int64Val: 7}})

vector := transposeSingle(t, view, [][]onlinestore.FeatureData{nil})

assert.Equal(t, serving.FieldStatus_NOT_FOUND, vector.Statuses[0])
assert.Equal(t, int64(7), vector.Values.(*array.Int64).Value(0))
}

func TestTransposeSubstitutesDefaultForNullValue(t *testing.T) {
view := defaultValueTestView(&types.Value{Val: &types.Value_Int64Val{Int64Val: 7}})
featureData2D := [][]onlinestore.FeatureData{{{
Reference: serving.FeatureReferenceV2{FeatureViewName: "driver_stats", FeatureName: "conv_rate"},
Timestamp: timestamppb.Timestamp{Seconds: timestamppb.Now().Seconds},
Value: types.Value{Val: &types.Value_NullVal{}},
}}}

vector := transposeSingle(t, view, featureData2D)

assert.Equal(t, serving.FieldStatus_NOT_FOUND, vector.Statuses[0])
assert.Equal(t, int64(7), vector.Values.(*array.Int64).Value(0))
}

func TestTransposeLeavesPresentValueUntouched(t *testing.T) {
view := defaultValueTestView(&types.Value{Val: &types.Value_Int64Val{Int64Val: 7}})
featureData2D := [][]onlinestore.FeatureData{{{
Reference: serving.FeatureReferenceV2{FeatureViewName: "driver_stats", FeatureName: "conv_rate"},
Timestamp: timestamppb.Timestamp{Seconds: timestamppb.Now().Seconds},
Value: types.Value{Val: &types.Value_Int64Val{Int64Val: 3}},
}}}

vector := transposeSingle(t, view, featureData2D)

assert.Equal(t, serving.FieldStatus_PRESENT, vector.Statuses[0])
assert.Equal(t, int64(3), vector.Values.(*array.Int64).Value(0))
}

func TestTransposeWithoutDefaultLeavesNull(t *testing.T) {
view := defaultValueTestView(nil)

vector := transposeSingle(t, view, [][]onlinestore.FeatureData{nil})

assert.Equal(t, serving.FieldStatus_NOT_FOUND, vector.Statuses[0])
// array.Null reports NullN rather than IsNull for this type.
assert.Equal(t, 1, vector.Values.NullN())
}
4 changes: 4 additions & 0 deletions protos/feast/core/Feature.proto
Original file line number Diff line number Diff line change
Expand Up @@ -45,4 +45,8 @@ message FeatureSpecV2 {

// Field indicating the vector length
int32 vector_length = 7;

// Value substituted when this feature is missing or null at retrieval time.
// Unset means no default. Presence is what separates that from a default of 0.
feast.types.Value default_value = 8;
}
4 changes: 4 additions & 0 deletions sdk/python/feast/feature_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -2105,6 +2105,10 @@ def get_historical_features(
**kwargs,
)

default_values = utils.get_default_values_by_column(fvs, full_feature_names)
if default_values:
job._feature_default_values = default_values

# Auto-log to MLflow if configured
try:
if self.mlflow is not None and self.config.mlflow.auto_log:
Expand Down
9 changes: 9 additions & 0 deletions sdk/python/feast/feature_view_projection.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from feast.protos.feast.core.FeatureViewProjection_pb2 import (
FeatureViewProjection as FeatureViewProjectionProto,
)
from feast.protos.feast.types.Value_pb2 import Value as ValueProto

if TYPE_CHECKING:
from feast.base_feature_view import BaseFeatureView
Expand Down Expand Up @@ -156,6 +157,14 @@ def from_definition(base_feature_view: "BaseFeatureView"):
desired_features=[],
)

def default_value_protos(self) -> Dict[str, ValueProto]:
"""Proto defaults for this projection's features that configure one."""
return {
field.name: field.default_value_proto
for field in self.features
if field.default_value_proto is not None
}

def get_feature(self, feature_name: str) -> Field:
try:
return next(field for field in self.features if field.name == feature_name)
Expand Down
Loading
Loading