Time Series Characterization¶
Purpose¶
Before any forecasting model is trained, ForecastAI profiles every time series through a six-stage characterization pipeline. The result is a SeriesCharacteristics record that drives automatic method routing, parameter selection, and data sufficiency filtering.
Why characterization matters: A single model cannot perform well on every type of demand pattern. Intermittent spare-parts demand behaves completely differently from seasonal consumer goods demand. Routing the wrong model wastes compute and produces poor forecasts. Characterization prevents this.
Implementation: files/characterization/characterization.py — class TimeSeriesCharacterizer
flowchart TD
A[Raw series\nn observations] --> B[Basic statistics\nmean, std, n_obs, date range]
B --> C{n_obs < 4?}
C -- yes --> Z[Minimal analysis\nfall through to recommendation]
C -- no --> D[1. Seasonality detection\nACF at candidate lags]
D --> E[2. Trend detection\nMann-Kendall test]
E --> F[3. Intermittency\nADI + CoV + zero_ratio]
F --> G[4. Stationarity\nAugmented Dickey-Fuller]
G --> H[5. Complexity scoring\n6-factor weighted composite]
H --> I[6. Data sufficiency\nmin_for_ml, min_for_deep_learning]
I --> J[Method recommendation\n_recommend_methods]
J --> K[SeriesCharacteristics record\nsaved to series_characteristics table]
Input Format¶
The characterizer expects a long-format DataFrame following the Nixtla convention:
| Column | Type | Description |
|---|---|---|
unique_id |
string | Series identifier (e.g., item_site_segment) |
ds |
datetime | Observation date |
y |
float | Demand quantity |
Stage 1: Seasonality Detection¶
Method: Autocorrelation Function (ACF) at candidate lag periods.
Pre-requisite gate: The series date span must be at least 2 years (730 days). Series with shorter history always receive has_seasonality = False. This prevents false positives on young data where one observed seasonal cycle could be noise.
Algorithm (_detect_seasonality):
- Compute the modal inter-observation interval from the date index.
- If there are calendar gaps (e.g., missing weeks), reindex the series to a uniform calendar grid. Gaps become
NaN; statsmodels handles them withmissing='conservative'(pairwise non-NaN products in the ACF calculation). - For each candidate period in the configured list (default
[4, 13, 26, 52]), test only ifperiod < n / 2andperiod >= 2. - Compute:
acf_values = acf(source, nlags=period, fft=True, missing='conservative') - A period is seasonal if
|acf_values[period]| > min_strength(default0.3). seasonal_strength= maximum ACF value across all detected periods.
Formula: For a series \(\{y_t\}\) of length \(n\), the sample ACF at lag \(k\) is:
Period \(k\) is considered seasonal when:
Candidate periods and their supply chain meaning:
| Period (weeks) | Frequency | Typical pattern |
|---|---|---|
| 4 | Monthly | Pay-cycle driven demand |
| 13 | Quarterly | Budget-cycle purchasing |
| 26 | Bi-annual | Mid-year / end-of-year spikes |
| 52 | Annual | Full-year seasonal cycle (summer/winter) |
Configuration:
Output fields: has_seasonality (bool), seasonal_periods (list of int), seasonal_strength (float 0–1).
Stage 2: Trend Detection¶
Method: Mann-Kendall test (default) or linear regression fallback.
Why Mann-Kendall: It is non-parametric — it makes no assumption about the distribution of the series. It is robust to outliers and does not require the series to be normally distributed. This matters for supply chain data which is often right-skewed or multimodal.
Mann-Kendall Algorithm (_mann_kendall_test)¶
Requirement: At least 10 observations. Fewer returns has_trend = False.
Step 1 — S statistic: Sum of the signs of all pairwise differences between later and earlier observations:
where \(\text{sgn}(x) = +1\) if \(x > 0\), \(-1\) if \(x < 0\), \(0\) if \(x = 0\).
Step 2 — Kendall's tau (normalised trend strength):
Step 3 — Variance with tie correction:
where \(t_i\) is the count of observations tied at value \(i\).
Step 4 — Z-score:
Step 5 — P-value (two-sided, standard normal):
Decision: has_trend = True if \(p < \text{significance\_level}\) (default 0.05).
Direction: 'up' if \(S > 0\), 'down' if \(S < 0\).
Strength: Kendall's tau \(\in [0, 1]\). Higher means stronger monotonic trend.
Linear Regression Fallback¶
When method = 'linear_regression' in config:
Fit by OLS. has_trend is determined by the p-value of \(\beta\). Trend strength = \(R^2\) (coefficient of determination).
Configuration:
Output fields: has_trend (bool), trend_direction ('up'/'down'/'none'), trend_strength (float 0–1).
Stage 3: Intermittency Detection¶
Intermittent demand is demand that occurs only occasionally — spare parts, MRO items, low-volume speciality products. Standard forecasting models fail on intermittent demand because they assume continuous positive demand.
Three detection rules (any one triggers is_intermittent = True):
Rule 1: Syntetos-Boylan (ADI + CoV)¶
The Syntetos-Boylan (2001) classification uses two metrics:
ADI (Average Demand Interval) — mean number of periods between consecutive non-zero observations:
where \(i_k\) is the index of the \(k\)-th non-zero observation and \(N_{\text{nz}}\) is the count of non-zero periods.
Edge cases: If only 1 non-zero observation exists, \(ADI = n\) (entire series length). If all zeros, \(ADI = n\).
CoV (Coefficient of Variation) of non-zero demand values:
where \(\sigma_{\text{nz}}\) and \(\mu_{\text{nz}}\) are the standard deviation (ddof=1) and mean of non-zero values only.
Syntetos-Boylan rule: is_intermittent = True when:
Rule 2: Zero-ratio rule¶
Default threshold: 0.5 (more than half of periods have zero demand).
Rule 3: Minimum positive-period count¶
is_intermittent = True when the total number of periods with positive demand \(< \text{min\_positive\_periods}\) (default: 5).
Combined rule:
Figure: Intermittency decision flow — any one of the three Syntetos-Boylan rules triggers is_intermittent = True.
flowchart TD
S["Non-zero analysis<br/>zero_ratio, ADI, CoV"] --> R1{"ADI > 1.32<br/>AND CoV > 0.49?"}
R1 -- yes --> IN["is_intermittent = True"]
R1 -- no --> R2{"zero_ratio > 0.5?"}
R2 -- yes --> IN
R2 -- no --> R3{"positive periods < 5?"}
R3 -- yes --> IN
R3 -- no --> NI["is_intermittent = False"]
Croston Classification Matrix¶
The ADI vs CoV² quadrant diagram classifies demand into four types:
quadrantChart
title Demand Classification (Syntetos-Boylan Matrix)
x-axis "Low CoV² (< 0.49²)" --> "High CoV² (> 0.49²)"
y-axis "Low ADI (< 1.32)" --> "High ADI (> 1.32)"
quadrant-1 Lumpy (IMAPA / ADIDA)
quadrant-2 Intermittent (CrostonOptimized)
quadrant-3 Erratic (AutoETS / AutoARIMA)
quadrant-4 Smooth (AutoETS / MSTL)
| Quadrant | ADI | CoV | Demand Type | Recommended Methods |
|---|---|---|---|---|
| Smooth | Low | Low | Regular, predictable | AutoETS, AutoARIMA, MSTL |
| Erratic | Low | High | Irregular quantity, frequent | AutoETS, NHITS |
| Intermittent | High | Low | Infrequent, consistent size | CrostonOptimized |
| Lumpy | High | High | Infrequent, variable size | IMAPA, ADIDA |
Configuration:
characterization:
intermittency:
zero_threshold: 0.5
adi_threshold: 1.32
cov_threshold: 0.49
min_positive_periods: 5
Output fields: is_intermittent (bool), zero_ratio (float), adi (float), cov (float).
Stage 4: Stationarity Detection¶
Method: Augmented Dickey-Fuller (ADF) test via statsmodels.tsa.stattools.adfuller(values, autolag='AIC').
What it tests: The ADF null hypothesis \(H_0\) is that the series has a unit root (non-stationary — has a random walk component). A small p-value rejects \(H_0\), meaning the series is stationary (mean-reverting).
ADF test statistic:
The test statistic is the t-ratio for \(\gamma\). Under \(H_0\): \(\gamma = 0\) (unit root present).
Decision: is_stationary = True if \(p < 0.05\).
Supply chain relevance: Non-stationary series (trending, random-walk-like) benefit from models that handle integration (AutoARIMA with differencing \(d > 0\), AutoETS with trend component). Stationary series work well with simpler models.
Configuration:
Output fields: is_stationary (bool), adf_pvalue (float).
Stage 5: Complexity Scoring¶
A composite score \(\in [0, 1]\) that combines six normalized factors, each measuring a different aspect of series difficulty.
Formula:
| Factor | Weight \(w_i\) | Computation |
|---|---|---|
| Coefficient of variation | 0.20 | $\min\left(\left |
| Entropy of first differences | 0.25 | Histogram-based entropy of \(\Delta y_t\), normalized by \(\log_2(n_{\text{bins}})\) |
| Turning-points ratio | 0.15 | \(\frac{\text{sign changes in } \Delta y_t}{n-2}\) — fraction of local extrema |
| Non-stationarity | 0.15 | \(1.0\) if not stationary, \(0.0\) if stationary |
| Seasonal strength | 0.10 | \(\min(\text{seasonal\_strength}, 1.0)\) |
| Intermittency | 0.15 | \(1.0\) if intermittent, \(0.0\) otherwise |
Entropy computation: The first differences \(\Delta y_t = y_t - y_{t-1}\) are normalized by their standard deviation, then binned into \(\min(20, \max(5, n/5))\) bins. Entropy is:
Higher entropy = less predictable = harder to forecast.
Turning-points ratio: A turning point is where the direction of the series reverses (local maximum or minimum). High turning-points ratio means the series oscillates frequently, making it hard to model.
Classification thresholds:
Thresholds are configurable in config.yaml under characterization.complexity.low_threshold (0.35) and characterization.complexity.high_threshold (0.65).
Output fields: complexity_score (float), complexity_level ('low'/'medium'/'high').
Stage 6: Data Sufficiency¶
Determines whether the series has enough observations to train the more data-hungry model families.
Rules (_assess_sufficiency):
chars.sufficient_for_ml = chars.n_observations >= min_for_ml # default: 20
chars.sufficient_for_deep_learning = chars.n_observations >= min_for_deep_learning # default: 30
Note on defaults
The config defaults are 20 for ML and 30 for deep learning. Neural models have an additional hard-coded minimum of 50 observations inside the NeuralForecaster class.
Configuration:
Output fields: sufficient_for_ml (bool), sufficient_for_deep_learning (bool).
Method Recommendation Logic¶
The _recommend_methods() function maps a SeriesCharacteristics object to a list of candidate forecasting methods. This list is then passed to both the forecaster and the backtester.
Two Strategies¶
Strategy: auto (default)¶
A strict priority decision tree assigns each series to exactly one demand group:
flowchart TD
A[Start] --> B{obs_per_year\n< sparse_obs_per_year?}
B -- yes --> S[sparse_data group\nSeasonalNaive / HistoricAverage / TimesFM]
B -- no --> C{is_intermittent?}
C -- yes --> I[intermittent group\nCrostonOptimized / ADIDA / IMAPA / TimesFM]
C -- no --> D{complexity_level\n== 'high'?}
D -- yes --> X[complex group\nNHITS / NBEATS / TFT / PatchTST / AutoETS]
D -- no --> E{has_seasonality?}
E -- yes --> SEA[seasonal group\nMSTL / AutoETS / NHITS / PatchTST / TimesFM]
E -- no --> STD[standard group\nAutoETS / AutoARIMA / AutoTheta / NHITS / TimesFM]
Sparsity check:
Series with obs_per_year < 5 (default) are classified as sparse.
After group assignment: Methods are filtered by data sufficiency:
- Methods in
{LightGBM, XGBoost}are removed ifsufficient_for_ml = False - Methods in
{NHITS, NBEATS, PatchTST, TFT, DeepAR}are removed ifsufficient_for_deep_learning = False - If all methods are removed: fallback is
['HistoricAverage']
Planner override within group: If forecasting.method_overrides.<group> is set in the parameter configuration, the system attempts to use only that single method. If the method is incompatible (e.g., DL model without sufficient data), it falls back to the full group list.
Strategy: best_fit¶
When method_selection_strategy = 'best_fit', the system uses the planner-configured best_fit_methods list directly (ignoring demand groups). Each method is checked for compatibility:
status = 'error'→ method is skipped with a warningstatus = 'warning'→ method is included with a logged caveatstatus = 'ok'→ method is included
Compatibility checks (check_method_compatibility):
| Method family | Condition for error |
|---|---|
| DL methods (NHITS, NBEATS, PatchTST, TFT, DeepAR) | sufficient_for_deep_learning = False |
| ML methods (LightGBM, XGBoost) | sufficient_for_ml = False |
| Intermittent-only (Croston, ADIDA, IMAPA) | is_intermittent = False (warning only) |
| Seasonal-required (MSTL) | has_seasonality = False (warning only) |
Configurable Method Lists¶
All group lists are configurable in scenario.parameters under forecasting.method_selection:
forecasting:
method_selection:
sparse_data: [SeasonalNaive, HistoricAverage, TimesFM]
intermittent: [CrostonOptimized, ADIDA, IMAPA, TimesFM]
complex: [NHITS, NBEATS, TFT, PatchTST, AutoETS, LightGBM, XGBoost]
seasonal: [MSTL, AutoETS, NHITS, PatchTST, TimesFM, LightGBM]
standard: [AutoETS, AutoARIMA, AutoTheta, NHITS, TimesFM, LightGBM]
Output: SeriesCharacteristics¶
All fields are persisted to the scenario.series_characteristics table (one row per series, overwritten on re-run).
| Field | Type | Description |
|---|---|---|
item_id |
bigint | Item identifier |
site_id |
bigint | Site identifier |
n_observations |
int | Number of data points |
date_range_start |
string | First observation date |
date_range_end |
string | Last observation date |
mean |
float | Series mean |
std |
float | Series standard deviation |
has_seasonality |
bool | Seasonality detected |
seasonal_periods |
JSON array | List of seasonal lag periods |
seasonal_strength |
float | Max ACF at seasonal lags |
has_trend |
bool | Monotonic trend detected |
trend_direction |
string | 'up' / 'down' / 'none' |
trend_strength |
float | Kendall's tau (MK) or R² (LR) |
is_intermittent |
bool | Any intermittency rule triggered |
zero_ratio |
float | Fraction of zero-demand periods |
adi |
float | Average Demand Interval |
cov |
float | CoV of non-zero demands |
is_stationary |
bool | ADF test rejected unit root |
adf_pvalue |
float | ADF p-value |
complexity_score |
float | Composite complexity [0,1] |
complexity_level |
string | 'low' / 'medium' / 'high' |
sufficient_for_ml |
bool | Enough data for ML models |
sufficient_for_deep_learning |
bool | Enough data for neural models |
recommended_methods |
JSON array | Candidate methods for this series |
frequency |
string | Data frequency ('W', 'M', etc.) |
Edge Cases¶
Series with fewer than 4 observations
Characterization is skipped after basic statistics. The method recommendation falls back to a minimal analysis based only on n_observations. Most methods will be unavailable due to data sufficiency checks, leaving only HistoricAverage.
2-year gate for seasonality
A series of 100 weekly observations but spanning only 20 months will never be classified as seasonal, even if it shows a periodic pattern. This prevents spurious seasonality on short histories where only one or two cycles are visible.
Intermittency overrides seasonality
The decision tree checks is_intermittent before has_seasonality. A series can be seasonal and intermittent, but it will be routed to the intermittent group. This is intentional: Croston-family models handle the intermittency; seasonal patterns in intermittent demand are typically too irregular to exploit with MSTL or AutoETS.
SQL Characterization Engine (Feature Flag)¶
A set-based ClickHouse + Python hybrid engine can replace the per-series Python loop for improved performance. It is hidden behind a feature flag and falls back to the Python characterizer on any error.
Architecture¶
- CH query: Fetches per-series ordered value arrays (
groupArray(y)) and basic aggregates (count,avg,stddevSamp,min/max date,span_days) in a single query, joiningMASTER_demand_actualswithPIPE_demand_correctedfor outlier corrections. - Python post-processing: Computes intermittency, seasonality (ACF), trend (Mann-Kendall or linear regression), stationarity (ADF), complexity, and data sufficiency on the arrays returned by CH.
- Method recommendation: Uses the same
recommend_methods()function as the Python characterizer.
Feature flag¶
When true and there are no per-SKU parameter-set overrides, the SQL engine handles all bulk series. If multiple parameter groups exist (per-SKU overrides), the Python characterizer is used instead. On any SQL error, the Python characterizer runs as fallback.
Config resolution¶
The SQL characterizer reads method-selection config from the same three sources as the Python characterizer, in priority order:
forecasting.*backtesting.*best_method.*
This includes method_selection_strategy, best_fit_methods, method_selection, and method_overrides.
Sufficiency thresholds use the same defaults as the Python engine:
| Threshold | Default | Source key |
|---|---|---|
min_for_ml |
100 | characterization.data_sufficiency.min_for_ml |
min_for_deep_learning |
200 | characterization.data_sufficiency.min_for_deep_learning |
Files¶
| File | Purpose |
|---|---|
files/characterization/sql_characterization.py |
ClickHouseCharacterizer class + helper functions (_compute_intermittency_df, _compute_seasonality_df, _compute_trend_df, _compute_stationarity_df, _compute_complexity_df, _compute_sufficiency_df) |
files/characterization/characterization.py |
Extracted module-level recommend_methods(), check_method_compatibility(), filter_by_sufficiency() (shared by both engines) |
files/utils/orchestrator.py |
step_characterize dispatches to SQL engine when flag is on |
files/tests/characterization/test_sql_characterization.py |
Unit tests for helper functions and recommendation logic; integration parity tests (require DB) |
CH query detail¶
WITH corrected AS (
SELECT
da.item_id,
da.site_id,
da.date,
COALESCE(dc.corrected_value, da.qty) AS y
FROM MASTER_demand_actuals AS da
LEFT JOIN PIPE_demand_corrected AS dc
ON dc.item_id = da.item_id
AND dc.site_id = da.site_id
AND dc.date = da.date
AND dc.pipeline_id = {pid:Int64}
AND dc.scenario_id = {sid:Int64}
WHERE (da.item_id, da.site_id) IN {uids:Array(Tuple(Int64,Int64))}
),
ordered AS (
SELECT * FROM corrected ORDER BY item_id, site_id, date
),
series AS (
SELECT
item_id,
site_id,
groupArray(y) AS vals,
groupArray(date) AS dates,
count() AS n_observations,
min(date) AS date_range_start,
max(date) AS date_range_end,
avg(y) AS mean_val,
stddevSamp(y) AS std_val,
dateDiff('day', min(date), max(date)) AS span_days
FROM ordered
GROUP BY item_id, site_id
)
SELECT item_id, site_id, vals, n_observations, date_range_start,
date_range_end, mean_val, std_val, span_days
FROM series ORDER BY item_id, site_id
The query uses ClickHouse parameterised placeholders ({uids:Array(String)}, {pid:Int64}, {sid:Int64}) bound via ch_query() — no string interpolation, safe against SQL injection.
Python post-processing pipeline¶
Figure: Feature-extraction pipeline for the SQL characterization engine — seven Python steps run in sequence on the arrays fetched by ClickHouse.
flowchart LR
Q["CH query<br/>groupArray(y) + aggregates<br/>one row per series"] --> P1["1. _compute_intermittency_df<br/>zero_ratio, adi, cov"]
P1 --> P2["2. _compute_seasonality_df<br/>ACF, seasonal_periods"]
P2 --> P3["3. _compute_trend_df<br/>Mann-Kendall / linear"]
P3 --> P4["4. _compute_stationarity_df<br/>ADF pvalue"]
P4 --> P5["5. _compute_complexity_df<br/>6-factor weighted score"]
P5 --> P6["6. _compute_sufficiency_df<br/>ml / deep_learning flags"]
P6 --> P7["7. _apply_method_recommendation<br/>recommended_methods"]
P7 --> O["series_characteristics table"]
After the CH query returns one row per series with an ordered vals array, the following Python functions run in sequence on the DataFrame:
| Step | Function | Metrics added |
|---|---|---|
| 1 | _compute_intermittency_df |
zero_ratio, adi, cov, is_intermittent |
| 2 | _compute_seasonality_df |
has_seasonality, seasonal_periods, seasonal_strength |
| 3 | _compute_trend_df |
has_trend, trend_direction, trend_strength |
| 4 | _compute_stationarity_df |
adf_pvalue, is_stationary |
| 5 | _compute_complexity_df |
complexity_score, complexity_level |
| 6 | _compute_sufficiency_df |
sufficient_for_ml, sufficient_for_deep_learning |
| 7 | _apply_method_recommendation |
recommended_methods |
The vals column is dropped before returning the final DataFrame.
Differences from Python engine¶
| Aspect | Python engine | SQL engine | Impact |
|---|---|---|---|
| Seasonality ACF | Reindexes to uniform calendar grid before ACF; gaps become NaN handled by statsmodels.acf(missing='conservative') |
Uses observation-index lag (position k in the array) | For series with calendar gaps (holidays, missing weeks), the SQL engine may compute ACF at different effective time separations. For gap-free series, results are identical. |
| Std deviation (complexity CV) | np.std(values) — population std (ddof=0) |
np.std(vals) — population std (ddof=0), computed from the raw array in Python rather than CH's stddevSamp (ddof=1) |
Parity maintained by computing std in Python from the array. |
| Sufficiency defaults | min_for_ml=100, min_for_deep_learning=200 |
Same defaults | Parity maintained. |
| Config source priority | forecasting.* → backtesting.* → best_method.* |
Same three-source priority | Parity maintained. |
| Data loading | Python reads df from PG, iterates per series |
CH fetches arrays in one query | The SQL path eliminates per-series DB round-trips. Heavy statistical tests (Mann-Kendall, ADF) remain exact Python implementations operating on the fetched arrays. |
Known limitations¶
- Memory: The CH query materialises all series × observations as
groupArray(y)in a single result set. For very large tenant datasets (100k+ series with long histories), the Python process may need significant RAM to hold all arrays simultaneously. - Mann-Kendall O(n²): The trend test uses the same double-loop implementation as the Python engine. For long series (e.g. 520+ weekly observations), this is the dominant per-series cost. A future optimisation could use an O(n log n) inversion-count algorithm.
- Calendar gaps: Seasonality ACF does not reindex to a uniform calendar grid. Series with missing periods (holidays, sparse data) may show different
has_seasonalityresults compared to the Python engine. This is documented and acceptable for Phase 1; a future iteration could implement calendar reindexing on the arrays returned from CH.
Rollback¶
Set characterization.use_sql_engine: false in config (or remove the key entirely — default is false). The orchestrator's try/except block also provides automatic per-run fallback: if the SQL characterizer throws any exception, the Python characterizer runs instead.