Custom primitives¶
A primitive builds a narwhals expression and never sees a value, which is what keeps it pushed down:
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"
input_dtypes = (F.NUMERIC,)
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.
@dataclass(frozen=True) is required: features deduplicate by value. A
primitive without it is rejected with PrimitiveError.
There is no second, shorter way to declare one. Every built-in primitive is a
frozen dataclass written out like this, so Year and Count are the same kind
of object as Range — nothing in tusk can reach a definition path your own
code cannot.
The pieces¶
| Attribute | Meaning |
|---|---|
name |
The string DFS resolves, and the uppercased stem of generated column names. |
input_dtypes |
One DtypeFamily per argument. An empty tuple means zero-arity, like count. |
output_dtype |
The narwhals dtype produced. Omit to derive it from the inputs by overriding return_dtype. |
default_value |
What an empty group gets. See empty groups. |
build |
Takes one expression per input and returns the output expression, or a sequence of them for a multi-output primitive. |
Subclass AggregationPrimitive for
something that reduces a child table to one row per parent, and
TransformPrimitive for something that
maps a row to a row.
@register puts the class in the registry so its
name resolves as a string; without it you can still pass an instance.
Group-aware primitives¶
A primitive like "share of group total" is group-aware without being order-dependent, which no built-in covers:
from dataclasses import dataclass
import narwhals as nw
from tusk.dtypes import DtypeFamily as F
from tusk.primitives import TransformPrimitive, register
@register
@dataclass(frozen=True)
class ShareOfGroupTotal(TransformPrimitive):
"""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 groupby_trans_primitives and it computes
each transaction's share of its session's total, each session's share of its
customer's total, and so on — a genuinely useful feature type that has no other
path into tusk. See what can go in
groupby_trans_primitives
for why the built-in elementwise transforms cannot.