Skip to content

Configuration reference

One YAML file describes your schema and what to synthesize. The annotated skeleton, then every key in detail:

target: customers # required — entity alias features are FOR
max_depth: 2 # required — relationship traversal depth
intervals: [P7D, P30D] # required — ISO-8601 rolling windows
aggregations: [count, mean] # optional — omit for the curated default set
transformations: [identity] # optional — omit for the curated default set
as_of_boundary: exclusive # optional — inclusive (default) | exclusive
entities: # required
- alias: customers
table: customers # fully-qualified name works too (schema.table)
id: customer_id
temporal_ix: signup_date
variables:
age: {type: numeric}
relationships: # optional
- parent: {entity: customers, key: customer_id}
child: {entity: orders, key: customer_id}

Featurizer("config.yaml") validates on load — structural checks, value checks (ISO-8601 durations, known variable types), semantic checks (target exists, relationship endpoints exist, temporal-join requirements) and best-practice warnings (max_depth > 5, more than 10 intervals). Unknown primitive names get a “did you mean?” from the registry. Disable with Featurizer("config.yaml", validate=False) for legacy configs.

keyrequiredmeaning
targetyesthe entity alias the output matrix is indexed on: one row per (as_of_date, target id)
max_depthyeshow many relationship hops the planner traverses from the target
intervalsyesISO-8601 durations (P7D, P1M, P1Y…). Each one adds a windowed variant of every aggregation, on top of the lifetime variant that is always emitted — see Column budget
aggregationsnosubset of registered aggregations to apply — see the primitives reference; omit (or null) for the curated default set. An explicit [] suppresses the aggregation layer: zero aggregation features (since v1.0.1)
transformationsnosubset of registered transformers; omit (or null) for the curated default set. An explicit [] suppresses the transform layer — features pass through unchanged, identical to [identity] (since v1.0.1)
as_of_boundarynoinclusive (events at the as-of date count) or exclusive (strictly before)
as_of_datesno{id_column: <name>} — declares that your as_of_dates table pairs each date with target ids, so the matrix holds only those pairs. See Paired cohorts
entitiesyesthe tables — see below
relationshipsnoforeign-key links — see below
spatial_relationshipsnosecond-table spatial features — see below
graph_relationshipsnonative 1-hop graph features over an edge table (0.9.0) — see below

The runtime also expects an as_of_dates table (one as_of_date column) in the database at execution time — it is the outer spine every feature is computed as of. By default every target row is emitted under every date.

When each date has its own set of entities, add a column holding the target’s id to that table and name it:

as_of_dates:
id_column: cohort_id # a column of YOUR as_of_dates table

The matrix then has one row per (as_of_date, id) pair in the table, with the same values the dense run gives for those pairs. validate checks the block’s shape only. It has no database connection, so a column name that does not exist surfaces as a PostgreSQL error when the query runs. Without the block the rendered SQL is byte-identical to what it was before the key existed. Paired cohorts covers when to use it and what it saves.

entities:
- alias: facilities # short name used in CTEs and feature names
table: clean.facilities # the actual table (may be schema-qualified)
id: facility_id # primary key (optional, required for targets)
temporal_ix: license_date # event-timestamp column (optional)
spatial_ix: location # PostGIS point column (optional; spatial pass)
variables:
risk: {type: numeric}
facility_type:
type: categorical
role: categorical # one-hot against a FIXED vocabulary
vocabulary: [Restaurant, Grocery Store, School]
name:
type: text
role: identifier # carried through, never a feature
  • temporal_ix is what makes features point-in-time correct: interval aggregations and as-of joins filter on it, and an entity’s rows dated after an as-of date are not read for that date. An entity without one contributes only static (non-windowed) features.
  • On the target, temporal_ix also decides which rows a date returns. A target row dated after an as-of date is not emitted under it, so a customer who signs up in August has no row for an as-of date in January, and an as-of date earlier than every row returns nothing. Releases up to 1.2 emitted those rows. For every target row under every date, leave temporal_ix off the target; to choose among the rows that exist, use as_of_dates: {id_column}.
  • id is what a window walks. Window transformers (lag_*, cum_*, rolling_*, ema_*, diff, …) partition by the entity’s id and walk its rows in the entity’s row order: temporal_ix, then the other identifier columns, then every declared variable. Two rows on one timestamp therefore come out in the same order on every read (since 1.3.0). An event table declared with the id of the row it belongs to (id: customer_id on orders) walks that customer’s events; declared with its own unique key it walks one-row partitions, and a lag over it is NULL; with no id the window transformers emit nothing for it.
  • Variable type: numeric, categorical, text, boolean, date, timestamp, index or vector. Types decide which aggregations/transformers apply.
  • role: categorical + vocabulary one-hot encodes a direct categorical into "<alias>.<col>=<value>" 0/1 columns against the declared vocabulary (or the column’s PostgreSQL ENUM labels if you omit vocabulary). Deliberately split-blind and fit-free: featurizer never learns a vocabulary from data — learned (train-only) encodings are the consumer’s job.
  • role: identifier excludes a column from the feature output while keeping it available as a key.
  • Column names are read the way PostgreSQL reads them. A name you could write without quotes in SQL (totalAmount) folds to lower case, so it matches a column created by ordinary, unquoted DDL. A name you could not write bare (Amount USD, MEAN(games.goals)) or a reserved word (order) is quoted for you. To name a column whose stored name really has capitals, put the double quotes inside the YAML string: '"entityId"'. Feature names keep the spelling you declared, without the quotes. The rule covers every key that names a column: id, temporal_ix, spatial_ix, variables, relationship keys, peer_groups, and the edges of a graph relationship. table is the exception. It is taken as written, because it may carry a schema (clean.facilities).

