BhalalaDaksh's picture
docs: update license header in compactts/agent/profiler.py to BSL-1.1
82f5ce2 verified
Raw History Blame Contribute Delete
4.85 kB
"""Series Profiler for CompactTS-Agent.
Extracts statistical signatures, spectral characteristics, seasonality,
trend strength, intermittency, and regime shifts from time-series trajectories.
Attribution: Daksh Bhalala
License: Business Source License 1.1 (BSL-1.1)
"""
from typing import Dict, Any, Optional
import numpy as np
import pandas as pd
from scipy import stats
from gluonts.time_feature import get_seasonality
class SeriesProfiler:
"""Profiles univariate and multivariate time series for autonomous agent arbitration."""
def __init__(self, freq: str = "H"):
self.freq = freq
try:
self.base_seasonality = max(get_seasonality(freq), 1)
except Exception:
freq_map = {
"10S": 60, "5T": 12, "10T": 6, "15T": 4,
"H": 24, "D": 7, "W": 52, "M": 12, "Q": 4, "A": 1
}
self.base_seasonality = freq_map.get(freq, 24)
def profile(self, series: np.ndarray) -> Dict[str, Any]:
"""Extract comprehensive statistical and structural characteristics.
Parameters
----------
series : np.ndarray
1D or 2D array of historical observations.
Returns
-------
Dict[str, Any]
Dictionary of structural features:
- length: sequence length
- mean, std: empirical scale
- zero_fraction: proportion of near-zero values (intermittency/solar)
- cv: coefficient of variation (volatility)
- kurtosis: tail weight indicator
- seasonal_period: detected dominant lag
- seasonal_strength: autocorrelation at seasonal lag
- trend_strength: relative trend variance ratio
- mean_shift: shift in mean between first and second half (non-stationarity)
- std_shift: shift in std between first and second half
- has_missing: boolean indicating gaps or NaNs
"""
y = np.asarray(series, dtype=np.float32)
if y.ndim > 1:
y = y.squeeze()
if y.ndim > 1:
# If multivariate, profile primary target channel
y = y[0] if y.shape[0] < y.shape[1] else y[:, 0]
has_missing = bool(np.isnan(y).any() or np.isinf(y).any())
if has_missing:
s = pd.Series(y).ffill().bfill().to_numpy()
y = np.nan_to_num(s, nan=0.0).astype(np.float32)
n = len(y)
if n < 4:
return {
"length": n,
"mean": float(np.mean(y)) if n > 0 else 0.0,
"std": float(np.std(y)) if n > 0 else 1.0,
"zero_fraction": 0.0,
"cv": 1.0,
"kurtosis": 0.0,
"seasonal_period": self.base_seasonality,
"seasonal_strength": 0.0,
"trend_strength": 0.0,
"mean_shift": 0.0,
"std_shift": 0.0,
"has_missing": has_missing,
}
mean_val = float(np.mean(y))
std_val = float(np.std(y))
if std_val < 1e-8:
std_val = 1.0
zero_fraction = float(np.mean(np.abs(y) < 1e-5))
cv = std_val / (abs(mean_val) + 1e-5)
# Kurtosis for heavy-tail detection
try:
kurt = float(stats.kurtosis(y))
except Exception:
kurt = 0.0
# Non-stationarity & regime shift metrics (split context in half)
half = n // 2
m1, m2 = float(np.mean(y[:half])), float(np.mean(y[half:]))
s1, s2 = float(np.std(y[:half])), float(np.std(y[half:]))
mean_shift = abs(m1 - m2) / (std_val + 1e-5)
std_shift = abs(s1 - s2) / (std_val + 1e-5)
# Seasonal autocorrelation
S = self.base_seasonality
s_corr = 0.0
if n > 2 * S and S > 1:
try:
c = np.corrcoef(y[S:], y[:-S])[0, 1]
if np.isfinite(c):
s_corr = max(0.0, float(c))
except Exception:
s_corr = 0.0
# Trend strength: ratio of diff variance to raw variance
diff_var = float(np.var(np.diff(y)))
raw_var = float(np.var(y))
trend_ratio = max(0.0, 1.0 - (diff_var / (raw_var + 1e-8)))
return {
"length": n,
"mean": mean_val,
"std": std_val,
"zero_fraction": zero_fraction,
"cv": cv,
"kurtosis": kurt,
"seasonal_period": S,
"seasonal_strength": s_corr,
"trend_strength": trend_ratio,
"mean_shift": mean_shift,
"std_shift": std_shift,
"has_missing": has_missing,
}