-
Notifications
You must be signed in to change notification settings - Fork 1
Best Practices
Generally speaking, it is best to append new versions of features rather than trying to modify them in-place. Generic Delta Lake operations (updates, merges, upserts) are supported, but they carry higher complexity and should be used only when necessary.
The versioning model makes re-appending cheap and safe:
# Add an updated version of your features — old version is preserved
lake.add_features(updated_feature_df, owner="my-team")
# You now have two versions — the old one is still readable
print(lake.feature_metadata_frame().collect())By default, read() returns the latest version of each feature. To read an earlier version, filter the metadata
frame first:
# Read only the original version (version == 1)
meta = lake.feature_metadata_frame().filter(pl.col("version") == 1).collect()
result = lake.read(meta)Each _add() operation, including calls through add_features() and add_targets(), scans the global index once and
appends newly discovered index rows once. Every call also pays fixed dispatch, metadata, and storage-commit overhead, so
the shape and number of writes can matter more than the total row count.
Prefer wide-and-shallow writes over tall-and-skinny writes. Here, wide means batching related value columns and all secondary-key values for a primary-sort-key period; shallow means writing fewer complete periods at a time. Avoid splitting the same period across many calls.
Representative object-storage benchmarks for one feature group and one primary-sort-key period show the per-call cost:
| Write shape | Execution mode | Time |
|---|---|---|
| 1,000 features × 1,000 secondary-key values | Ray | ~4 s |
| 1,000 features × 5,000 secondary-key values | Ray | ~6.5 s |
| 1,000 features × 5,000 secondary-key values | Local | ~9 s |
| The same 5,000 values split across two local calls | Local | ~14 s |
| Ten local calls of 1,000 values each | Local | 55+ s |
For backfills, build blocks containing every secondary-key value for each primary-sort-key period, include as many complete periods as fit comfortably in memory, and write each block in one call. Do not repeatedly batch the same period by entity. Keep unrelated owners or update cadences in separate feature groups even when batching writes.
If incremental weekly additions leave related features fragmented across many small feature-group tables, call
consolidate_feature_groups() after the features share an update cadence. Consolidation reduces the number of table
scans required by future reads without changing feature values.
Every add_* call accepts an owner string. Use this to track which team, pipeline, or experiment produced a feature:
lake.add_features(df_alpha, owner="alpha-team")
lake.add_features(df_beta, owner="beta-experiment-2024-06")You can then read only a specific team's features:
alpha_meta = lake.feature_metadata_frame().filter(pl.col("owner") == "alpha-team").collect()
result = lake.read(alpha_meta)Gamma Lake writes one Delta table per add_features() call. Features written in the same call share a Delta table
(a feature group). Design your feature groups around:
- Update frequency — features updated daily should be in the same group; intraday features in a separate group
- Ownership — features owned by the same team are natural candidates for the same group
- Size — very wide groups (thousands of columns) can be split for better read performance
Reads always select the minimum set of Delta tables required for the requested feature names, so smaller groups improve selective read performance.
When iterating locally — unit tests, notebooks, small experiments — set run_on_ray_cluster=False to skip Ray
dispatch overhead:
lake = GammaFeatureLake(base_path="/tmp/my_lake", run_on_ray_cluster=False)Switch back to run_on_ray_cluster=True (the default) for production or large-scale workloads where parallel writes
and reads provide significant speedups.
The primary_sort_key (default: "timestamp") is used for range filtering in all reads. Ensure it is:
-
Present in your index schema — the
initialize()call will raise if it is missing - Monotonically useful for range queries — dates and timestamps are natural choices
-
Consistent — changing
primary_sort_keyon an already-initialised lake is not supported
All read methods accept start and end parameters that are pushed down to the Delta table scan:
result = lake.read(["momentum", "vol_20d"], start="2023-01-01", end="2023-12-31")Always provide date ranges when reading from large lakes to avoid scanning all partitions.
lake.index_frame().collect() returns the full master index. This can be very large in production. Use it for
diagnostics and small-scale inspection only. For range-bounded reads, use the read() API with start/end instead.
If you are creating throwaway lakes for tests or experiments, always clean up the base directory afterwards:
import tempfile, shutil
tmp = tempfile.mkdtemp()
try:
lake = GammaFeatureLake(base_path=tmp, run_on_ray_cluster=False)
lake.initialize(schema)
# ... work ...
finally:
shutil.rmtree(tmp)Or use tempfile.TemporaryDirectory() as a context manager (see examples/quickstart.py).
| Method | Use when |
|---|---|
add_features() |
Standard features updated on a regular schedule |
add_targets() |
Labels / target variables (stored separately, read together) |
add_as_of_features() |
Point-in-time safe features (e.g., fundamental data with publication lag) |
add_sparse_features() |
Infrequently updated features (e.g., quarterly earnings) |
add_runtime_computed_features() |
Features derived from stored features at read time (no extra storage) |
add_runtime_transforms() |
Standard row-local transforms over stored features at read time |
Set enable_runtime_computed_features=True only when the lake's feature metadata is trusted. The opt-in is required both
to register runtime transforms and to read them.
add_runtime_transforms(features, transforms, owner) creates the cross-product of the supplied lists with deterministic
<feature>__<transform> names. Supported transforms are abs, missing, nan, negative, nonzero, positive,
reciprocal, sign, signed_log1p, and squared. Numeric transforms cast inputs and outputs to Float64 and propagate
nulls; reciprocal also maps zero to null. missing, nan, and nonzero are boolean. positive and negative clamp to
the non-negative part of the value and its negation, respectively. sign returns -1, 0, or 1, and signed_log1p computes
sign(x) * log1p(abs(x)).
This wiki is autogenerated. To made updates, open a PR against the original source file in docs/wiki.
Get Started
Developer Guide