Outlier Detection¶
Purpose¶
Outliers in historical demand data corrupt every downstream algorithm: characterization flags are skewed, model parameters are mis-estimated, and backtesting metrics are inflated. ForecastAI's outlier detection module (files/outlier/detection.py, class OutlierDetector) runs immediately after ETL and before characterization.
The module:
- Detects which observations are outliers using one of three statistical methods.
- Corrects or flags the outlier values using one of four strategies.
- Stores every detected outlier (original value, corrected value, z-score, bounds) in the
detected_outlierstable. - Writes the corrected value back to
demand_actuals.corrected_qty.
flowchart LR
A[demand_actuals.qty] --> B[OutlierDetector]
B --> C{Detection method}
C --> D[IQR]
C --> E[Z-Score / Modified Z-Score]
C --> F[STL Residuals]
D & E & F --> G[is_outlier mask\nlower_bound, upper_bound]
G --> H{Correction method}
H --> I[clip]
H --> J[interpolate]
H --> K[median]
H --> L[none - flag only]
I & J & K & L --> M[corrected_qty written\nto demand_actuals]
G --> N[detected_outliers table\norig value, z_score, bounds]
Minimum series length: Series with fewer than min_observations (default: 6) points are skipped — too short for reliable statistical bounds.
Figure: Detection-method decision flow — STL residuals require at least two seasonal cycles, otherwise fall back to IQR.
flowchart TD
S["demand_actuals.qty"] --> G{"n < min_observations<br/>(default 6)?"}
G -- yes --> SK["Skip series"]
G -- no --> M{"detection_method"}
M -- iqr --> IQR["IQR<br/>Q1 - 1.5*IQR, Q3 + 1.5*IQR"]
M -- zscore --> ZS["Modified Z-score<br/>median + MAD, |z| > 3.0"]
M -- stl_residuals --> CK{"n >= 2 * seasonal_period?"}
CK -- no --> IQR
CK -- yes --> STL["STL robust decomposition<br/>z-score on residuals"]
IQR & ZS & STL --> MS["is_outlier mask<br/>lower_bound, upper_bound"]
Method 1: IQR (Interquartile Range)¶
The IQR method is the default. It is non-parametric and robust: it does not assume a specific distribution, and is not sensitive to extreme values (the very outliers it is designed to find).
Algorithm (_detect_iqr):
- Compute the 25th and 75th percentiles: \(Q_1\) and \(Q_3\)
- Compute the IQR: \(IQR = Q_3 - Q_1\)
- Compute bounds:
- Flag as outlier: any observation where \(y < \text{lower}\) or \(y > \text{upper}\)
Multiplier \(k\): Default is 1.5 (the classic Tukey fence). A smaller \(k\) (e.g., 1.0) is more aggressive (more outliers flagged). A larger \(k\) (e.g., 3.0) is more conservative.
Supply chain context: IQR works well for most demand series. It is symmetric, which can occasionally over-flag high-demand spikes on right-skewed distributions (e.g., promotional orders). In those cases, consider using a larger multiplier or the STL residuals method.
Configuration:
Method 2: Modified Z-Score¶
The standard Z-score uses the mean and standard deviation, which are themselves sensitive to outliers. The modified Z-score uses the median and Median Absolute Deviation (MAD) instead, making it robust.
Algorithm (_detect_zscore with use_mad: true, the default):
- Compute median: \(\tilde{y} = \text{median}(y)\)
- Compute MAD: \(\text{MAD} = \text{median}(|y_t - \tilde{y}|)\)
-
Scale MAD for consistency with a normal distribution: \(\hat{\sigma} = 1.4826 \cdot \text{MAD}\)
The factor 1.4826 comes from the normal distribution: \(\text{MAD}/\sigma \approx 0.6745\) for a normal distribution, so \(\sigma \approx 1.4826 \cdot \text{MAD}\).
-
Compute modified z-scores:
- Flag as outlier when \(|z_t| > \text{threshold}\) (default 3.0):
Bounds (for correction and reporting):
Fallback to mean/std: When use_mad: false, the standard Z-score is used:
where \(\bar{y}\) is the mean and \(\sigma\) is the standard deviation. This is less robust but may be preferred for near-normal distributions.
Edge case: If \(\hat{\sigma} = 0\) (all values identical), no outliers are returned.
Configuration:
outlier_detection:
detection_method: zscore
zscore:
threshold: 3.0
use_mad: true # false = standard mean/std z-score
Method 3: STL Residuals¶
Seasonal-Trend Decomposition using LOESS (STL) separates a series into trend, seasonal, and remainder components. The remainder (residuals) should be white noise; large residuals indicate anomalies that cannot be explained by the regular trend or seasonal pattern.
This method is the most sophisticated and most appropriate for series with strong seasonality, where the IQR/Z-score methods would incorrectly flag seasonal peaks as outliers.
Algorithm (_detect_stl_residuals):
Pre-check: Series must have at least \(2 \times \text{seasonal\_period}\) observations. If not, falls back to IQR.
- Interpolate any NaN values linearly before STL fitting (STL requires no missing values).
-
Fit robust STL decomposition:
STL(series, period=seasonal_period, robust=True).fit()The
robust=Trueflag uses iteratively reweighted least squares in the LOESS smoother, reducing the influence of outliers on the trend and seasonal component estimates. -
Extract residuals \(r_t\) from the fit:
result.resid - Apply modified Z-score to residuals:
- Flag as outlier when \(z_t^{(r)} > \text{residual\_threshold}\) (default 3.0):
Bounds (approximate, in original scale): 1st and 99th percentiles of the original series values (used for correction reference only — the actual outlier decision is made on residuals).
STL decomposition model:
where \(T_t\) is the trend (LOESS smoother), \(S_t\) is the seasonal component, and \(R_t\) is the remainder (residual). An observation is flagged only when its \(R_t\) is anomalously large — meaning the value cannot be explained by either the trend or the seasonal pattern.
Configuration:
outlier_detection:
detection_method: stl_residuals
stl_residuals:
seasonal_period: 12 # periods in one cycle (12 for monthly, 52 for weekly)
residual_threshold: 3.0
When to use STL
Use STL residuals for series with strong, stable seasonality (e.g., consumer goods with holiday peaks). IQR would incorrectly flag the peaks as outliers. STL correctly identifies that the peaks are expected seasonal behaviour and only flags values that deviate from the expected seasonal level.
Correction Methods¶
Once outliers are flagged, one of four correction strategies is applied.
Figure: Correction pipeline — the chosen strategy writes corrected_qty to demand_actuals (qty unchanged) while every detected outlier is also logged to detected_outliers.
flowchart TD
MS["is_outlier mask<br/>+ bounds"] --> CM{"correction_method"}
CM -- clip --> CL["y_corr = min(max(y, lower), upper)"]
CM -- interpolate --> IP["linear interpolation from neighbors<br/>bfill / ffill at edges"]
CM -- median --> MD["rolling-window median<br/>window=5, center"]
CM -- none --> NO["y_corr = y (flag only)"]
CL & IP & MD & NO --> DA["demand_actuals.corrected_qty<br/>(qty column unchanged)"]
MS --> DO["detected_outliers table<br/>original, corrected, z_score, bounds"]
Clip (default)¶
Cap the outlier value at the bound:
When to use: Preserves the information that an extreme event occurred (the corrected value is still higher/lower than normal), but removes the distorting magnitude. Good for promotional demand spikes.
Interpolate¶
Replace the outlier value with a linear interpolation from its neighbours:
Edges (first or last observation are outliers) are filled with backward-fill then forward-fill. The interpolation method is configurable (interpolation_method: default 'linear').
When to use: Best for data-entry errors or isolated erroneous spikes that should genuinely be replaced.
Median¶
Replace the outlier value with the rolling-window median:
where \(w\) is the window size (default: 5, center=True, min_periods=1).
When to use: More stable than interpolation when the series has high local variability. Preserves the general level of demand around the outlier.
None (flag only)¶
No correction is applied. The original values are left unchanged. The detected_outliers table is still populated for review, but corrected_qty equals qty.
When to use: When you want to review outliers before deciding on a correction, or when the business process requires human approval of corrections.
Configuration:
outlier_detection:
correction_method: clip # clip / interpolate / median / none
correction:
interpolation_method: linear
median_window: 5
Z-Score Reporting¶
Regardless of which detection method is configured, the _compute_zscores function always computes a modified z-score for every observation (using median/MAD). This score is stored in the detected_outliers table alongside the detection result, providing a comparable severity measure across different detection methods.
Database Storage¶
detected_outliers table — one row per detected outlier:
| Column | Description |
|---|---|
unique_id |
Series identifier |
date |
Date of the outlier observation |
original_value |
Original demand quantity |
corrected_value |
Corrected demand quantity (same as original if correction_method = 'none') |
detection_method |
Which method flagged it (iqr, zscore, stl_residuals) |
correction_method |
How it was corrected |
z_score |
Modified z-score of the original value |
lower_bound |
Lower detection threshold |
upper_bound |
Upper detection threshold |
demand_actuals.corrected_qty — the corrected demand value used by all downstream steps (characterization, forecasting, backtesting). The original qty column is never modified.
Full Configuration Reference¶
outlier_detection:
enabled: true
detection_method: iqr # iqr / zscore / stl_residuals
correction_method: clip # clip / interpolate / median / none
min_observations: 6 # skip series shorter than this
iqr:
multiplier: 1.5 # Tukey fence multiplier k
zscore:
threshold: 3.0 # |z| threshold
use_mad: true # true = robust MAD; false = mean/std
stl_residuals:
seasonal_period: 12 # number of periods in one cycle
residual_threshold: 3.0 # |z| threshold on residuals
correction:
interpolation_method: linear
median_window: 5
Choosing a Method¶
| Scenario | Recommended method |
|---|---|
| General-purpose, no strong seasonality | iqr (default) |
| Near-normal distribution, no seasonality | zscore |
| Strong seasonal pattern (e.g., annual peaks) | stl_residuals |
| Series where outliers should never be removed | none |
| Data entry errors (isolated spikes) | interpolate |
| Promotional demand (real but extreme) | clip |
IQR and zero-heavy series
For intermittent demand series with many zeros, \(Q_1 = 0\), \(Q_3\) may be 0 or small, and the IQR can be near-zero. This can make the bounds very tight, flagging normal non-zero observations as outliers. Consider setting a larger iqr.multiplier (e.g., 3.0) or using zscore for intermittent series.