MYTHOSLIVE / models /arima_model.py
3VVM's picture
Fix evaluation identities, model failures, causal execution and reproducibility
a9e6751 verified
Raw History Blame Contribute Delete
4.59 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, quiet_fit
from features.feature_pipeline import validate_features
class ArimaModel(BaseForecastModel):
name = 'ARIMA'
def __init__(self, max_p=3, max_d=2, max_q=3, order=None, refit_order_every=None):
self.max_p = max_p
self.max_d = max_d
self.max_q = max_q
self._cached_order = order
self.refit_order_every = refit_order_every
self._calls_since_search = 0
self._lock = threading.Lock()
self.last_diagnostics = {}
def _best_order(self, series: pd.Series, exog: pd.DataFrame=None):
arima = import_arima(self.name)
best_aic = np.inf
best_order = None
failures = []
# AIC values from different integration orders have different likelihood
# bases. Select d on the training sample, then compare only p/q at that d.
from models.auto_arima_model import AutoArimaModel
selected_d = AutoArimaModel(max_d=self.max_d)._find_d(series)
with quiet_fit():
candidates = [(p, d, q)
for d in [selected_d]
for p in range(self.max_p + 1)
for q in range(self.max_q + 1)
if p + q <= 2]
for order in candidates:
try:
fit = fit_arima(arima(series, exog=exog, order=order))
except Exception as error:
failures.append({'order': order, 'error': f'{type(error).__name__}: {error}'})
continue
if fit.aic < best_aic:
best_aic = fit.aic
best_order = order
if best_order is None:
self.last_diagnostics = {'converged': False, 'candidate_errors': failures}
raise ValueError(f'No ARIMA candidate converged on this training window: {failures}')
return best_order
def _fit_forecast(self, arima, series, order, horizon, exog, future_exog):
"""Execute the selected ARIMA order; a failed fit is a failed evaluation."""
with quiet_fit():
fit = fit_arima(arima(series, exog=exog, order=order))
forecast = fit.forecast(steps=horizon, exog=future_exog) if exog is not None else fit.forecast(steps=horizon)
values = np.exp(np.asarray(forecast, dtype=float))
if values.shape != (horizon,) or not np.isfinite(values).all() or (values <= 0).any():
raise ValueError(f'{self.name} returned an invalid forecast for selected order {order}.')
self.last_diagnostics = {'converged': True, 'selected_order': order, 'executed_order': order,
'order_fallback': False, 'feature_policy': 'lagged one candle; unknown future covariates persist'}
return values
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('ARIMA needs at least 20 candles of history.')
arima = import_arima(self.name)
exog = future_exog = None
if features is not None:
validate_features(features, expected_len=len(series), model_name='ARIMA')
exog, future_exog = causal_exogenous(features, horizon, self.name)
if exog is not None:
series = series.iloc[1:].reset_index(drop=True)
if (series <= 0).any():
raise ValueError('ARIMA needs strictly positive prices for its log-price model.')
log_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:
self._cached_order = self._best_order(log_series, exog=exog)
self._calls_since_search = 0
self._calls_since_search += 1
forecast = self._fit_forecast(arima, log_series, self._cached_order, horizon, exog, future_exog)
return [float(x) for x in forecast]