Primitives¶
A primitive builds a narwhals expression, which DFS composes into features. The whole computation stays on your backend.
What ships with tusk¶
See the API reference for the full list of aggregation, transform, group transform and ordered transform primitives tusk ships.
Every time_since_* primitive needs more than its input columns. It also
requires a cutoff_time at apply time, since it measures its value against
that moment rather than deriving it from the rows alone. Ordered transform
primitives require a row_creation_time on the table.
See primitive coverage for how these line up against featuretools.
Every name above is also an importable class, so from tusk.primitives import
Year, CumSum works. They take the same form as a user-defined one, so Year
and Count are the same kind of object as anything you write yourself. See
Custom primitives.
Defaults¶
Passing agg_primitives=None or trans_primitives=None selects the
primitives featuretools uses by default, where tusk has them:
AGG_DEFAULTS for aggregation
and TRANS_DEFAULTS for
transforms.
The defaults exclude arithmetic primitives because arithmetic primitives build hundreds of features on wide tables.
Multi-output primitives¶
A multi-output primitive such as quantiles builds indexed columns:
QUANTILES__orders__quantity__0, __1, __2. A primitive takes one column
as input. These columns are therefore the end of their chain. They go into
the feature matrix, and no further primitive builds on them. Use one at any
depth.
Empty groups¶
Aggregating a group with no rows is the most surprising correct behavior in the library. It is worth stating plainly. After the left join, a customer with no orders gets:
| Primitive | Value | Why |
|---|---|---|
COUNT |
0 |
There are zero rows to count. |
N_UNIQUE |
0 |
Zero rows hold zero distinct values. A null counts as one value. A group of only nulls is therefore 1. |
SUM |
0 |
Zero is the additive identity for SUM. |
MEAN, MIN, MAX, STD, MEDIAN, QUANTILES, MODE |
null |
These are genuinely undefined over an empty set. Examples are 0/0 and the min or max of nothing. |
PERCENT_TRUE |
null |
This is undefined over an empty set, the same as MEAN. Within a non-empty group a null counts as false. An all-null group therefore computes to 0.0 rather than falling through to this default. |
N_TRUE |
0 |
Zero rows hold nothing to count. |
N_UNIQUE_DAYS, N_UNIQUE_DAYS_OF_CALENDAR_YEAR, N_UNIQUE_DAYS_OF_MONTH, N_UNIQUE_MONTHS |
0 |
Zero rows hold zero distinct values. A null date counts as one value. A group of only nulls is therefore 1. |
ANY_TRUE |
false |
No row is true. |
ALL_TRUE |
null |
A group of only nulls is true, as polars and duckdb answer, but a group with no rows gives null. |
IS_UNIQUE |
null |
There are no rows to compare. Nulls are values. A group of several nulls is therefore false. |
VARIANCE, SKEW, KURTOSIS, MAX_MIN_DELTA, FIRST_LAST_TIME_DELTA, PERCENT_UNIQUE, ENTROPY |
null |
These are undefined over an empty set. SKEW and KURTOSIS are also null for a group whose values do not vary. |
COUNT and SUM report 0 because a group with no rows has nothing to
count and nothing to add up. MEAN stays null, because there is no number
that is the average of nothing. Each value lives on the primitive as
default_value, so your own primitives set theirs the same way.
featuretools agrees on COUNT and SUM. It also leaves MEAN, MIN and
MAX null. It differs on N_UNIQUE. featuretools leaves an empty group as
NaN, where tusk reports 0 for the reason above. featuretools also
ignores a null value entirely, where tusk counts it as one distinct value.
Nulls in and and or¶
A null is an unknown value, not a third truth value. A null input therefore makes the answer null only when it could have changed the answer:
true |
false |
null |
|
|---|---|---|---|
AND true |
true |
false |
null |
AND false |
false |
false |
false |
AND null |
null |
false |
null |
OR true |
true |
true |
true |
OR false |
true |
false |
null |
OR null |
true |
null |
null |
false AND null is false because nothing the null value turns out to be
makes the conjunction true. This is what polars, duckdb and every SQL system
answer, and tusk builds the operator rather than working around it.
featuretools instead propagates the null in every one of those cells. NOT
agrees on both sides: the negation of a null is null.
Comparing two columns¶
The comparison primitives accept a pair of numbers or a pair of datetimes, never one of each. A primitive may declare several input shapes. Each shape matches as a whole:
equal and not_equal also accept a pair of booleans or a pair of strings.
A null on either side gives a null answer, as in SQL. A null value cannot be
shown equal to anything, nor greater than it. See primitive
coverage for how this holds up against featuretools,
column by column.
Labels are a separate case. equal_categorical and not_equal_categorical
compare Categorical and Enum columns. They compare the labels themselves
rather than their encodings. On polars, two Enum columns with different
member lists cannot be compared directly at all. A null label follows the
same rule as everything else on this page. It makes the comparison null
rather than simply unequal.
(F.HAS_DATE, F.HAS_DATE) matches a Datetime column or a Date column,
regardless of time zone. A table holding a Date column and a tz-aware
or tz-naive Datetime column will therefore build a comparison between
any two of them.
You can guard against this with db.validate().
How transforms are applied¶
trans_primitives takes every transform primitive, and synthesis applies each
one by its class:
- A row-wise transform
(
TransformPrimitive) runs on each row, reading only that row:absolute,month,add_numeric, … - A group transform primitive
(
GroupTransformPrimitive) runs within each foreign-key group:percentile, which ranks each value within its group. - An ordered transform primitive
(
OrderedTransformPrimitive) runs within each foreign-key group and reads the group's rows inrow_creation_timeorder, such ascum_sum. The API reference lists them all.
A group or ordered transform gives one feature per parent relationship of the
table, named after the foreign key it groups by, such as
CUM_SUM__amount__by__session_id. A running total or a rank over every row
would mix rows from different parent rows. It would also change with
whichever rows are in the dataset, such as a test split. Both effects are a
data-leakage risk. This is why it never runs across the whole table. It
looks only at the rows sharing its row's foreign key, the same rows an
aggregation sees.
A group does not always hold the rows of only one target row. Grouping by a shared parent, such as drivers when the target is customers, puts several customers' rows in one group, exactly as an aggregation over drivers does.
A primitive of your own picks its behavior the same way, through the class it subclasses. See choosing a transform base class.