MYTHOSLIVE / models /auto_arima_model.py
3VVM's picture
Fix evaluation identities, model failures, causal execution and reproducibility
a9e6751 verified
Raw History Blame Contribute Delete
4.37 kB
import threading
import numpy as np
import pandas as pd
from models.base_model import BaseForecastModel, causal_exogenous, validate_history_and_horizon
from models.dependencies import fit_arima, import_arima, import_kpss, quiet_fit
from features.feature_pipeline import validate_features
class AutoArimaModel(BaseForecastModel):
name = 'Auto-ARIMA'
def __init__(self, max_p=5, max_q=5, max_d=2, order=None, refit_order_every=None):
self.max_p = max_p
self.max_q = max_q
self.max_d = max_d
self._cached_order = order
self.refit_order_every = refit_order_every
self._calls_since_search = 0
self._lock = threading.Lock()
self.last_diagnostics = {}
def _find_d(self, series: pd.Series) -> int:
kpss = import_kpss(self.name)
s = series.copy()
d = 0
if float(s.std()) < 1e-12:
return 0
with quiet_fit():
while d < self.max_d:
try:
_, p_value, _, _ = kpss(s, regression='c', nlags='auto')
except Exception as error:
raise ValueError(f'Auto-ARIMA stationarity test failed ({type(error).__name__}): {error}') from error
if p_value >= 0.05:
break
s = s.diff().dropna()
d += 1
return d
def _aic(self, series: pd.Series, order, exog: pd.DataFrame=None) -> float:
arima = import_arima(self.name)
try:
return fit_arima(arima(series, exog=exog, order=order)).aic
except Exception as error:
self.last_diagnostics.setdefault('candidate_errors', []).append(
{'order': order, 'error': f'{type(error).__name__}: {error}'})
return np.inf
def _stepwise_search(self, series: pd.Series, d: int, exog: pd.DataFrame=None):
# A compact Hyndman-style candidate set is more stable for noisy
# intraday data than repeatedly walking a large grid at every origin.
candidates = [(0, 0), (1, 0), (0, 1), (1, 1), (2, 0), (0, 2), (2, 1), (1, 2)]
candidates = [(p, q) for p, q in candidates if p <= self.max_p and q <= self.max_q]
best_order, best_aic = (None, np.inf)
with quiet_fit():
for p, q in candidates:
aic = self._aic(series, (p, d, q), exog=exog)
if aic < best_aic:
best_aic, best_order = (aic, (p, q))
if best_order is None:
raise ValueError(f'No Auto-ARIMA candidate converged on this training window: {self.last_diagnostics}')
p, q = best_order
return (p, d, q)
def predict(self, history: pd.Series, horizon: int=1, features: pd.DataFrame=None) -> list:
with self._lock:
self.last_diagnostics = {}
validate_history_and_horizon(history, horizon, self.name)
series = pd.Series(history).astype(float).reset_index(drop=True)
validate_history_and_horizon(series, horizon, self.name)
if len(series) < 20:
raise ValueError('Auto-ARIMA needs at least 20 candles of history.')
arima = import_arima(self.name)
if (series <= 0).any():
raise ValueError('Auto-ARIMA needs strictly positive prices for its log-price model.')
exog = future_exog = None
if features is not None:
validate_features(features, expected_len=len(series), model_name='Auto-ARIMA')
exog, future_exog = causal_exogenous(features, horizon, self.name)
if exog is not None:
series = series.iloc[1:].reset_index(drop=True)
series = np.log(series)
need_search = self._cached_order is None
if self.refit_order_every and self._calls_since_search >= self.refit_order_every:
need_search = True
if need_search:
d = self._find_d(series)
self._cached_order = self._stepwise_search(series, d, exog=exog)
self._calls_since_search = 0
self._calls_since_search += 1
from models.arima_model import ArimaModel
forecast = ArimaModel._fit_forecast(self, arima, series, self._cached_order, horizon, exog, future_exog)
return forecast.tolist()