Walkthrough: zero to a feature matrix
featurizer implements Deep Feature Synthesis (Kanter & Veeramachaneni, IEEE DSAA 2015) for temporal, relational data — compiled to pure PostgreSQL. You describe entities and relationships in YAML; featurizer plans, renders, and (optionally) executes SQL that computes hundreds of point-in-time-correct features. The φ formalism explains why this construction cannot leak.
This walkthrough follows example 01 (an e-commerce scenario: customers and their orders). Clone the repo to run every step verbatim:
git clone https://github.com/ccd-ia/featurizer.gitcd featurizer1. Install
Section titled “1. Install”featurizer is distributed through GitHub releases — deliberately no PyPI. Pin a tag:
# with uv (recommended)uv add "featurizer @ git+https://github.com/ccd-ia/featurizer.git@v1.0.0"
# or with pippip install "featurizer @ git+https://github.com/ccd-ia/featurizer.git@v1.0.0"Add the [parquet] extra (featurizer[parquet] @ …) if you want Arrow/Parquet
output. Working inside the cloned repo, uv sync is all you need.
2. The data
Section titled “2. The data”Three tables. Two are yours; one small one is featurizer’s contract:
| table | role |
|---|---|
customers | the target entity — one row per customer (customer_id, signup_date, country, age) |
orders | a child event stream — one row per order (order_id, customer_id, order_date, amount, status) |
as_of_dates | one as_of_date column: the snapshot dates you want features as of |
The column featurizer cares most about is each entity’s temporal_ix — the
event timestamp (order_date for orders). Every generated feature is computed
using only rows with temporal_ix <= as_of_date, which is what makes the
matrix point-in-time correct: a feature for June 1st cannot see June 2nd,
so nothing leaks from the future into training data.
3. One YAML file
Section titled “3. One YAML file”The whole configuration for this scenario
(examples/01-basic-aggregations/config.yaml):
target: customers # the entity features are FORmax_depth: 2 # how far to traverse relationships
intervals: # rolling windows, ISO-8601 durations - P7D # last 7 days - P30D # last 30 days
aggregations: # a focused set keeps the tutorial readable — [count, sum, mean, min, max, stddev, nunique]transformations: [identity, abs]
entities: - alias: customers id: customer_id table: customers temporal_ix: signup_date variables: country: type: categorical role: categorical # one-hot against a FIXED vocabulary — vocabulary: [AU, CA, DE, FR, UK, US] # split-blind, fit-free age: type: numeric
- alias: orders id: order_id table: orders temporal_ix: order_date variables: amount: {type: numeric} status: {type: categorical}
relationships: - parent: {entity: customers, key: customer_id} child: {entity: orders, key: customer_id}Omit aggregations:/transformations: and featurizer applies its full default
set — 67 aggregations × 83 transformers, which is usually far more than a
tutorial (or PostgreSQL’s 1664-column row limit) wants.
4. Render the SQL — no database needed
Section titled “4. Render the SQL — no database needed”uv run python examples/01-basic-aggregations/run_example.py --show-sqlor in Python:
from featurizer import Featurizer
f = Featurizer("examples/01-basic-aggregations/config.yaml")print(f.query) # a single PostgreSQL query — inspect before you runFor this config that is one 56-line, ~19 KB query. Its skeleton is worth reading once, because every featurizer query has this shape:
select aod.as_of_date, t.*from as_of_dates as aodcross join lateral ( with orders_synth as (…), -- child columns, selected orders_transform as (…), -- transformers applied (abs(amount), …) orders_aggs_for_customers as ( select customer_id, count(order_id) as "COUNT(orders.order_id)", count(order_id) filter (where daterange((aod.as_of_date - interval 'P7D')::date, aod.as_of_date::date, '[]') @> order_date::date) as "COUNT(orders.order_id|interval=P7D)", sum(amount) as "SUM(orders.amount)" -- … one column per (aggregation × variable × interval) from orders_transform where order_date <= aod.as_of_date -- the leakage guard group by customer_id ), customers_synth as (…), -- join aggregates onto the target customers_transform as (…) -- target-level transformers + one-hots select * from customers_transform) as tThe cross join lateral re-evaluates the feature CTEs per as-of date, and
the where order_date <= aod.as_of_date guard plus interval filter clauses
are the point-in-time semantics, visible in plain SQL.
5. Execute against PostgreSQL
Section titled “5. Execute against PostgreSQL”featurizer emits PostgreSQL-dialect SQL, so execution needs a real PostgreSQL. The repo manages a throwaway one:
just db-up # ephemeral postgres:16uv run python examples/01-basic-aggregations/create_data.py # seed example_01uv run python examples/01-basic-aggregations/run_example.py --execute(Any PostgreSQL works: set DATABASE_URL or the PG* variables instead.)
In Python, materialization is one call:
df = f.to_dataframe()df.shape # (1200, 105) — 100 customers × 12 as-of dates, 105 featuresThe frame is indexed by (as_of_date, customer_id): the same customer appears
once per snapshot date, with features computed from only what was knowable at
that date.
6. Read your features
Section titled “6. Read your features”Column names are self-describing — AGG(entity.column|interval=WINDOW):
COUNT(orders.order_id|interval=P7D) orders in the last 7 daysSUM(orders.amount|interval=P30D) spend in the last 30 daysMEAN(orders.ABS(orders.amount)) mean of a transformed child columncustomers.country=US fixed-vocabulary one-hot (0/1)Two conventions to know:
- NULL is signal. A customer with no orders in the window gets
NULL(not 0) forMEAN(orders.amount|interval=P7D)— “no data” and “zero” are different facts. Opt into imputation explicitly viato_dataframe(impute=True)when you want it. - Every column is documented by the feature manifest.
f.feature_manifest(also persisted as a<target>_manifesttable byto_tables()) carries, per column: the full label, kind (variable / one_hot / derived), lineage (depth, parents, source column, interval) and a generated plain-language description.
7. Choose your primitives
Section titled “7. Choose your primitives”List everything the registry offers — 67 aggregations and 83 transformers:
uv run python -m featurizer list-primitives --type agg --categoryuv run python -m featurizer list-primitives --type transform --show-sqlThen select per config, as example 01 does. Beyond the basics there are
ordered-set aggregations (median, p90…), temporal-gap statistics
(gap_mean, burstiness), categorical distributions (entropy, hhi,
gini), and sequence features (ngram_2_freq, longest_streak) — the
primitives reference lists all 150 with
SQL examples.
8. Point-in-time joins (as-of)
Section titled “8. Point-in-time joins (as-of)”When a parent record should contribute its most recent state as of each child row’s own date — a patient’s latest risk assessment at admission, a product’s price at purchase — declare the relationship temporal:
relationships: - parent: {entity: risk_assessments, key: patient_id} # the timestamped lookup child: {entity: patients, key: patient_id} # receives the value temporal: mode: as_of grace: P30D # optional: only look back this farfeaturizer renders a left join lateral … order by … limit 1 that picks, for
each child row, the newest parent row dated at or before that row’s own
temporal_ix. The lookup table is the parent here — the reverse of an
aggregation, where the target is the parent. A temporal: block on an
aggregation relationship is discarded, and validate warns about it.
Tutorial 02 (healthcare) puts the two directions side by side —
examples/02-temporal-joins.
Two more things the engine decides for you, since 1.3.0. A target that
declares a temporal_ix is read as of each date too, so a row dated after an
as-of date is not emitted under it; leave temporal_ix off the target to get
every row under every date. And two rows on one timestamp are always walked in
the same order (identifiers, then the declared variables), so a lag or a
transition never depends on how the table was loaded.
How a feature is computed explains both.
9. When each date has its own cohort
Section titled “9. When each date has its own cohort”By default the matrix holds every target row under every as-of date, which is
the shape temporal cross-validation wants. Some cohorts are defined by the
date instead: the day’s arrivals, the events of a date, the customers active
in the month before. Give as_of_dates a second column holding the target’s
id, one row per (date, id) pair, and declare it:
create temp table as_of_dates (as_of_date date, cohort_id int);insert into as_of_dates values ('2024-02-01', 1), ('2024-02-01', 2), ('2024-03-01', 2);as_of_dates: id_column: cohort_idThe matrix then has one row per pair, and the children are read for that cohort only, so a date no longer aggregates the history of every entity to keep a twentieth of it. On the 22,169-facility validation database, 272 features over 6 monthly dates take 5.0 s paired against 21.3 s dense (measured 2026-09-21, PostgreSQL 16). Values equal the dense run’s on the pairs. Paired cohorts has the recipes for the pair table, including the one-row-per-event shape.
10. Visualize the matrix
Section titled “10. Visualize the matrix”The optional [viz] extra adds FeaturizerViz — distribution, missingness,
correlation, embedding, and per-entity temporal diagnostics on the materialized
matrix:
from featurizer import FeaturizerViz
viz = FeaturizerViz.from_featurizer(f, df=df)viz.plot_feature_distributions(kind="violin")viz.plot_missing_heatmap() # NULL-as-signal, made visibleReal output from a live 177k-row × 272-feature matrix:


11. Where next
Section titled “11. Where next”- The tutorials: five executed notebooks, from basic aggregations to custom primitives — examples/ (rendered versions join this site shortly).
- The theory: φ — the formalism behind feature creation, with an interactive explorable.
- References: the full
primitive registry and the complete
config.yamlschema. - Proof it holds up: every release is validated against three live databases — the v1.0.0 reports.