Custom primitives¶
A primitive builds a narwhals expression, so it runs on your backend:
from dataclasses import dataclass
import narwhals as nw
from tusk.dtypes import DtypeFamily as F
from tusk.primitives import AggregationPrimitive, register
@register
@dataclass(frozen=True)
class Range(AggregationPrimitive):
"""Difference between the largest and smallest value."""
name = "range" # identifier and also the stem of the column names it builds
input_dtypes = (F.NUMERIC,) # one per input; empty means zero-arity, like count
output_dtype = nw.Float64 # build()'s result is cast to this
default_value = None # empty group has no range
def build(self, expr: nw.Expr) -> nw.Expr:
return expr.max() - expr.min()
Then pass "range" or Range() to deep_feature_synthesis(). Parameters are
ordinary dataclass fields.
Every primitive needs @dataclass(frozen=True). Passing a primitive without
it raises PrimitiveError.
See empty groups for how the compiler uses
default_value.
Say your primitive takes either two numbers or two dates, never one of each.
Declare input_dtypes as a tuple of signatures instead of a flat one:
Synthesis matches each signature as a whole. Every signature must take the same number of inputs.
Subclass AggregationPrimitive for
something that reduces a child table to one row per parent. Subclass
OrderedAggregationPrimitive
when the aggregation reads the rows in row_creation_time order, as first
does.
An aggregation that measures each row against its own group first, as
count_above_mean does, subclasses
GroupRelativeAggregationPrimitive.
This base class exists because SQL backends reject an aggregate nested in an
aggregate.
An aggregation that needs to know how often each value occurs in its group
subclasses
ValueCountAggregationPrimitive,
as mode does. Its compare_with_group(expr, counts) receives the column
and, for each row, how often the row's value occurs.
Subclass TransformPrimitive for
something that maps a row to a row. A transform that reads the other rows of
its foreign-key group subclasses
GroupTransformPrimitive. Subclass
OrderedTransformPrimitive when
it reads those rows in row_creation_time order. See choosing a transform
base class.
@register puts the class in the registry so its
name resolves as a string. Without it, you can still pass an instance.
Choosing a transform base class¶
Every transform goes in trans_primitives. The class it subclasses decides
how synthesis applies it:
TransformPrimitive: the value comes from the row's own columns only, as inyearoradd_numeric. It runs on each row.GroupTransformPrimitive: the value needs the other rows sharing the row's foreign key, but not their order, as in a share of the group's total orpercentile. It runs within each foreign-key group.OrderedTransformPrimitive: the value needs the earlier rows of its group inrow_creation_timeorder, as incum_sumordiff. It runs within each foreign-key group, ordered by the table'srow_creation_time, then its primary key. The table therefore needs arow_creation_time.
To choose in practice, look at what build() calls on its input:
- A reduction or window over the column (
sum,mean,count,rank, …) reads other rows. SubclassGroupTransformPrimitive. cum_*,shift,diffor a forward fill depends on order. SubclassOrderedTransformPrimitive.- Anything else subclasses
TransformPrimitive.
Subclass by what your expression does. The class you choose determines how tusk runs it:
- A reduction under
TransformPrimitive, such asexpr / expr.sum(), computes over every row of the table. It does this on every backend. Every row's value leaks into every other row's value, and your feature carries the whole dataset in it. - An order-dependent expression under
TransformPrimitive, such asexpr.cum_sum(), passes synthesis, including withfeatures_only=True. narwhals then raisesInvalidOperationError: Order-dependent expressions are not supported for use in LazyFrame. This happens when the compiler builds the feature matrix's query, on every backend, before anything is collected.
A share of the group's total reads the other rows of its group, without
needing their order. It subclasses GroupTransformPrimitive:
from dataclasses import dataclass
import narwhals as nw
from tusk.dtypes import DtypeFamily as F
from tusk.primitives import GroupTransformPrimitive, register
@register
@dataclass(frozen=True)
class ShareOfGroupTotal(GroupTransformPrimitive):
"""Each value's fraction of its group's total."""
name = "share_of_group_total"
input_dtypes = (F.NUMERIC,)
output_dtype = nw.Float64
def build(self, expr: nw.Expr) -> nw.Expr:
return expr / expr.sum()
Pass "share_of_group_total" in trans_primitives. It computes each
transaction's share of its session's total, each session's share of its
customer's total, and so on. Its total never spans the whole table.
See how transforms are applied for the built-in transforms of each kind.
Primitives that measure against the cutoff time¶
Mix in NeedsCutoffTime for a primitive
whose value depends on the moment the compiler builds the feature matrix's
query, not just its input column. time_since is the built-in example. build()
takes cutoff_time as a keyword alongside the usual input expressions:
from dataclasses import dataclass
from datetime import datetime
import narwhals as nw
from tusk.dtypes import DtypeFamily as F
from tusk.primitives import NeedsCutoffTime, TransformPrimitive, register
@register
@dataclass(frozen=True)
class TimeSince(NeedsCutoffTime, TransformPrimitive):
"""Time elapsed from a datetime to the cutoff time."""
name = "time_since"
input_dtypes = (F.HAS_DATE,)
output_dtype = nw.Duration
def build(self, expr: nw.Expr, *, cutoff_time: datetime) -> nw.Expr:
return nw.lit(cutoff_time) - expr
cutoff_time arrives as a build argument, so you can build one FeatureList
once and apply it at several cutoff times. deep_feature_synthesis() and
FeatureList.apply() raise ValidationError if you request such a primitive
without giving a cutoff_time.