Compare each entity against peers sharing a categorical:

entities:
- alias: facilities
# …
peer_groups:
- by: facility_type # required — the grouping categorical
measures: [risk_score] # optional — numeric columns to compare

(peer_group: {by: …} is sugar for a one-element list.) This synthesizes PEER_GROUP_SIZE, PEER_EVENT_RATE, and per-measure PEER_MEAN / PEER_ZSCORE / PEER_PCTILE / EGO_MINUS_PEER_MEAN features.

relationships:
- parent: {entity: customers, key: customer_id}
child: {entity: orders, key: customer_id}

Backward traversal (parent ← child) applies aggregations over the child rows per parent; forward traversal pulls parent attributes onto the child. Parent and child keys may have different names — declare each side’s own column.

target: patients
relationships:
- parent: {entity: risk_assessments, key: patient_id} # the timestamped lookup
child: {entity: patients, key: patient_id} # the entity receiving the value
temporal:
mode: as_of # the only mode
grace: P30D # optional — look back at most this far
child_timestamp: recorded # optional — see below

Renders a left join lateral … order by <timestamp> desc limit 1: for each row of the child, the most recent parent row dated at or before that child row’s own temporal_ix, and no older than grace when one is given. This is the point-in-time join for slowly-changing state — a patient’s latest risk assessment at admission, a product’s price at purchase.

Direction matters, and it is the opposite of an aggregation. The lookup table is the parent; the entity that receives the value is the child. The block is read only on that forward transfer, where a parent’s value is carried onto its child. Declared the other way round — the target as parent of the timestamped table — the relationship is an aggregation, the temporal: block is discarded, and validate warns:

The 'temporal' block on relationship 'patients -> care_plans' has no effect. …

Nothing leaks in that case: an aggregation bounds its child rows on as_of_date with or without the block. What is lost is grace, so the query is stricter than the config asked for. Delete the block, or re-orient the relationship if an as-of lookup is what you meant.

grace is a lookback cap, never a look-ahead: grace: P30D renders <lookup timestamp> >= <child timestamp> - interval 'P30D'. No value of grace lets the join read a row dated after the child’s timestamp.

child_timestamp names the column on the lookup (parent) entity to order and bound by, for when that differs from the entity’s declared temporal_ix. The name is historical; the key is frozen under the 1.0 API. The column does not have to be declared as a variable of the lookup entity; featurizer carries it for the join.

What a lookup transfers. The looked-up row’s variables, and nothing else: its id, keys and temporal index stay behind. A parent of the receiving entity can aggregate the transferred values, and an interval on such an aggregation is cut on the receiving entity’s temporal_ix — MAX(events.level|interval=P90D) is the maximum over the events of the last 90 days of the level in force at each event.

Windows over a transferred value walk the target’s timeline. A window transformer (lag_*, rolling_*, cum_*, ema_*, …) applied to a feature brought in by a transfer — as-of or plain — orders by the receiving entity’s temporal_ix, not the source’s. The transfer has already collapsed the source’s history to one row per receiving row, so the value is “the source’s state as of this row” and the only timeline left to walk is the receiver’s. The two windows mean different things and both are kept: CUM_SUM(plans.cost) is computed on the source side and walks the source’s history; CUM_SUM(plans.CUM_SUM(plans.cost)) is the same window applied again after the transfer, and walks the receiver’s rows.

