anofox-forecast-data-prep
Data preparation for the anofox_forecast DuckDB extension — filling gaps, imputing nulls, dropping bad series, differencing, detrending, hierarchical key operations. Use when preparing raw time series
SKILL.mdplugins/anofox-forecast/skills/anofox-forecast-data-prep/SKILL.md
GitHubAnofox Forecast — Data Preparation Cheat Sheet
Extension: anofox_forecast v2026.09.26 (Rust crate anofox-forecast v0.16.2) | DuckDB: v1.4.5 LTS / v1.5.4+ | Dual naming: ts_* and anofox_fcst_ts_* (identical)
Prep raw time series so downstream forecasting / CV has clean input: no gaps, no unwanted NULLs, no degenerate series, correct hierarchy.
Critical gotcha — materialise between _by steps
_by table functions do not chain in CTEs — under parallel execution they silently return 0 rows. Always CREATE TABLE between pipeline steps.
-- BROKEN (silent 0 rows under parallelism) -- `raw` is (product_id, ds, y):
WITH step1 AS (SELECT * FROM ts_fill_gaps_by('raw', product_id, ds, y, '1d'))
SELECT * FROM ts_fill_nulls_const_by('step1', product_id, ds, y, 0.0);
-- CORRECT:
CREATE OR REPLACE TABLE step1 AS SELECT * FROM ts_fill_gaps_by('raw', product_id, ds, y, '1d');
CREATE OR REPLACE TABLE step2 AS SELECT * FROM ts_fill_nulls_const_by('step1', product_id, ds, y, 0.0);
Gap filling
ts_fill_gaps_by
Insert missing date rows (NULL value) so every series has a complete regular grid.
ts_fill_gaps_by(source VARCHAR, group_col COLUMN, date_col COLUMN, value_col COLUMN,
frequency VARCHAR) → TABLE
No params — pure frequency-driven grid completion. Inserted rows have NULL in value_col; use ts_fill_nulls_*_by next to impute.
Frequencies: '1d', '1h', '30m', '1w', '1mo', '1q', '1y' (Polars-style) or '1 day' (DuckDB INTERVAL) or raw int (days).
Month-end rule (v2026.10.16, D-09): monthly/quarterly/yearly gap fill preserves
calendar alignment — a month-end series stays month-end (2024-01-31 → inserted
2024-02-29 → 2024-03-31, not the 1st), a 1st-of-month series stays on the 1st, and
any other day-of-month keeps its own day (clamped to the target month's length). Same
rule ts_fill_forward_by uses when forward-filling with a calendar frequency.
CREATE TABLE gaps_filled AS
SELECT * FROM ts_fill_gaps_by('raw', product_id, ds, y, '1d');
-- Month-end series: the inserted row lands on 2024-02-29, not 2024-02-01
CREATE TABLE month_end_raw AS
SELECT * FROM (VALUES
(1, TIMESTAMP '2024-01-31', 10.0),
(1, TIMESTAMP '2024-03-31', 30.0)
) t(product_id, ds, y);
CREATE TABLE monthly_filled AS
SELECT * FROM ts_fill_gaps_by('month_end_raw', product_id, ds, y, '1mo');
NULL imputation
Table macros. Signatures:
| Function | Signature |
|---|---|
ts_fill_nulls_forward_by(source, group_col, date_col, value_col) |
Last observation carried forward (LOCF) — one-directional: a leading NULL run stays NULL |
ts_fill_nulls_backward_by(source, group_col, date_col, value_col) |
Next observation carried backward (NOCB) — mirrors forward: a trailing NULL run stays NULL |
ts_fill_nulls_mean_by(source, group_col, date_col, value_col) |
Per-group mean, computed in Rust (NaN counts as missing too; all-missing group stays NULL) |
ts_fill_nulls_const_by(source, group_col, date_col, value_col, fill_value) |
Constant (e.g. 0.0 for retail counts) |
ts_fill_forward_by(...) |
Streaming per-group forward fill; also ts_fill_forward_operator for windowed variants |
ts_mark_unknown_by |
Marks values matching a sentinel as unknown |
ts_fill_unknown_by |
Fills previously-marked unknowns |
There is no SQL linear-interpolation function (D-14, G26). ts_fill_nulls_forward_by/
ts_fill_nulls_backward_by fill in ONE direction only — chain both (forward then backward, or
vice versa) if you need every NULL filled regardless of position. Internally, where the
extension interpolates (historical exogenous regressors, the panel forecasting grid), interior
gaps are linear but leading/trailing gaps take the NEAREST observed value (constant edge
extension, never extrapolated) — this is documented, unchanged behaviour, not a bug.
-- Retail: zero-fill absent-day rows produced by ts_fill_gaps_by
CREATE TABLE nulls_filled AS
SELECT * FROM ts_fill_nulls_const_by('gaps_filled', product_id, ds, y, 0.0);
Series filtering — drop degenerate cases
| Function | Drops when |
|---|---|
ts_drop_constant_by |
Rust is_constant, SCALE-RELATIVE (D-13, G25): max - min <= 1e-10 * max(1, max|x|) over present values, or fewer than two present values (incl. all-NULL groups) — e.g. [1e12, 1e12 + 0.001] IS constant at that scale |
ts_drop_short_by(source, group_col, min_length) |
Series length < min |
ts_drop_gappy_by(source, group_col, value_col, max_gap_ratio) |
Gap ratio exceeds threshold (0.1 = 10 %) |
ts_drop_zeros_by |
All zero values |
ts_drop_leading_zeros_by |
Trim leading zeros (product not yet launched) |
ts_drop_trailing_zeros_by |
Trim trailing zeros (product discontinued) |
ts_drop_edge_zeros_by |
Trim both leading and trailing zeros |
-- Named distinctly from the global `clean` fixture used elsewhere in this skill.
CREATE OR REPLACE TABLE short_filtered AS
SELECT * FROM ts_drop_short_by('nulls_filled', product_id, 24); -- ≥ 2 years monthly
Transforms
ts_diff_by(source, group_col, date_col, value_col, diff_order INTEGER, seasonal_lag := 1)— d-th order differencing in Rust (numpydiff(n=diff_order));diff_order=2is the true second differencey[t] - 2*y[t-1] + y[t-2]. Passseasonal_lag := kfor the old lag-k readingy[t] - y[t-k](breaking change from pre-Phase-12:diff_orderused to mean lag-k). One row per input row; firstdiff_order * seasonal_lagrows per group are NULL; any difference touching a NULL/NaN input is NULL (never an error).ts_detrend_by(source, group_col, date_col, value_col, method)— removes a fitted trend per group.method:'linear';'quadratic'(alias'polynomial','poly', degree 2);'cubic'(degree 3);'diff'/'diff2';'loess'(alias'lowess',O(n^2));'auto'(default, AIC-selects among the above). An unrecognised method name is a SQL error naming every valid choice, not a silent fallback.ts_validate_timestamps_by— check for out-of-order / duplicate timestamps.
CREATE TABLE stationary AS
SELECT * FROM ts_diff_by('clean', product_id, ds, y, 1); -- first-order difference (same as lag-1)
Hierarchical key operations
Retail and IoT panels usually have composite keys (region × store × item). These macros manipulate them:
| Function | Purpose |
|---|---|
ts_combine_keys |
Concatenate multiple ID columns into one, sep-delimited |
ts_split_keys |
Reverse — split back into components |
ts_aggregate_hierarchy |
Sum-aggregate up a hierarchy level (region+store+item → region+store) |
ts_validate_separator |
Verify separator string isn't present in the source columns |
-- Setup: raw_hierarchy(ds, y, region_id, store_id, item_id) -- ts_combine_keys /
-- ts_aggregate_hierarchy take a subquery source (date_col, value_col, id_cols...)
-- and a params MAP, not bare positional group columns.
CREATE OR REPLACE TABLE raw_hierarchy AS
SELECT
(DATE '2024-01-01' + INTERVAL (i) DAY) AS ds,
10.0 + (i % 7) AS y,
'R1' AS region_id, 'STORE01' AS store_id, 'SKU01' AS item_id
FROM range(0, 60) t(i);
-- Combine two IDs for grouping
CREATE OR REPLACE TABLE with_combined AS
SELECT * FROM ts_combine_keys(
(SELECT ds, y, region_id, store_id FROM raw_hierarchy),
MAP{'separator': '|'}
);
-- Aggregate to store level
CREATE OR REPLACE TABLE store_level AS
SELECT * FROM ts_aggregate_hierarchy(
(SELECT ds, y, region_id, store_id, item_id FROM raw_hierarchy),
MAP{}
);
Canonical prep pipeline
-- 1. Regularise the time grid
CREATE OR REPLACE TABLE p1 AS
SELECT * FROM ts_fill_gaps_by('raw', product_id, ds, y, '1d');
-- 2. Impute (retail → zero; continuous → forward)
CREATE OR REPLACE TABLE p2 AS
SELECT * FROM ts_fill_nulls_const_by('p1', product_id, ds, y, 0.0);
-- 3. Drop what can't be forecast
CREATE OR REPLACE TABLE p3 AS
SELECT * FROM ts_drop_short_by('p2', product_id, 30);
CREATE OR REPLACE TABLE clean AS
SELECT * FROM ts_drop_constant_by('p3', product_id, y);
STRUCT vs MAP params
Both work everywhere for functions that accept a trailing params argument. STRUCT is
recommended — keeps numeric params typed. Note ts_fill_gaps_by itself is a 5-argument
macro (source, group_col, date_col, value_col, frequency) — it takes no params/tolerance
argument at all; passing a 6th argument raises a Binder Error:
-- Correct 5-arg call (no params argument exists for this macro)
SELECT * FROM ts_fill_gaps_by('clean', product_id, ds, y, '1d');
-- Functions that DO take a trailing params argument accept either form:
-- STRUCT (recommended)
SELECT * FROM ts_forecast_by('clean', product_id, ds, y, 'AutoETS', 7, '1d', {seasonal_period: 7});
-- MAP (all strings)
SELECT * FROM ts_forecast_by('clean', product_id, ds, y, 'AutoETS', 7, '1d', MAP{'seasonal_period': '7'});
Empty params: MAP{} or {}.
Common patterns
- Kaggle-style split: prep on train + test together, then split at the cutoff. Prevents inconsistent gap-fill / drop decisions between splits.
- CV-ready input:
ts_cv_folds_byrequires the input to already be gap-filled and NULL-imputed. Always run the prep pipeline first. - JSON extension required for a subset of prep-related functions (period detection auto-loading, etc.). Enable
SET autoinstall_known_extensions=1; SET autoload_known_extensions=1;in your session.
See also: anofox-forecast-eda (quality check before prep), anofox-forecast-detection (fill gaps before detecting periods), anofox-forecast-backtest (cv_folds requires clean input), anofox-forecast-models (forecast requires clean input).
Reference docs:
docs/api/04-data-preparation.mddocs/guides/01-getting-started.md