286 lines
9.8 KiB
Python
286 lines
9.8 KiB
Python
import numpy as np
|
|
import pandas as pd
|
|
from typing import Dict, List, Optional, Tuple
|
|
from datetime import datetime
|
|
from loguru import logger
|
|
|
|
try:
|
|
from statsmodels.tsa.arima.model import ARIMA
|
|
HAS_STATSMODELS = True
|
|
except ImportError:
|
|
HAS_STATSMODELS = False
|
|
logger.debug("statsmodels not installed, ARIMA model unavailable")
|
|
|
|
try:
|
|
from sklearn.ensemble import RandomForestRegressor
|
|
from sklearn.model_selection import train_test_split
|
|
HAS_SKLEARN = True
|
|
except ImportError:
|
|
HAS_SKLEARN = False
|
|
logger.debug("scikit-learn not installed, ML models unavailable")
|
|
|
|
|
|
class TemperaturePredictor:
|
|
"""
|
|
Temperature prediction model using statistical and ML methods
|
|
|
|
Supports:
|
|
- ARIMA for time series prediction
|
|
- Random Forest for feature-based prediction
|
|
- Ensemble of both methods
|
|
"""
|
|
|
|
def __init__(self, config: dict = None):
|
|
self.config = config or {}
|
|
self.arima_order = self.config.get("arima_order", (5, 1, 2))
|
|
self.rf_estimators = self.config.get("rf_estimators", 100)
|
|
|
|
self.arima_model = None
|
|
self.rf_model = None
|
|
self.is_trained = False
|
|
|
|
logger.info("Temperature Predictor initialized")
|
|
|
|
def prepare_features(self, data: pd.DataFrame) -> pd.DataFrame:
|
|
"""
|
|
Prepare features for ML model
|
|
|
|
Args:
|
|
data: DataFrame with temperature history
|
|
|
|
Returns:
|
|
DataFrame: Feature-engineered data
|
|
"""
|
|
df = data.copy()
|
|
|
|
# Time-based features
|
|
if 'date' in df.columns:
|
|
df['date'] = pd.to_datetime(df['date'])
|
|
df['day_of_year'] = df['date'].dt.dayofyear
|
|
df['month'] = df['date'].dt.month
|
|
df['day_of_week'] = df['date'].dt.dayofweek
|
|
|
|
# Lag features
|
|
if 'temp' in df.columns:
|
|
for lag in [1, 2, 3, 7, 14]:
|
|
df[f'temp_lag_{lag}'] = df['temp'].shift(lag)
|
|
|
|
# Rolling statistics
|
|
df['temp_rolling_mean_7'] = df['temp'].rolling(window=7).mean()
|
|
df['temp_rolling_std_7'] = df['temp'].rolling(window=7).std()
|
|
df['temp_rolling_mean_14'] = df['temp'].rolling(window=14).mean()
|
|
|
|
# Drop NaN rows created by lag features
|
|
df = df.dropna()
|
|
|
|
return df
|
|
|
|
def train_arima(self, temperature_series: List[float]) -> bool:
|
|
"""
|
|
Train ARIMA model on temperature time series
|
|
|
|
Args:
|
|
temperature_series: List of historical temperatures
|
|
|
|
Returns:
|
|
bool: Success status
|
|
"""
|
|
if not HAS_STATSMODELS:
|
|
logger.error("statsmodels required for ARIMA training")
|
|
return False
|
|
|
|
if len(temperature_series) < 30:
|
|
logger.warning("Insufficient data for ARIMA training (need 30+ points)")
|
|
return False
|
|
|
|
try:
|
|
series = np.array(temperature_series)
|
|
model = ARIMA(series, order=self.arima_order)
|
|
self.arima_model = model.fit()
|
|
logger.info(f"ARIMA model trained. AIC: {self.arima_model.aic:.2f}")
|
|
return True
|
|
except Exception as e:
|
|
logger.error(f"ARIMA training failed: {e}")
|
|
return False
|
|
|
|
def train_random_forest(self,
|
|
features: pd.DataFrame,
|
|
target_col: str = 'temp') -> bool:
|
|
"""
|
|
Train Random Forest model
|
|
|
|
Args:
|
|
features: Feature DataFrame
|
|
target_col: Target column name
|
|
|
|
Returns:
|
|
bool: Success status
|
|
"""
|
|
if not HAS_SKLEARN:
|
|
logger.error("scikit-learn required for Random Forest training")
|
|
return False
|
|
|
|
if len(features) < 50:
|
|
logger.warning("Insufficient data for RF training (need 50+ rows)")
|
|
return False
|
|
|
|
try:
|
|
# Prepare data
|
|
feature_cols = [c for c in features.columns
|
|
if c not in [target_col, 'date', 'datetime']]
|
|
|
|
X = features[feature_cols].values
|
|
y = features[target_col].values
|
|
|
|
# Train-test split
|
|
X_train, X_test, y_train, y_test = train_test_split(
|
|
X, y, test_size=0.2, random_state=42
|
|
)
|
|
|
|
# Train model
|
|
self.rf_model = RandomForestRegressor(
|
|
n_estimators=self.rf_estimators,
|
|
random_state=42,
|
|
n_jobs=-1
|
|
)
|
|
self.rf_model.fit(X_train, y_train)
|
|
|
|
# Evaluate
|
|
train_score = self.rf_model.score(X_train, y_train)
|
|
test_score = self.rf_model.score(X_test, y_test)
|
|
|
|
logger.info(f"Random Forest trained. Train R²: {train_score:.4f}, Test R²: {test_score:.4f}")
|
|
|
|
# Store feature names
|
|
self.feature_names = feature_cols
|
|
|
|
return True
|
|
except Exception as e:
|
|
logger.error(f"Random Forest training failed: {e}")
|
|
return False
|
|
|
|
def predict_arima(self, steps: int = 1) -> Optional[Dict]:
|
|
"""
|
|
Make prediction using ARIMA model
|
|
|
|
Args:
|
|
steps: Number of steps to forecast
|
|
|
|
Returns:
|
|
dict: Prediction with confidence interval
|
|
"""
|
|
if self.arima_model is None:
|
|
logger.warning("ARIMA model not trained")
|
|
return None
|
|
|
|
try:
|
|
forecast = self.arima_model.forecast(steps=steps)
|
|
conf_int = self.arima_model.get_forecast(steps=steps).conf_int()
|
|
|
|
return {
|
|
"method": "ARIMA",
|
|
"predicted_temp": float(forecast[0]) if steps == 1 else [float(f) for f in forecast],
|
|
"confidence_interval": [float(conf_int.iloc[0, 0]), float(conf_int.iloc[0, 1])] if steps == 1 else conf_int.values.tolist()
|
|
}
|
|
except Exception as e:
|
|
logger.error(f"ARIMA prediction failed: {e}")
|
|
return None
|
|
|
|
def predict_rf(self, features: np.ndarray) -> Optional[Dict]:
|
|
"""
|
|
Make prediction using Random Forest model
|
|
|
|
Args:
|
|
features: Feature array for prediction
|
|
|
|
Returns:
|
|
dict: Prediction result
|
|
"""
|
|
if self.rf_model is None:
|
|
logger.warning("Random Forest model not trained")
|
|
return None
|
|
|
|
try:
|
|
prediction = self.rf_model.predict(features.reshape(1, -1))[0]
|
|
|
|
# Estimate confidence using tree variance
|
|
tree_predictions = [tree.predict(features.reshape(1, -1))[0]
|
|
for tree in self.rf_model.estimators_]
|
|
std = np.std(tree_predictions)
|
|
|
|
return {
|
|
"method": "RandomForest",
|
|
"predicted_temp": float(prediction),
|
|
"confidence_interval": [float(prediction - 1.96 * std),
|
|
float(prediction + 1.96 * std)],
|
|
"std": float(std)
|
|
}
|
|
except Exception as e:
|
|
logger.error(f"Random Forest prediction failed: {e}")
|
|
return None
|
|
|
|
def predict_ensemble(self,
|
|
temperature_history: List[float],
|
|
feature_data: pd.DataFrame = None,
|
|
arima_weight: float = 0.4,
|
|
rf_weight: float = 0.6) -> Dict:
|
|
"""
|
|
Make ensemble prediction combining ARIMA and Random Forest
|
|
|
|
Args:
|
|
temperature_history: Historical temperature series
|
|
feature_data: Feature data for RF prediction
|
|
arima_weight: Weight for ARIMA prediction
|
|
rf_weight: Weight for RF prediction
|
|
|
|
Returns:
|
|
dict: Ensemble prediction
|
|
"""
|
|
predictions = []
|
|
weights = []
|
|
|
|
# ARIMA prediction
|
|
if self.arima_model is not None:
|
|
arima_pred = self.predict_arima(steps=1)
|
|
if arima_pred:
|
|
predictions.append(arima_pred["predicted_temp"])
|
|
weights.append(arima_weight)
|
|
|
|
# Random Forest prediction
|
|
if self.rf_model is not None and feature_data is not None:
|
|
# Get latest features
|
|
prepared = self.prepare_features(feature_data)
|
|
if len(prepared) > 0 and hasattr(self, 'feature_names'):
|
|
latest_features = prepared[self.feature_names].iloc[-1].values
|
|
rf_pred = self.predict_rf(latest_features)
|
|
if rf_pred:
|
|
predictions.append(rf_pred["predicted_temp"])
|
|
weights.append(rf_weight)
|
|
|
|
if not predictions:
|
|
logger.debug("No predictions available (Model not trained)")
|
|
return {
|
|
"predicted_temp": None,
|
|
"confidence": 0.5,
|
|
"error": "No models available for prediction"
|
|
}
|
|
|
|
# Weighted average
|
|
weights = np.array(weights) / np.sum(weights) # Normalize weights
|
|
ensemble_pred = np.average(predictions, weights=weights)
|
|
|
|
# Estimate confidence based on model agreement
|
|
if len(predictions) > 1:
|
|
spread = abs(predictions[0] - predictions[1])
|
|
confidence = max(0.5, 1.0 - spread / 5.0) # Lower confidence if predictions differ
|
|
else:
|
|
confidence = 0.7
|
|
|
|
return {
|
|
"predicted_temp": float(ensemble_pred),
|
|
"confidence": confidence,
|
|
"confidence_interval": [ensemble_pred - 2.0, ensemble_pred + 2.0], # Approximate
|
|
"individual_predictions": predictions,
|
|
"weights": weights.tolist()
|
|
}
|