Known boundary (v1.0): the correlated LATERAL cannot be flattened into temp-table shards. If the entity carrying an as-of join also grows past the oversized-CTE materialization threshold (issue-#7 sharding), featurizer raises NotImplementedError — loudly, instead of emitting subtly-wrong SQL. Workarounds: narrow that entity’s primitive/interval breadth so its synth stays under the limit, or attach the as-of relationship to the target entity (the target is never shard-materialized). See the FAQ entry.

With PostGIS and entities that declare a spatial_ix:

spatial_relationships:
- name: nearby # feature-name token
left: facilities # target-side entity alias
right: facilities # other entity (self-joins allowed)
within_m: 500 # colocation radius, meters
bandwidth_m: 10000 # KDE bandwidth, meters

Synthesizes COLOCATION_COUNT, DISTANCE_TO_NEAREST, and KDE_INTENSITY features between the two tables.

The taxonomy’s cheap graph tier, in pure SQL — as-of degree and 1-hop neighbour aggregates over an edge stream, no Python and no [bridge] dependency:

graph_relationships:
- name: contacts # required — feature-name token
left: facilities # required — node entity; features attach here
right: states # optional — neighbour-STATE entity (default: left)
edges: # required — the edge stream
table: contact_edges # may be schema-qualified
source: src_id # edge source node-id column
target: dst_id # edge target node-id column
timestamp: contacted_at # REQUIRED — the pass is as-of by construction
directed: true # optional — false unions both directions
measures: [risk_score] # optional — default: right's numeric variables
shares: [flagged] # optional — default: right's boolean variables
features: [degree, neighbour_mean, neighbour_share] # optional — default: all

Synthesizes, per left row:

  • DEGREE(<name>) — edge incidences knowable as-of, plus one windowed DEGREE(<name>|interval=P3M) variant per configured interval;
  • NEIGHBOUR_MEAN(<name>.<measure>) — mean of each numeric measures column over the 1-hop neighbours’ state rows;
  • NEIGHBOUR_SHARE(<name>.<flag>) — share of each boolean shares column (e.g. the flagged-neighbour rate).

Two causal bounds apply together: the edge timestamp and — when right declares a temporal_ix — the neighbour state’s timestamp, both cut at the as-of date. That is why edges.timestamp is required: a static edge table cannot be causally bounded. The pass is deliberately 1-hop only; 2-hop aggregation pulls neighbours’ future labels (the classic temporal-GNN leakage) and is not offered. A neighbour reached by two knowable edges (or carrying several knowable state rows) weighs proportionally in the mean/share.

For heavier graph features (centralities, community membership) see the bridge cookbook — including the Path-2 move where near-duplicate or co-mention text induces the edge table this block then consumes.

aggregations: [count, sum, mean, gap_cv, entropy]
transformations: [identity, abs, ln]

Both keys accept any registered name — browse the primitives reference or run python -m featurizer list-primitives. Omitting a key applies the full default set; note that all defaults on a wide schema can synthesize past PostgreSQL’s 1664-column row limit, which featurizer handles by sharding the output into column groups automatically (and warns when a config predicts a pathological query plan).

identity is the transformer that keeps a column as it is. Leave it out of transformations and the output holds only what the listed transformers produce: transformations: [abs] emits ABS(...) columns and no raw ones. Aggregations are not affected. A parent still aggregates its children’s raw variables, so ABS(customers.SUM(orders.amount)) is computed whether or not identity is listed; SUM(orders.amount) itself is output only when it is.

Each aggregation yields I + 1 columns, not I

Section titled “Each aggregation yields I + 1 columns, not I”

For every aggregated child column the planner calls each aggregator once with no interval, then once per entry in intervals. With I intervals that is I + 1 columns: the lifetime value plus one per window.

intervals: [P30D, P90D] # I = 2
aggregations: [sum, mean]

renders, for a child column orders.amount, six columns and not four:

SUM(orders.amount) SUM(orders.amount|interval=P30D) SUM(orders.amount|interval=P90D)
MEAN(orders.amount) MEAN(orders.amount|interval=P30D) MEAN(orders.amount|interval=P90D)

The lifetime column cannot be switched off. intervals: [] still emits it, and gives exactly one column per aggregation. The windowed variants are skipped only for a child entity that declares no temporal_ix, because there is nothing to window on. Budget the aggregation layer as

child columns × aggregations × (I + 1)

and multiply by the transformers applied on top. At I = 2 the lifetime column is a third of the aggregation layer, which is easy to miss until you count the output.

The limit that binds a numeric matrix is row size, not column count

Section titled “The limit that binds a numeric matrix is row size, not column count”

Three limits in this documentation are column counts: PostgreSQL’s 1664 entries per target list, its 1600 columns per table, and featurizer’s default of 1400 columns per output group. A matrix of fixed-width numbers reaches a fourth limit first. A PostgreSQL heap row must fit one 8 kB page (about 8160 usable bytes), and double precision and bigint are 8 bytes each and cannot be moved out of line by TOAST. That puts the ceiling near 1,000 numeric columns per table, under all three column counts. A 1,396-column float8 table fails with row is too big: size 11176, maximum size 8160.

to_tables() checks this before it writes. It estimates each group’s row width and re-partitions into narrower tables when a group would exceed an 8000-byte budget, which with two key columns allows 979 feature columns per table. Reading the matrix with to_dataframe(), to_arrow() or to_parquet() is not affected, because PostgreSQL streams a result row and never stores it in a page.

The check protects only the tables featurizer writes. If you take Featurizer.query and write the result yourself with create table … as or COPY, nothing pre-flights the row width and PostgreSQL reports the error at the first over-wide row. Split the write at the same 979 feature columns per table, carrying (as_of_date, <target id>) in each, or call to_tables() and read its groups. See the FAQ entry and Staying under PostgreSQL’s limits.

  • Unknown aggregation 'avg' → did you mean mean? — names must match the registry exactly.
  • Invalid interval 'P1' → intervals are full ISO-8601 durations (P1D, P1M, P1Y).
  • Target entity 'X' not found → target must equal one entity’s alias.
  • temporal block requires the child entity to declare temporal_ix (or child_timestamp) — an as-of join needs a timestamp to order by.