Skip to content

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):

  1. Compute the modal inter-observation interval from the date index.
  2. If there are calendar gaps (e.g., missing weeks), reindex the series to a uniform calendar grid. Gaps become NaN; statsmodels handles them with missing='conservative' (pairwise non-NaN products in the ACF calculation).
  3. For each candidate period in the configured list (default [4, 13, 26, 52]), test only if period < n / 2 and period >= 2.
  4. Compute: acf_values = acf(source, nlags=period, fft=True, missing='conservative')
  5. A period is seasonal if |acf_values[period]| > min_strength (default 0.3).
  6. 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:

\[\hat{\rho}(k) = \frac{\sum_{t=k+1}^{n}(y_t - \bar{y})(y_{t-k} - \bar{y})}{\sum_{t=1}^{n}(y_t - \bar{y})^2}\]

Period \(k\) is considered seasonal when:

\[|\hat{\rho}(k)| > \text{min\_strength}\]

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:

characterization:
  seasonality:
    test_periods: [4, 13, 26, 52]
    min_strength: 0.3

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:

\[S = \sum_{k=1}^{n-1} \sum_{j=k+1}^{n} \text{sgn}(y_j - y_k)\]

where \(\text{sgn}(x) = +1\) if \(x > 0\), \(-1\) if \(x < 0\), \(0\) if \(x = 0\).

Step 2 — Kendall's tau (normalised trend strength):

\[\tau = \frac{|S|}{n(n-1)/2}\]

Step 3 — Variance with tie correction:

\[\text{Var}(S) = \frac{n(n-1)(2n+5) - \sum_i t_i(t_i-1)(2t_i+5)}{18}\]

where \(t_i\) is the count of observations tied at value \(i\).

Step 4 — Z-score:

\[z = \begin{cases} \dfrac{S - 1}{\sqrt{\text{Var}(S)}} & \text{if } S > 0 \\ 0 & \text{if } S = 0 \\ \dfrac{S + 1}{\sqrt{\text{Var}(S)}} & \text{if } S < 0 \end{cases}\]

Step 5 — P-value (two-sided, standard normal):

\[p = 2\left(1 - \Phi(|z|)\right)\]

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:

\[y_t = \alpha + \beta t + \epsilon_t\]

Fit by OLS. has_trend is determined by the p-value of \(\beta\). Trend strength = \(R^2\) (coefficient of determination).

Configuration:

characterization:
  trend:
    method: mann_kendall    # or 'linear_regression'
    significance_level: 0.05

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:

\[ADI = \frac{1}{N_{\text{nz}}-1}\sum_{k=1}^{N_{\text{nz}}-1} (i_{k+1} - i_k)\]

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:

\[CoV = \frac{\sigma_{\text{nz}}}{\mu_{\text{nz}}}\]

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:

\[ADI > 1.32 \quad \text{AND} \quad CoV > 0.49\]

Rule 2: Zero-ratio rule

\[\text{zero\_ratio} = \frac{\text{count}(y_t = 0)}{n} > \text{zero\_threshold}\]

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:

is_intermittent = sb_rule or zero_rule or count_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:

\[\Delta y_t = \alpha + \beta t + \gamma y_{t-1} + \sum_{j=1}^{p} \delta_j \Delta y_{t-j} + \epsilon_t\]

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:

characterization:
  stationarity:
    significance_level: 0.05

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:

\[\text{complexity\_score} = \sum_{i} w_i \cdot f_i\]
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:

\[H = -\sum_k p_k \log_2 p_k, \quad \text{normalized to } [0,1] \text{ by } \log_2(n_{\text{bins}})\]

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:

\[\text{complexity\_level} = \begin{cases} \text{low} & \text{if score} < 0.35 \\ \text{medium} & \text{if } 0.35 \leq \text{score} < 0.65 \\ \text{high} & \text{if score} \geq 0.65 \end{cases}\]

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:

characterization:
  data_sufficiency:
    min_for_ml: 20
    min_for_deep_learning: 30
    sparse_obs_per_year: 5

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:

\[\text{obs\_per\_year} = \frac{n\_observations}{(t_{\text{end}} - t_{\text{start}}) / 365.25}\]

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 if sufficient_for_ml = False
  • Methods in {NHITS, NBEATS, PatchTST, TFT, DeepAR} are removed if sufficient_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 warning
  • status = 'warning' → method is included with a logged caveat
  • status = '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

  1. 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, joining MASTER_demand_actuals with PIPE_demand_corrected for outlier corrections.
  2. 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.
  3. Method recommendation: Uses the same recommend_methods() function as the Python characterizer.

Feature flag

characterization:
  use_sql_engine: false   # true to enable set-based CH characterization

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:

  1. forecasting.*
  2. backtesting.*
  3. 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_seasonality results 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.