import os
from typing import Optional, List, Tuple, Dict
import numpy as np
import pandas as pd
import pytz
import streamlit as st
import plotly.express as px
import plotly.graph_objects as go
import matplotlib.pyplot as plt
from scipy import stats as scipy_stats
from statsmodels.tsa.stattools import adfuller, kpss, acf as sm_acf, pacf as sm_pacf
from statsmodels.graphics.tsaplots import plot_acf, plot_pacf
from statsmodels.tsa.seasonal import seasonal_decompose
from statsmodels.stats.outliers_influence import variance_inflation_factor
from statsmodels.tools import add_constant
import sys
import os
# Добавляем папку с модулями (путь к файлу streamlit_app.py)
# Это гарантирует, что Python найдёт lab3_pipeline и lab3_functions в той же папке src
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
try:
import lab3_functions as lab3
from lab3_pipeline import run_pipeline
LAB3_AVAILABLE = True
except Exception as e:
LAB3_AVAILABLE = False
lab3 = None
_LAB3_IMPORT_ERROR = str(e)
# Для дебага выведем в консоль (и в Streamlit, если он загружен)
print("lab3 import failed:", _LAB3_IMPORT_ERROR)
try:
import streamlit as _st
_st.warning("lab3 import failed: " + _LAB3_IMPORT_ERROR)
except Exception:
pass
st.set_page_config(page_title="Анализ временных рядов", layout="wide", initial_sidebar_state="expanded")
MOSCOW = pytz.timezone("Europe/Moscow")
# Импорт функций для ЛР №2
import sys
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
try:
from lab2_functions import (
create_advanced_features, apply_transformations, apply_boxcox_transform, inverse_boxcox_transform,
inverse_transformations, recursive_forecast, direct_forecast, hybrid_forecast, create_exponential_smoothing_model,
evaluate_forecast, naive_forecast, time_series_cv_sliding_window, time_series_cv_expanding_window,
diagnose_model_residuals, calculate_mape
)
LAB2_AVAILABLE = True
except ImportError as e:
LAB2_AVAILABLE = False
st.warning(f"Функции ЛР №2 недоступны: {e}")
# после импорта lab2_functions
try:
import lab3_functions as lab3
LAB3_AVAILABLE = True
except Exception:
LAB3_AVAILABLE = False
try:
import lab4_functions as lab4
LAB4_AVAILABLE = True
except Exception:
LAB4_AVAILABLE = False
try:
import lab5_functions as lab5
LAB5_AVAILABLE = True
except Exception:
LAB5_AVAILABLE = False
# Навигация между лабораторными работами
st.sidebar.title("🧪 Лабораторные работы")
lab_choice = st.sidebar.radio(
"Выберите лабораторную работу:",
["ЛР №1: Введение в анализ временных рядов",
"ЛР №2: Прогнозирование временных рядов",
"ЛР №3: Классические модели",
"ЛР №4: ML для TS",
"ЛР №5: Deep Learning для TS"
],
index=0
)
# ---------------- Utilities ----------------
def detect_date_column(df: pd.DataFrame) -> Optional[str]:
candidates = [c for c in df.columns if any(k in c.lower() for k in ("date", "time", "timestamp", "dt", "day"))]
if candidates:
pref = [c for c in candidates if 'date' in c.lower()]
return pref[0] if pref else candidates[0]
scores = {}
for c in df.columns:
parsed = pd.to_datetime(df[c], errors='coerce', dayfirst=True, infer_datetime_format=True)
scores[c] = parsed.notna().mean()
best, score = max(scores.items(), key=lambda x: x[1])
return best if score > 0.5 else None
def try_parse_dates(series: pd.Series) -> pd.Series:
s = series.astype(str).replace('nan', pd.NA)
parsed = pd.to_datetime(s, errors='coerce', infer_datetime_format=True)
parsed = parsed.fillna(pd.to_datetime(s, format='%d.%m.%Y', errors='coerce'))
parsed = parsed.fillna(pd.to_datetime(s, format='%Y-%m-%d', errors='coerce'))
return parsed
def localize_to_moscow(ts: pd.Series, assume_tz: str = 'local') -> pd.Series:
ts = pd.to_datetime(ts, errors='coerce')
if ts.dt.tz is None:
if assume_tz == 'utc':
ts = ts.dt.tz_localize('UTC').dt.tz_convert('Europe/Moscow')
elif assume_tz == 'local':
ts = ts.dt.tz_localize('Europe/Moscow')
else:
pass
else:
ts = ts.dt.tz_convert('Europe/Moscow')
return ts
def detect_outliers_iqr(col: pd.Series) -> pd.Series:
q1 = col.quantile(0.25)
q3 = col.quantile(0.75)
iqr = q3 - q1
lo = q1 - 1.5 * iqr
hi = q3 + 1.5 * iqr
return (col < lo) | (col > hi)
def winsorize_series(col: pd.Series, lower_q: float = 0.01, upper_q: float = 0.99) -> pd.Series:
low = col.quantile(lower_q)
high = col.quantile(upper_q)
return col.clip(lower=low, upper=high)
# ---------------- Preprocessing (3.2) ----------------
def preprocess_timeseries(
df: pd.DataFrame,
date_col: str,
tz_assume: str = 'local',
numeric_missing_strategy: str = 'interpolate',
cat_missing_strategy: str = 'mode',
outlier_strategy: str = 'interpolate',
resample_freq: Optional[str] = None,
) -> Tuple[pd.DataFrame, Dict]:
info: Dict = {}
df2 = df.copy()
parsed = try_parse_dates(df2[date_col])
info['parse_success'] = float(parsed.notna().mean())
df2['timestamp'] = parsed
df2['timestamp'] = localize_to_moscow(df2['timestamp'], assume_tz=tz_assume)
before = len(df2)
df2 = df2.dropna(subset=['timestamp']).reset_index(drop=True)
info['dropped_no_timestamp'] = before - len(df2)
df2 = df2.sort_values('timestamp').drop_duplicates(subset=['timestamp']).reset_index(drop=True)
num_cols = df2.select_dtypes(include=[np.number]).columns.tolist()
cat_cols = [c for c in df2.columns if c not in num_cols and c != 'timestamp' and c != date_col]
info['num_cols'] = num_cols
info['cat_cols'] = cat_cols
info['missing_before'] = df2[num_cols].isna().sum().to_dict()
if numeric_missing_strategy == 'drop':
df2 = df2.dropna(subset=num_cols).reset_index(drop=True)
elif numeric_missing_strategy == 'interpolate':
df2 = df2.set_index('timestamp')
df2[num_cols] = df2[num_cols].interpolate(method='time', limit_direction='both')
df2 = df2.reset_index()
elif numeric_missing_strategy == 'rolling':
for c in num_cols:
df2[c] = df2[c].fillna(df2[c].rolling(window=7, min_periods=1).mean())
else:
raise ValueError('unknown numeric_missing_strategy')
for c in cat_cols:
if cat_missing_strategy == 'mode':
mode = df2[c].mode()
fill = mode[0] if not mode.empty else 'unknown'
df2[c] = df2[c].fillna(fill)
else:
df2[c] = df2[c].fillna('unknown')
info['missing_after'] = df2[num_cols].isna().sum().to_dict()
outlier_summary = []
for c in num_cols:
col = df2[c]
iqr_mask = detect_outliers_iqr(col)
outlier_summary.append({'column': c, 'iqr_count': int(iqr_mask.sum())})
info['outlier_summary'] = outlier_summary
if outlier_strategy == 'mark':
pass
elif outlier_strategy == 'interpolate':
df2 = df2.set_index('timestamp')
for c in num_cols:
mask = detect_outliers_iqr(df2[c])
df2.loc[mask, c] = np.nan
df2[num_cols] = df2[num_cols].interpolate(method='time', limit_direction='both')
df2 = df2.reset_index()
elif outlier_strategy == 'winsorize':
for c in num_cols:
df2[c] = winsorize_series(df2[c])
elif outlier_strategy == 'drop':
for c in num_cols:
mask = detect_outliers_iqr(df2[c])
df2 = df2.loc[~mask].reset_index(drop=True)
else:
raise ValueError('unknown outlier_strategy')
if resample_freq is not None:
df2 = df2.set_index('timestamp')
agg = {}
for c in num_cols:
lname = c.lower()
if any(k in lname for k in ('case', 'count', 'death', 'new', 'confirmed', 'positive', 'tests')):
agg[c] = 'sum'
else:
agg[c] = 'mean'
res = df2.resample(resample_freq).agg(agg)
for c in cat_cols:
res[c] = df2[c].resample(resample_freq).first()
res = res.reset_index()
df2 = res
if 'timestamp' in df2.columns:
ts = pd.to_datetime(df2['timestamp'])
if ts.dt.tz is None:
df2['timestamp'] = ts.dt.tz_localize('Europe/Moscow')
else:
df2['timestamp'] = ts.dt.tz_convert('Europe/Moscow')
info['final_shape'] = df2.shape
return df2, info
# ---------------- Descriptive (3.3) ----------------
def descriptive_statistics(df: pd.DataFrame, numeric_cols: List[str]) -> pd.DataFrame:
rows = []
for c in numeric_cols:
s = df[c].dropna()
rows.append({
'column': c,
'count': int(s.count()),
'mean': float(s.mean()) if not s.empty else None,
'median': float(s.median()) if not s.empty else None,
'std': float(s.std()) if not s.empty else None,
'min': float(s.min()) if not s.empty else None,
'q1': float(s.quantile(0.25)) if not s.empty else None,
'q3': float(s.quantile(0.75)) if not s.empty else None,
'max': float(s.max()) if not s.empty else None,
'skew': float(s.skew()) if not s.empty else None,
'kurtosis': float(s.kurtosis()) if not s.empty else None,
'missing_pct': float(df[c].isna().mean())
})
return pd.DataFrame(rows).set_index('column')
# ---------------- Stationarity (3.4) helpers ----------------
def run_adf(series: pd.Series) -> Dict:
try:
res = adfuller(series.dropna().values, autolag='AIC')
return {'statistic': res[0], 'pvalue': res[1], 'usedlag': res[2], 'nobs': res[3]}
except Exception as e:
return {'error': str(e)}
def run_kpss(series: pd.Series) -> Dict:
try:
res = kpss(series.dropna().values, nlags='auto')
return {'statistic': res[0], 'pvalue': res[1], 'nlags': res[2]}
except Exception as e:
return {'error': str(e)}
# ---------------- Lag & Rolling (3.5) ----------------
def create_lags_and_rolls(df: pd.DataFrame, target: str, lags: List[int], roll_windows: List[int], extra_features: List[str] = None) -> pd.DataFrame:
df2 = df.copy().set_index('timestamp')
df2 = df2.sort_index()
for l in lags:
df2[f'{target}_lag_{l}'] = df2[target].shift(l)
if extra_features:
for feat in extra_features:
for l in lags:
df2[f'{feat}_lag_{l}'] = df2[feat].shift(l)
for w in roll_windows:
df2[f'{target}_roll_mean_{w}'] = df2[target].rolling(window=w, min_periods=1).mean()
df2[f'{target}_roll_std_{w}'] = df2[target].rolling(window=w, min_periods=1).std()
return df2.reset_index()
def compute_lag_correlations(df: pd.DataFrame, target: str, lags: List[int]) -> pd.DataFrame:
cols = [f'{target}_lag_{l}' for l in lags if f'{target}_lag_{l}' in df.columns]
corr_rows = []
for c in cols:
corr = df[[target, c]].dropna().corr().iloc[0, 1]
corr_rows.append({'lag_col': c, 'corr_with_target': float(corr) if pd.notna(corr) else None})
return pd.DataFrame(corr_rows).set_index('lag_col')
def compute_vif(df: pd.DataFrame, features: List[str]) -> pd.DataFrame:
X = df[features].dropna()
if X.shape[0] == 0:
return pd.DataFrame({'feature': features, 'VIF': [None] * len(features)}).set_index('feature')
X_const = add_constant(X)
vif_vals = []
for i, col in enumerate(X.columns):
try:
v = variance_inflation_factor(X_const.values, i + 1)
except Exception:
v = np.nan
vif_vals.append({'feature': col, 'VIF': float(v) if pd.notna(v) else None})
return pd.DataFrame(vif_vals).set_index('feature')
# ---------------- ACF/PACF helpers (3.6) ----------------
def get_acf_pacf_with_conf(series: pd.Series, nlags: int = 40, alpha: float = 0.05):
acf_vals, acf_confint = sm_acf(series.dropna().values, nlags=nlags, alpha=alpha)
pacf_vals, pacf_confint = sm_pacf(series.dropna().values, nlags=nlags, alpha=alpha)
return acf_vals, acf_confint, pacf_vals, pacf_confint
def significant_lags_from_conf(vals: np.ndarray, confint: np.ndarray) -> List[int]:
sig = []
for i in range(1, len(vals)):
lower, upper = confint[i]
v = vals[i]
if (v < lower) or (v > upper):
sig.append(i)
return sig
def plotly_acf_pacf(acf_vals, acf_conf, pacf_vals, pacf_conf, max_lag, title_prefix=''):
# build ACF bar + conf intervals
lags = list(range(len(acf_vals)))[: max_lag + 1]
acf_fig = go.Figure()
acf_fig.add_trace(go.Bar(x=lags, y=acf_vals[:len(lags)], name='ACF'))
# conf intervals as lines
if acf_conf is not None and len(acf_conf) >= len(lags):
lower = [acf_conf[i][0] for i in lags]
upper = [acf_conf[i][1] for i in lags]
acf_fig.add_trace(go.Scatter(x=lags, y=upper, mode='lines', line=dict(width=1), name='conf_upper'))
acf_fig.add_trace(go.Scatter(x=lags, y=lower, mode='lines', line=dict(width=1), name='conf_lower'))
acf_fig.update_layout(title=f'{title_prefix} ACF', xaxis_title='lag')
lags_p = list(range(len(pacf_vals)))[: max_lag + 1]
pacf_fig = go.Figure()
pacf_fig.add_trace(go.Bar(x=lags_p, y=pacf_vals[:len(lags_p)], name='PACF'))
if pacf_conf is not None and len(pacf_conf) >= len(lags_p):
lowerp = [pacf_conf[i][0] for i in lags_p]
upperp = [pacf_conf[i][1] for i in lags_p]
pacf_fig.add_trace(go.Scatter(x=lags_p, y=upperp, mode='lines', line=dict(width=1), name='conf_upper'))
pacf_fig.add_trace(go.Scatter(x=lags_p, y=lowerp, mode='lines', line=dict(width=1), name='conf_lower'))
pacf_fig.update_layout(title=f'{title_prefix} PACF', xaxis_title='lag')
return acf_fig, pacf_fig
# ---------------- Report generation (3.8 helpers) ----------------
def generate_html_report(
df: pd.DataFrame,
target: str,
features: List[str],
params: Dict,
figs: Dict[str, any],
tables: Dict[str, pd.DataFrame]
) -> str:
parts = []
parts.append(f"
Отчёт по временным рядам — target: {target}
")
parts.append(f"Параметры: {params}
")
# include time series fig
if 'series' in figs:
parts.append('Временной ряд
')
parts.append(figs['series'].to_html(full_html=False, include_plotlyjs='cdn'))
if 'decomp' in figs:
parts.append('Декомпозиция
')
parts.append(figs['decomp_observed'].to_html(full_html=False, include_plotlyjs='cdn'))
parts.append(figs['decomp_trend'].to_html(full_html=False, include_plotlyjs='cdn'))
parts.append(figs['decomp_seasonal'].to_html(full_html=False, include_plotlyjs='cdn'))
parts.append(figs['decomp_resid'].to_html(full_html=False, include_plotlyjs='cdn'))
if 'corr' in figs:
parts.append('Матрица корреляций
')
parts.append(figs['corr'].to_html(full_html=False, include_plotlyjs='cdn'))
if 'acf' in figs and 'pacf' in figs:
parts.append('ACF / PACF
')
parts.append(figs['acf'].to_html(full_html=False, include_plotlyjs='cdn'))
parts.append(figs['pacf'].to_html(full_html=False, include_plotlyjs='cdn'))
# tables
for name, table in tables.items():
parts.append(f'{name}
')
parts.append(table.to_html(classes="table table-striped", index=True))
html = '' + ''.join(parts) + ''
return html
# ---------------- Функция для отображения ЛР №1 ----------------
def render_lab1():
st.title("🧪 Лабораторная работа №1: Введение в анализ временных рядов")
st.markdown("**Этапы:** Сбор, очистка, визуализация и диагностика многомерных данных")
# Sidebar
st.sidebar.header("Настройки")
uploaded_file = st.sidebar.file_uploader("Загрузите CSV/Parquet", type=['csv', 'parquet'])
# small built-in example option (uses local file if present)
sample_option = None
if os.path.exists('russia_covid_dataset.csv'):
sample_option = 'russia_covid_dataset.csv'
sample_choice = st.sidebar.selectbox('Или выбрать предзагруженный пример', options=[None, sample_option] if sample_option else [None])
tz_assume = st.sidebar.selectbox("Как трактовать tz-naive метки?",
options=['local', 'utc', 'keep'], index=0,
format_func=lambda x: {'local': 'локально (Europe/Moscow)', 'utc': 'UTC->Moscow', 'keep': 'не трогать'}[x])
numeric_missing_strategy = st.sidebar.selectbox("Заполнение пропусков (числ.)", options=['interpolate', 'drop', 'rolling'], index=0)
cat_missing_strategy = st.sidebar.selectbox("Заполнение пропусков (категор.)", options=['mode', 'unknown'], index=0)
outlier_strategy = st.sidebar.selectbox("Обработка выбросов", options=['interpolate', 'winsorize', 'drop', 'mark'], index=0)
resample_freq = st.sidebar.selectbox("Ресемплить к частоте (если нужно)", options=[None, 'D', 'W', 'M'], index=1)
# load dataset and persist
if 'df_in' not in st.session_state:
st.session_state['df_in'] = None
if uploaded_file is not None:
try:
if uploaded_file.name.endswith('.parquet'):
df_in = pd.read_parquet(uploaded_file)
else:
df_in = pd.read_csv(uploaded_file, low_memory=False)
st.session_state['df_in'] = df_in
st.success(f"Загружен файл: {uploaded_file.name} ({df_in.shape[0]}×{df_in.shape[1]})")
except Exception as e:
st.error(f"Ошибка загрузки: {e}")
st.stop()
elif sample_choice:
st.session_state['df_in'] = pd.read_csv(sample_choice, low_memory=False)
st.info(f"Выбран пример: {sample_choice}")
else:
local_path = 'russia_covid_dataset.csv'
if st.session_state['df_in'] is None and os.path.exists(local_path):
st.session_state['df_in'] = pd.read_csv(local_path, low_memory=False)
st.info(f"Авто-загружен локальный файл {local_path}")
elif st.session_state['df_in'] is None:
st.info("Загрузите файл или поместите russia_covid_dataset.csv в рабочую папку.")
st.stop()
df_in = st.session_state['df_in']
st.subheader("Preview входного датасета")
st.dataframe(df_in.head(8))
# detect date column
detected = detect_date_column(df_in)
col_for_date = st.text_input("Колонка с временной меткой", value=detected if detected else "")
if not col_for_date:
st.error("Укажите колонку с временной меткой.")
st.stop()
# Run buttons
col1, col2 = st.columns([1, 1])
with col1:
run_btn = st.button("Run Preprocessing")
with col2:
force_btn = st.button("Force Recompute (пересчитать)")
# session keys
st.session_state.setdefault('preprocessed', False)
st.session_state.setdefault('df_clean', None)
st.session_state.setdefault('info', {})
st.session_state.setdefault('df_lags', None)
if run_btn or force_btn or (not st.session_state['preprocessed'] and st.session_state['df_clean'] is None):
df_clean, info = preprocess_timeseries(
df_in,
date_col=col_for_date,
tz_assume=tz_assume,
numeric_missing_strategy=numeric_missing_strategy,
cat_missing_strategy=cat_missing_strategy,
outlier_strategy=outlier_strategy,
resample_freq=resample_freq,
)
st.session_state['df_clean'] = df_clean
st.session_state['info'] = info
st.session_state['preprocessed'] = True
# Main UI after preprocess
if st.session_state.get('preprocessed'):
df_clean = st.session_state['df_clean']
info = st.session_state['info']
st.subheader("Финальный датасет (первые строки)")
st.dataframe(df_clean.head(10))
st.markdown(f"**Размер до/после:** {df_in.shape} → {info.get('final_shape')}")
st.markdown(f"**Доля распарсенных дат:** {info.get('parse_success', 0):.2%}")
st.markdown(f"**Удалено строк без даты:** {info.get('dropped_no_timestamp', 0)}")
st.download_button("Скачать final_dataset.csv", data=df_clean.to_csv(index=False).encode('utf-8'), file_name='final_dataset.csv', mime='text/csv')
# 3.3 Descriptive
st.header("Этап 3.3 — Описательная статистика и визуализация")
numeric_cols = df_clean.select_dtypes(include=[np.number]).columns.tolist()
if not numeric_cols:
st.warning("Нет числовых колонок для анализа.")
else:
stats_df = descriptive_statistics(df_clean, numeric_cols)
st.subheader("Дескриптивная статистика")
st.dataframe(stats_df)
st.subheader("Гистограммы / Boxplot / Pairwise")
sel = st.multiselect("Выбрать колонки для графиков", numeric_cols, default=numeric_cols[:3])
for c in sel:
c1, c2 = st.columns(2)
with c1:
fig = px.histogram(df_clean, x=c, nbins=60, title=f'Histogram: {c}')
st.plotly_chart(fig, use_container_width=True)
with c2:
figb = go.Figure()
figb.add_trace(go.Box(y=df_clean[c], name=c))
st.plotly_chart(figb, use_container_width=True)
if len(sel) >= 2:
st.subheader("Scatter matrix")
figm = px.scatter_matrix(df_clean, dimensions=sel[:6], title='Scatter matrix (часть признаков)')
st.plotly_chart(figm, use_container_width=True)
st.subheader("Матрица корреляций")
corr_method = st.selectbox("Тип корреляции", options=['pearson', 'spearman'], index=0)
corr = df_clean[numeric_cols].corr(method=corr_method)
figc = px.imshow(corr, text_auto=True, title=f'Correlation ({corr_method})')
st.plotly_chart(figc, use_container_width=True)
# 3.4 Stationarity
st.header("Этап 3.4 — Проверка на стационарность (ADF/KPSS) и визуальная диагностика")
if not numeric_cols:
st.info("Нет числовых колонок для тестов.")
else:
station_target = st.selectbox("Выберите колонку для тестов", options=numeric_cols, index=0, key='station_target')
window1 = st.number_input("Окно rolling mean/std (точки)", min_value=3, max_value=365, value=30)
s = df_clean.set_index('timestamp')[station_target].dropna()
fig = go.Figure()
fig.add_trace(go.Scatter(x=s.index, y=s.values, name='series'))
roll_mean = s.rolling(window=window1, min_periods=1).mean()
roll_std = s.rolling(window=window1, min_periods=1).std()
fig.add_trace(go.Scatter(x=roll_mean.index, y=roll_mean.values, name=f'rolling_mean_{window1}'))
fig.update_layout(title=f'Series & rolling mean ({station_target})', height=400)
st.plotly_chart(fig, use_container_width=True)
fig2 = go.Figure()
fig2.add_trace(go.Scatter(x=roll_std.index, y=roll_std.values, name=f'rolling_std_{window1}'))
fig2.update_layout(title=f'Rolling std ({station_target})', height=300)
st.plotly_chart(fig2, use_container_width=True)
if st.button("Run stationarity tests"):
adf_res = run_adf(s)
kpss_res = run_kpss(s)
alpha = 0.05
adf_stationary = ('pvalue' in adf_res) and (adf_res['pvalue'] < alpha)
kpss_stationary = ('pvalue' in kpss_res) and (kpss_res['pvalue'] > alpha)
st.subheader("Результаты тестов")
st.write("ADF:", adf_res)
st.write("KPSS:", kpss_res)
st.markdown(f"Интерпретация при α={alpha}: ")
st.write(f"- ADF говорит, что ряд {'стационарен' if adf_stationary else 'НЕ стационарен'} (p={adf_res.get('pvalue','?')})")
st.write(f"- KPSS говорит, что ряд {'стационарен' if kpss_stationary else 'НЕ стационарен'} (p={kpss_res.get('pvalue','?')})")
if adf_stationary and kpss_stationary:
st.success("Оба теста согласны: ряд, скорее всего, стационарен.")
elif (not adf_stationary) and (not kpss_stationary):
st.warning("Оба теста указывают на нестационарность → рекомендуем дифференцирование / детренд / лог-трансформацию.")
else:
st.info("Тесты противоречат друг другу — смотрите графики rolling mean/std и пробуйте трансформации (log/diff).")
st.subheader("Применить дифференцирование и повторить тесты")
diff_order = st.number_input("Порядок дифференцирования (целое >=1)", min_value=1, max_value=5, value=1, step=1)
if st.button("Apply diff & Re-test"):
s_diff = s.diff(periods=diff_order).dropna()
adf_res = run_adf(s_diff)
kpss_res = run_kpss(s_diff)
st.write(f"Результаты для {diff_order}-го диффа:")
st.write("ADF:", adf_res)
st.write("KPSS:", kpss_res)
figd = px.line(x=s_diff.index, y=s_diff.values, title=f'Differenced series (order={diff_order})')
st.plotly_chart(figd, use_container_width=True)
if st.checkbox("Сохранить дифференцированный ряд в session (переопределит final_dataset)", value=False):
df_store = df_clean.copy()
df_store[station_target] = df_store[station_target].diff(periods=diff_order)
df_store = df_store.dropna(subset=[station_target]).reset_index(drop=True)
st.session_state['df_clean'] = df_store
st.success("Дифференцированный ряд сохранён в final_dataset (session).")
# 3.5 Lag & Rolling features
st.header("Этап 3.5 — Создание лагов и скользящих статистик")
if not numeric_cols:
st.info("Нет числовых колонок для создания лагов.")
else:
st.subheader("Параметры генерации лагов/скользящих")
target_col = st.selectbox("Выберите целевую колонку (target)", options=numeric_cols, index=0, key='lag_target')
default_lags = st.text_input("Список лагов через запятую (напр. 1,7,30)", value='1,7,30')
default_rolls = st.text_input("Список окон для скользящих через запятую (напр. 7,30)", value='7,30')
extra_feats_raw = st.text_input("Доп. признаки для лагов (через запятую), необязательно", value='')
try:
lags = [int(x.strip()) for x in default_lags.split(',') if x.strip()]
except Exception:
lags = [1, 7, 30]
try:
rolls = [int(x.strip()) for x in default_rolls.split(',') if x.strip()]
except Exception:
rolls = [7, 30]
extra_feats = [x.strip() for x in extra_feats_raw.split(',') if x.strip()]
extra_feats = [f for f in extra_feats if f in df_clean.columns]
if st.button('Generate lags & rolls'):
df_lags = create_lags_and_rolls(df_clean, target_col, lags, rolls, extra_features=extra_feats)
st.session_state['df_lags'] = df_lags
st.success(f'Создан датасет с лагами: shape={df_lags.shape}')
if st.session_state.get('df_lags') is not None:
df_lags = st.session_state['df_lags']
st.subheader('Первые строки с лагами')
st.dataframe(df_lags.head(10))
st.subheader('Корреляция лагов с target')
corr_lags = compute_lag_correlations(df_lags, target_col, lags)
st.dataframe(corr_lags)
st.subheader('Heatmap корреляций (лаги + target + дополнительные фичи)')
lag_cols = [f'{target_col}_lag_{l}' for l in lags if f'{target_col}_lag_{l}' in df_lags.columns]
numeric_subset = [target_col] + lag_cols + [c for c in extra_feats if c in df_lags.select_dtypes(include=[np.number]).columns]
if len(numeric_subset) >= 2:
corr2 = df_lags[numeric_subset].corr()
figh = px.imshow(corr2, text_auto=True, title='Lag correlations heatmap')
st.plotly_chart(figh, use_container_width=True)
st.subheader('Проверка мультиколлинеарности (VIF) для признаков с лагами')
candidate_feats = st.multiselect('Выберите признаки для VIF (по умолчанию lag-колонки)', options=numeric_subset, default=lag_cols)
if candidate_feats:
vif_df = compute_vif(df_lags, candidate_feats)
st.dataframe(vif_df)
st.download_button('Скачать датасет с лагами (CSV)', data=df_lags.to_csv(index=False).encode('utf-8'), file_name='dataset_with_lags.csv', mime='text/csv')
if st.checkbox('Сохранить датасет с лагами в session (df_clean <- df_lags конвертировать)', value=False):
st.session_state['df_clean'] = df_lags
st.success('final_dataset в session заменён на датасет с лагами.')
# 3.6 ACF / PACF
st.header("Этап 3.6 — Анализ автокорреляции: ACF и PACF")
if not numeric_cols:
st.info('Нет числовых колонок для ACF/PACF.')
else:
acf_target = st.selectbox('Выберите колонку для ACF/PACF', options=numeric_cols, index=0, key='acf_target')
max_lag = st.number_input('Максимальный лаг (nlags)', min_value=10, max_value=500, value=40, step=1)
alpha = st.slider('Уровень значимости для доверительного интервала (alpha)', min_value=0.01, max_value=0.2, value=0.05, step=0.01)
s_acf = df_clean.set_index('timestamp')[acf_target].dropna()
if len(s_acf) < 2:
st.warning('Недостаточно наблюдений для ACF/PACF.')
else:
try:
acf_vals, acf_conf, pacf_vals, pacf_conf = get_acf_pacf_with_conf(s_acf, nlags=int(max_lag), alpha=float(alpha))
except Exception as e:
st.error(f'Ошибка при вычислении ACF/PACF: {e}')
acf_vals = pacf_vals = np.array([])
acf_conf = pacf_conf = np.array([])
fig_acf = plt.figure(figsize=(10, 4))
plot_acf(s_acf.values, lags=int(max_lag), alpha=alpha, zero=True, title=f'ACF: {acf_target}', ax=fig_acf.gca())
st.pyplot(fig_acf)
fig_pacf = plt.figure(figsize=(10, 4))
plot_pacf(s_acf.values, lags=int(max_lag), alpha=alpha, method='ywm', title=f'PACF: {acf_target}', ax=fig_pacf.gca())
st.pyplot(fig_pacf)
sig_acf = significant_lags_from_conf(acf_vals, acf_conf) if acf_vals.size else []
sig_pacf = significant_lags_from_conf(pacf_vals, pacf_conf) if pacf_vals.size else []
st.subheader('Статистически значимые лаги (по доверительным интервалам)')
st.write('ACF значимые лаги:', sig_acf)
st.write('PACF значимые лаги:', sig_pacf)
acf_rows = []
for i in range(min(len(acf_vals), int(max_lag) + 1)):
lower, upper = acf_conf[i] if acf_conf.size else (None, None)
acf_rows.append({'lag': i, 'acf': float(acf_vals[i]), 'conf_low': float(lower) if lower is not None else None, 'conf_high': float(upper) if upper is not None else None})
pacf_rows = []
for i in range(min(len(pacf_vals), int(max_lag) + 1)):
lower, upper = pacf_conf[i] if pacf_conf.size else (None, None)
pacf_rows.append({'lag': i, 'pacf': float(pacf_vals[i]), 'conf_low': float(lower) if lower is not None else None, 'conf_high': float(upper) if upper is not None else None})
st.subheader('ACF values (таблица)')
st.dataframe(pd.DataFrame(acf_rows).set_index('lag'))
st.subheader('PACF values (таблица)')
st.dataframe(pd.DataFrame(pacf_rows).set_index('lag'))
st.markdown('**Интерпретация (упрощённо):** - Резкий обрыв в PACF на лаге p → возможный порядок AR(p). - Плавное затухание в ACF → возможный порядок MA(q). - Лаги, выходящие за доверительный интервал — статистически значимы.')
# 3.7 Decomposition
st.header("Этап 3.7 — Декомпозиция временного ряда")
if not numeric_cols:
st.info('Нет числовых колонок для декомпозиции.')
else:
decomp_target = st.selectbox('Выберите колонку для декомпозиции', options=numeric_cols, index=0, key='decomp_target')
model_choice = st.radio('Модель декомпозиции', options=['additive', 'multiplicative'], index=0)
period_option = st.selectbox('Период сезонности (если известен)', options=['auto', '7', '30', '365', 'custom'], index=0)
custom_period = None
if period_option == 'custom':
custom_period = st.number_input('Введите период (целое >1)', min_value=2, value=30, step=1)
if period_option == 'auto':
inferred = None
try:
tmp = df_clean.set_index('timestamp')[decomp_target].dropna()
inferred_freq = pd.infer_freq(tmp.index)
if inferred_freq in ('D', 'B'):
suggested = 7
elif inferred_freq == 'W':
suggested = 52
else:
suggested = None
inferred = suggested
except Exception:
inferred = None
else:
inferred = int(period_option) if period_option in ('7', '30', '365') else None
period = custom_period if custom_period is not None else inferred
st.write(f'Выбранная модель: {model_choice}. Период: {period if period is not None else "не задан (нужен для правильной декомпозиции)"}.')
if st.button('Run decomposition'):
s = df_clean.set_index('timestamp')[decomp_target].dropna()
if period is None:
st.error('Период не определён. Укажите период (например 7 для недельной сезонности) или используйте custom.')
elif len(s) < period * 2:
st.error(f'Недостаточно точек для надёжной декомпозиции при периоде={period}. Нужно >= 2*period наблюдений. У вас {len(s)}.')
else:
try:
decomp = seasonal_decompose(s, period=int(period), model=model_choice, extrapolate_trend='freq')
st.session_state['decomp'] = decomp
comp_df = pd.DataFrame({'timestamp': s.index, 'observed': decomp.observed, 'trend': decomp.trend, 'seasonal': decomp.seasonal, 'resid': decomp.resid}).reset_index(drop=True)
st.session_state['decomp_df'] = comp_df
st.subheader('Графики компонентов')
st.plotly_chart(px.line(comp_df, x='timestamp', y='observed', title='Observed'), use_container_width=True)
st.plotly_chart(px.line(comp_df, x='timestamp', y='trend', title='Trend'), use_container_width=True)
st.plotly_chart(px.line(comp_df, x='timestamp', y='seasonal', title='Seasonal'), use_container_width=True)
st.plotly_chart(px.line(comp_df, x='timestamp', y='resid', title='Residuals'), use_container_width=True)
st.success('Декомпозиция выполнена и сохранена в сессии (decomp, decomp_df).')
st.subheader('Анализ компонентов')
trend_nonnull = comp_df['trend'].dropna()
if len(trend_nonnull) > 2:
xnum = np.arange(len(trend_nonnull))
coef = np.polyfit(xnum, trend_nonnull.values, 1)
slope = coef[0]
st.write(f'- Приблизительный линейный наклон тренда: {slope:.6f} ({"вырос" if slope>0 else "упал"}).')
else:
st.write('- Слишком мало данных в компоненте trend для оценки наклона.')
seasonal = comp_df['seasonal'].dropna()
if not seasonal.empty:
amp = seasonal.max() - seasonal.min()
st.write(f'- Амплитуда сезонной компоненты: {amp:.4f} (max={seasonal.max():.4f}, min={seasonal.min():.4f}).')
resid = comp_df['resid'].dropna()
st.subheader('Диагностика остатков')
st.write(f'- Длина остатков: {len(resid)}')
if len(resid) > 3:
adf_r = run_adf(resid)
kpss_r = run_kpss(resid)
st.write('ADF (resid):', adf_r)
st.write('KPSS (resid):', kpss_r)
a_stat = ('pvalue' in adf_r) and (adf_r['pvalue'] < 0.05)
k_stat = ('pvalue' in kpss_r) and (kpss_r['pvalue'] > 0.05)
if a_stat and k_stat:
st.success('Остатки выглядят стационарными по ADF и KPSS — декомпозиция адекватна.')
else:
st.warning('Остатки, возможно, нестационарны. Посмотрите на график остатков и подумайте о дополнительных преобразованиях или изменении периода/модели.')
else:
st.info('Недостаточно данных для тестов остатков.')
st.download_button('Скачать компоненты (CSV)', data=comp_df.to_csv(index=False).encode('utf-8'), file_name='decomposition_components.csv', mime='text/csv')
except Exception as e:
st.error(f'Ошибка при декомпозиции: {e}')
st.info('Этап 3.7 завершён. Дальше можно делать ACF/PACF на остатках, моделирование или формирование отчёта.')
# ---------------- 3.8 Web interface & report export ----------------
st.header('Этап 3.8 — Веб-интерфейс, конфигурация и экспорт отчёта')
st.markdown('Здесь собраны управляющие элементы для быстрой генерации HTML-отчёта и экспорта результатов. Отчёт включает: график ряда, скользящее среднее, матрицу корреляций, ACF/PACF и декомпозицию.')
# Unified controls
with st.expander('Параметры для отчёта'):
report_target = st.selectbox('Target для отчёта', options=numeric_cols, index=0)
report_features = st.multiselect('Доп. признаки для отчёта (включаются в корреляции)', options=numeric_cols, default=[c for c in numeric_cols if c != report_target][:2])
report_roll = st.number_input('Окно для скользящего среднего в отчёте', min_value=2, max_value=365, value=30)
report_acf_lags = st.number_input('nlags для ACF/PACF в отчёте', min_value=10, max_value=500, value=40)
report_period = st.selectbox('Период для декомпозиции в отчёте', options=[None, 7, 30, 365], index=1)
if st.button('Сгенерировать и показать отчёт (вкладки ниже)'):
# prepare figures
figs = {}
# time series with rolling
s = df_clean.set_index('timestamp')[report_target].dropna()
fig_series = go.Figure()
fig_series.add_trace(go.Scatter(x=s.index, y=s.values, mode='lines', name='observed'))
fig_series.add_trace(go.Scatter(x=s.rolling(window=report_roll, min_periods=1).mean().index, y=s.rolling(window=report_roll, min_periods=1).mean().values, mode='lines', name=f'roll_mean_{report_roll}'))
fig_series.update_layout(title=f'Series: {report_target}', height=350)
figs['series'] = fig_series
# corr
corr_cols = [report_target] + report_features
corr_df = df_clean[corr_cols].corr()
figs['corr'] = px.imshow(corr_df, text_auto=True, title='Correlation matrix')
# decomposition (if available)
if 'decomp_df' in st.session_state:
comp_df = st.session_state['decomp_df']
figs['decomp_observed'] = px.line(comp_df, x='timestamp', y='observed', title='Observed')
figs['decomp_trend'] = px.line(comp_df, x='timestamp', y='trend', title='Trend')
figs['decomp_seasonal'] = px.line(comp_df, x='timestamp', y='seasonal', title='Seasonal')
figs['decomp_resid'] = px.line(comp_df, x='timestamp', y='resid', title='Residuals')
else:
figs['decomp_observed'] = figs['decomp_trend'] = figs['decomp_seasonal'] = figs['decomp_resid'] = None
# acf/pacf (plotly version)
try:
acf_vals, acf_conf, pacf_vals, pacf_conf = get_acf_pacf_with_conf(s, nlags=int(report_acf_lags), alpha=0.05)
acf_fig, pacf_fig = plotly_acf_pacf(acf_vals, acf_conf, pacf_vals, pacf_conf, max_lag=int(report_acf_lags), title_prefix=report_target)
figs['acf'] = acf_fig
figs['pacf'] = pacf_fig
except Exception:
figs['acf'] = figs['pacf'] = None
# tables
tables = {'Descriptive': descriptive_statistics(df_clean, corr_cols), 'Correlation': corr_df}
# show in tabs
tab1, tab2, tab3 = st.tabs(['Графики', 'Таблицы', 'Экспорт'])
with tab1:
st.subheader('Временной ряд и rolling')
st.plotly_chart(figs['series'], use_container_width=True)
st.subheader('Матрица корреляций')
st.plotly_chart(figs['corr'], use_container_width=True)
if figs.get('decomp_observed') is not None:
st.subheader('Декомпозиция')
st.plotly_chart(figs['decomp_observed'], use_container_width=True)
st.plotly_chart(figs['decomp_trend'], use_container_width=True)
st.plotly_chart(figs['decomp_seasonal'], use_container_width=True)
st.plotly_chart(figs['decomp_resid'], use_container_width=True)
if figs.get('acf') is not None:
st.subheader('ACF / PACF')
st.plotly_chart(figs['acf'], use_container_width=True)
st.plotly_chart(figs['pacf'], use_container_width=True)
with tab2:
st.subheader('Таблицы')
for name, table in tables.items():
st.write(name)
st.dataframe(table)
with tab3:
st.subheader('Экспорт отчёта')
params = {'roll': int(report_roll), 'acf_lags': int(report_acf_lags), 'period': report_period}
html = generate_html_report(df_clean, report_target, report_features, params, figs, tables)
html_bytes = html.encode('utf-8')
st.download_button('Скачать HTML-отчёт', data=html_bytes, file_name='ts_report.html', mime='text/html')
# try PDF (if pdfkit available)
try:
import pdfkit
# Попытка конвертировать HTML в PDF (требует установленного wkhtmltopdf)
pdf_bytes = pdfkit.from_string(html, False)
st.download_button('Скачать PDF-отчёт', data=pdf_bytes, file_name='ts_report.pdf',
mime='application/pdf')
except Exception:
st.info(
'PDF-конверсия недоступна (pdfkit/wkhtmltopdf не установлены). Скачайте HTML и конвертируйте локально, если нужно.')
# ---------------- Функция для отображения ЛР №2 ----------------
def render_lab2():
if not LAB2_AVAILABLE:
st.error("Функции ЛР №2 недоступны. Убедитесь, что файл lab2_functions.py существует и все зависимости установлены.")
return
st.title("🧪 Лабораторная работа №2: Прогнозирование временных рядов")
st.markdown("**Этапы:** Стратегии прогнозирования, валидация и модели экспоненциального сглаживания")
st.markdown("""
---
## 📖 Что происходит в этой работе?
**Цель:** Научиться строить модели для прогнозирования будущих значений временного ряда.
**Простыми словами:**
1. У вас есть данные за прошлое (например, продажи за последние 2 года)
2. Вы хотите предсказать, что будет в будущем (например, продажи на следующий месяц)
3. Для этого мы строим модели, которые "учатся" на прошлых данных и делают прогнозы
**Этапы работы:**
- **Этап 1:** Разбираем ряд на части (тренд, сезонность, остатки)
- **Этап 2:** Создаём дополнительные признаки (день недели, лаги и т.д.)
- **Этап 3:** Выбираем стратегию прогнозирования
- **Этап 4:** Проверяем качество моделей через кросс-валидацию
- **Этап 5:** Приводим данные к стационарному виду (убираем тренд)
- **Этап 6-7:** Строим модели и сравниваем их
- **Этап 8:** Анализируем результаты и выбираем лучшую модель
**Как работать:**
1. Начните с Этапа 1 - выполните декомпозицию
2. Перейдите к Этапу 5 - настройте преобразования (можно оставить по умолчанию)
3. В Этапе 6-7 нажмите кнопку "Применить преобразования и построить модели"
4. Посмотрите результаты в Этапе 8
---
""")
# Проверка наличия данных из ЛР №1
if 'df_clean' not in st.session_state or st.session_state['df_clean'] is None:
st.warning("⚠️ Сначала выполните предобработку данных в ЛР №1 или загрузите готовый датасет.")
uploaded_file = st.file_uploader("Загрузите предобработанный CSV/Parquet", type=['csv', 'parquet'], key='lab2_upload')
if uploaded_file is not None:
try:
if uploaded_file.name.endswith('.parquet'):
df_clean = pd.read_parquet(uploaded_file)
else:
df_clean = pd.read_csv(uploaded_file, low_memory=False)
if 'timestamp' not in df_clean.columns:
st.error("В датасете должна быть колонка 'timestamp'")
return
df_clean['timestamp'] = pd.to_datetime(df_clean['timestamp'])
st.session_state['df_clean'] = df_clean
st.success(f"Загружен файл: {uploaded_file.name}")
except Exception as e:
st.error(f"Ошибка загрузки: {e}")
return
else:
st.stop()
df_clean = st.session_state['df_clean'].copy()
numeric_cols = df_clean.select_dtypes(include=[np.number]).columns.tolist()
if len(numeric_cols) == 0:
st.error("Нет числовых колонок для анализа")
return
# Выбор целевой переменной
st.sidebar.header("Параметры прогнозирования")
target_col = st.sidebar.selectbox("Целевая переменная", options=numeric_cols, index=0)
horizon = st.sidebar.number_input("Горизонт прогнозирования (h)", min_value=1, max_value=365, value=7, step=1)
# Разделение на train/test
st.header("Этап 1: Углублённая декомпозиция и анализ остатков")
if len(df_clean) < 500:
st.warning(f"⚠️ Рекомендуется не менее 500 наблюдений для обучения. У вас {len(df_clean)}")
train_size = st.sidebar.number_input("Размер обучающей выборки", min_value=100, max_value=len(df_clean)-50, value=min(500, len(df_clean)-50))
test_size = len(df_clean) - train_size
if test_size < 50:
st.error(f"Тестовая выборка слишком мала ({test_size}). Уменьшите размер обучающей выборки.")
return
df_clean = df_clean.sort_values('timestamp').reset_index(drop=True)
train_data = df_clean.iloc[:train_size]
test_data = df_clean.iloc[train_size:]
st.info(f"Обучающая выборка: {len(train_data)} наблюдений ({train_data['timestamp'].min()} - {train_data['timestamp'].max()})")
st.info(f"Тестовая выборка: {len(test_data)} наблюдений ({test_data['timestamp'].min()} - {test_data['timestamp'].max()})")
# Декомпозиция
with st.expander("Этап 1: Декомпозиция и анализ остатков", expanded=True):
decomp_model = st.selectbox("Модель декомпозиции", options=['additive', 'multiplicative'], index=0, key='decomp_model')
decomp_period = st.number_input("Период сезонности", min_value=2, max_value=365, value=7, key='decomp_period')
if st.button("Выполнить декомпозицию", key='btn_decomp'):
s_train = train_data.set_index('timestamp')[target_col].dropna()
if len(s_train) < decomp_period * 2:
st.error(f"Недостаточно данных для декомпозиции (нужно >= {decomp_period * 2}, есть {len(s_train)})")
else:
try:
decomp = seasonal_decompose(s_train, period=int(decomp_period), model=decomp_model, extrapolate_trend='freq')
st.session_state['decomp'] = decomp
st.session_state['saved_decomp_period'] = int(decomp_period) # Сохраняем период для использования в моделях (используем другой ключ, чтобы не конфликтовать с виджетом)
comp_df = pd.DataFrame({
'timestamp': s_train.index,
'observed': decomp.observed,
'trend': decomp.trend,
'seasonal': decomp.seasonal,
'resid': decomp.resid
})
st.session_state['decomp_df'] = comp_df
st.subheader("Графики компонентов декомпозиции")
col1, col2 = st.columns(2)
with col1:
st.plotly_chart(px.line(comp_df, x='timestamp', y='observed', title='Observed'), use_container_width=True)
st.plotly_chart(px.line(comp_df, x='timestamp', y='trend', title='Trend'), use_container_width=True)
with col2:
st.plotly_chart(px.line(comp_df, x='timestamp', y='seasonal', title='Seasonal'), use_container_width=True)
st.plotly_chart(px.line(comp_df, x='timestamp', y='resid', title='Residuals'), use_container_width=True)
# Анализ остатков
resid = comp_df['resid'].dropna()
if len(resid) > 3:
st.subheader("Анализ остатков декомпозиции")
adf_r = run_adf(resid)
kpss_r = run_kpss(resid)
col1, col2 = st.columns(2)
with col1:
st.write("**ADF (остатки):**", adf_r)
with col2:
st.write("**KPSS (остатки):**", kpss_r)
# ACF/PACF остатков
try:
acf_vals, acf_conf, pacf_vals, pacf_conf = get_acf_pacf_with_conf(resid, nlags=min(40, len(resid)//4), alpha=0.05)
fig_acf, fig_pacf = plotly_acf_pacf(acf_vals, acf_conf, pacf_vals, pacf_conf, max_lag=min(40, len(resid)//4), title_prefix='Residuals')
st.plotly_chart(fig_acf, use_container_width=True)
st.plotly_chart(fig_pacf, use_container_width=True)
except Exception as e:
st.warning(f"Не удалось построить ACF/PACF остатков: {e}")
st.success("Декомпозиция выполнена")
except Exception as e:
st.error(f"Ошибка декомпозиции: {e}")
# Этап 2: Feature Engineering
with st.expander("Этап 2: Расширенный feature engineering", expanded=False):
if st.button("Создать расширенные признаки", key='btn_features'):
df_features = create_advanced_features(train_data, target_col, timestamp_col='timestamp')
st.session_state['df_features'] = df_features
st.success(f"Создано признаков: {len(df_features.columns)}")
st.dataframe(df_features.head(10))
st.download_button("Скачать датасет с признаками", data=df_features.to_csv(index=False).encode('utf-8'), file_name='dataset_with_features.csv', mime='text/csv')
# Этап 3: Стратегии прогнозирования
st.header("Этап 3: Стратегии многопшагового прогнозирования")
# Этап 4: Кросс-валидация
with st.expander("Этап 4: Кросс-валидация для временных рядов", expanded=False):
cv_method = st.selectbox("Метод кросс-валидации", options=['sliding_window', 'expanding_window', 'TimeSeriesSplit'], index=0)
cv_train_size = st.number_input("Размер обучающей выборки для CV", min_value=50, max_value=train_size, value=min(300, train_size), key='cv_train_size')
cv_test_size = st.number_input("Размер тестовой выборки для CV", min_value=10, max_value=100, value=30, key='cv_test_size')
cv_step = st.number_input("Шаг для CV", min_value=1, max_value=50, value=10, key='cv_step')
if st.button("Выполнить кросс-валидацию", key='btn_cv'):
s_full = df_clean.set_index('timestamp')[target_col].dropna()
# Функция-обёртка для создания модели
def create_model_wrapper(data, **kwargs):
return create_exponential_smoothing_model(data, trend='add', seasonal=None, optimized=True)
try:
if cv_method == 'sliding_window':
cv_results = time_series_cv_sliding_window(
create_model_wrapper, s_full, train_size=cv_train_size,
test_size=cv_test_size, horizon=horizon, step=cv_step
)
elif cv_method == 'expanding_window':
cv_results = time_series_cv_expanding_window(
create_model_wrapper, s_full, initial_train_size=cv_train_size,
test_size=cv_test_size, horizon=horizon, step=cv_step
)
else: # TimeSeriesSplit
from sklearn.model_selection import TimeSeriesSplit
tscv = TimeSeriesSplit(n_splits=min(5, (len(s_full) - cv_train_size) // cv_test_size))
cv_results = []
for fold, (train_idx, test_idx) in enumerate(tscv.split(s_full), 1):
train_cv = s_full.iloc[train_idx]
test_cv = s_full.iloc[test_idx]
try:
model = create_model_wrapper(train_cv)
forecast = model.forecast(steps=min(horizon, len(test_cv)))
metrics = evaluate_forecast(test_cv.values[:len(forecast)], forecast)
metrics['fold'] = fold
cv_results.append(metrics)
except Exception as e:
st.warning(f"Ошибка в фолде {fold}: {e}")
if cv_results:
cv_df = pd.DataFrame(cv_results)
st.subheader("Результаты кросс-валидации")
st.dataframe(cv_df)
# Средние метрики
st.subheader("Средние метрики по фолдам")
avg_metrics = cv_df[['MAE', 'RMSE', 'MAPE']].mean()
st.dataframe(avg_metrics.to_frame('Среднее значение'))
# Визуализация метрик по фолдам
fig_cv = go.Figure()
fig_cv.add_trace(go.Scatter(x=cv_df['fold'], y=cv_df['MAE'], name='MAE', mode='lines+markers'))
fig_cv.add_trace(go.Scatter(x=cv_df['fold'], y=cv_df['RMSE'], name='RMSE', mode='lines+markers'))
fig_cv.update_layout(title='Метрики по фолдам кросс-валидации', xaxis_title='Фолд', yaxis_title='Значение метрики')
st.plotly_chart(fig_cv, use_container_width=True)
st.session_state['cv_results'] = cv_results
except Exception as e:
st.error(f"Ошибка кросс-валидации: {e}")
import traceback
st.code(traceback.format_exc())
# Этап 5: Преобразования к стационарности
st.header("📊 Этап 5: Приведение к стационарности и преобразования")
st.markdown("""
**Что это значит?**
Временные ряды часто имеют тренд (растут или падают) и меняющуюся дисперсию.
Многие модели требуют стационарных данных (без тренда, с постоянной дисперсией).
**Что нужно сделать:**
1. Выберите тип преобразования (или оставьте 'none' для начала)
2. Если нужно, укажите порядок дифференцирования (обычно 1)
3. Нажмите кнопку "Применить преобразования и построить модели" в следующем разделе
""")
with st.expander("⚙️ Настройки преобразований", expanded=True):
transform_type = st.selectbox(
"Тип преобразования",
options=['none', 'log', 'boxcox'],
index=0,
key='transform_type',
help="none = без преобразования, log = логарифм (для стабилизации дисперсии), boxcox = автоматический подбор преобразования"
)
lambda_param = None
if transform_type == 'boxcox':
lambda_param = st.number_input(
"Lambda для Бокса-Кокса (0 = авто)",
min_value=-5.0, max_value=5.0, value=0.0, step=0.1,
key='lambda_param',
help="Оставьте 0 для автоматического подбора оптимального значения"
)
if lambda_param == 0.0:
lambda_param = None
diff_order = st.number_input(
"Порядок дифференцирования",
min_value=0, max_value=3, value=0,
key='diff_order',
help="0 = без дифференцирования, 1 = первая разность (убирает тренд), 2 = вторая разность"
)
seasonal_diff = st.number_input(
"Сезонное дифференцирование (период, 0 = отключено)",
min_value=0, max_value=365, value=0,
key='seasonal_diff',
help="Укажите период сезонности (например, 7 для недельной, 30 для месячной). 0 = отключено"
)
st.info("""
💡 **Рекомендации для улучшения прогноза:**
1. **Если наивный прогноз лучше моделей:**
- Попробуйте добавить сезонность: выберите модели Holt-Winters и укажите период сезонности
- Используйте период из декомпозиции (Этап 1)
- Попробуйте diff_order=1 для устранения тренда
- Попробуйте преобразование Бокса-Кокса для стабилизации дисперсии
2. **Для данных с сезонностью:**
- Обязательно используйте модели Holt-Winters
- Период сезонности должен совпадать с периодом из декомпозиции
3. **Для данных с трендом:**
- Используйте diff_order=1
- Или выберите модели с трендом (Holt_add, Holt_mul)
4. **Начните с простого:** 'none' и diff_order=0, затем постепенно добавляйте сложность
""")
# Этап 6-7: Модели и стратегии
st.header("🔮 Этап 6-7: Модели экспоненциального сглаживания и стратегии прогнозирования")
st.markdown("""
**Что здесь происходит?**
Здесь мы строим модели для прогнозирования будущих значений временного ряда.
**Стратегии прогнозирования:**
- **recursive (рекурсивная):** Одна модель, которая использует свои предыдущие прогнозы
- **direct (прямая):** Отдельная модель для каждого шага вперёд
- **hybrid (гибридная):** Комбинация рекурсивной и прямой
**Модели:**
- **SES:** Простое экспоненциальное сглаживание (без тренда и сезонности)
- **Holt_add:** Модель Хольта с аддитивным трендом (линейный рост/падение, без сезонности)
- **Holt_mul:** Модель Хольта с мультипликативным трендом (экспоненциальный рост/падение, без сезонности)
- **Holt-Winters_add:** Модель Хольта-Винтерса с аддитивным трендом и сезонностью (рекомендуется для данных с сезонностью!)
- **Holt-Winters_mul:** Модель Хольта-Винтерса с мультипликативным трендом и сезонностью
""")
strategy_choice = st.multiselect(
"Выберите стратегии прогнозирования",
options=['recursive', 'direct', 'hybrid'],
default=['recursive'],
key='strategy_choice',
help="Можно выбрать несколько для сравнения"
)
model_types = st.multiselect(
"Выберите модели",
options=['SES', 'Holt_add', 'Holt_mul', 'Holt-Winters_add', 'Holt-Winters_mul'],
default=['Holt_add', 'Holt-Winters_add'],
key='model_types',
help="Можно выбрать несколько для сравнения. Holt-Winters учитывает сезонность."
)
# Настройка сезонности для моделей Holt-Winters
use_seasonal = any('Holt-Winters' in m for m in model_types)
seasonal_period = None
# Пытаемся автоматически определить период из декомпозиции
default_seasonal_period = 7
if 'saved_decomp_period' in st.session_state:
default_seasonal_period = st.session_state.get('saved_decomp_period', 7)
elif 'decomp' in st.session_state:
# Пытаемся извлечь период из декомпозиции
try:
decomp = st.session_state.get('decomp')
if hasattr(decomp, 'seasonal') and len(decomp.seasonal) > 0:
# Пытаемся определить период по длине сезонной компоненты
seasonal_len = len(decomp.seasonal.dropna())
# Округляем до ближайшего разумного значения
if 6 <= seasonal_len <= 8:
default_seasonal_period = 7
elif 28 <= seasonal_len <= 32:
default_seasonal_period = 30
elif 360 <= seasonal_len <= 370:
default_seasonal_period = 365
else:
default_seasonal_period = min(seasonal_len, 365)
except:
pass
if use_seasonal:
seasonal_period = st.number_input(
"Период сезонности для моделей Holt-Winters",
min_value=2,
max_value=365,
value=int(default_seasonal_period),
key='seasonal_period',
help=f"Используйте период из декомпозиции (например, 7 для недельной, 30 для месячной). Автоопределено: {default_seasonal_period}"
)
if 'saved_decomp_period' in st.session_state:
st.info(f"💡 Подсказка: В декомпозиции использовался период {st.session_state.get('saved_decomp_period')}. Рекомендуется использовать тот же период.")
# Настройка доверительных интервалов
use_conf_int = st.sidebar.checkbox("Показать доверительные интервалы", value=False, key='use_conf_int')
conf_alpha = st.sidebar.slider("Уровень значимости для доверительных интервалов", min_value=0.01, max_value=0.5, value=0.05, step=0.01, key='conf_alpha') if use_conf_int else None
if st.button("Применить преобразования и построить модели", key='btn_models'):
s_train = train_data.set_index('timestamp')[target_col].dropna()
s_test = test_data.set_index('timestamp')[target_col].dropna()
# Сохраняем исходные данные для обратного преобразования
s_train_original = s_train.copy()
s_test_original = s_test.copy()
# Применяем преобразования
try:
# Проверяем наличие неположительных значений для log и boxcox
has_nonpositive = (s_train <= 0).any()
negative_count = (s_train <= 0).sum() if has_nonpositive else 0
min_value = float(s_train.min())
max_value = float(s_train.max())
# АВТОМАТИЧЕСКИЙ СДВИГ - если есть неположительные значения и нужен log/boxcox
if (transform_type == 'log' or transform_type == 'boxcox') and has_nonpositive:
shift_value = abs(min_value) + 1 # Сдвигаем так, чтобы минимум стал 1
# Автоматически применяем сдвиг БЕЗ ВСЯКИХ КНОПОК
s_train = s_train + shift_value
s_test = s_test + shift_value
s_train_original = s_train_original + shift_value
s_test_original = s_test_original + shift_value
st.info(f"✅ Автоматически применен сдвиг: +{shift_value:.2f} (было {negative_count} нулевых значений)")
s_train_transformed, transform_info = apply_transformations(
s_train, transformation=transform_type, lambda_param=lambda_param,
diff_order=diff_order, seasonal_diff=seasonal_diff if seasonal_diff > 0 else None
)
st.info(f"Применено преобразование: {transform_info}")
# Проверка стационарности после преобразования
st.subheader("Проверка стационарности после преобразования")
adf_res = run_adf(s_train_transformed)
kpss_res = run_kpss(s_train_transformed)
col1, col2 = st.columns(2)
with col1:
st.write("**ADF:**", adf_res)
with col2:
st.write("**KPSS:**", kpss_res)
# Модели экспоненциального сглаживания
st.subheader("Результаты моделей")
all_results = []
all_forecasts = {}
all_forecasts_transformed = {} # Прогнозы в преобразованном пространстве
all_conf_intervals = {} # Доверительные интервалы
all_models = {}
# Функция-обёртка для стратегий
def create_ses_model(data):
return create_exponential_smoothing_model(data, trend=None, seasonal=None, optimized=True)
def create_holt_add_model(data):
return create_exponential_smoothing_model(data, trend='add', seasonal=None, optimized=True)
def create_holt_mul_model(data):
return create_exponential_smoothing_model(data, trend='mul', seasonal=None, optimized=True)
def create_hw_add_model(data):
return create_exponential_smoothing_model(
data,
trend='add',
seasonal='add',
seasonal_periods=seasonal_period if seasonal_period and seasonal_period > 1 else None,
optimized=True
)
def create_hw_mul_model(data):
return create_exponential_smoothing_model(
data,
trend='mul',
seasonal='mul',
seasonal_periods=seasonal_period if seasonal_period and seasonal_period > 1 else None,
optimized=True
)
for model_name in model_types:
if model_name == 'SES':
model_func = create_ses_model
model_display = 'SES'
elif model_name == 'Holt_add':
model_func = create_holt_add_model
model_display = 'Holt (additive)'
elif model_name == 'Holt_mul':
if not (s_train_transformed > 0).all():
st.warning("Holt multiplicative требует положительные значения - пропущено")
continue
model_func = create_holt_mul_model
model_display = 'Holt (multiplicative)'
elif model_name == 'Holt-Winters_add':
if seasonal_period is None or seasonal_period < 2:
st.warning(f"Holt-Winters требует период сезонности >= 2. Пропущено {model_name}")
continue
if len(s_train_transformed) < seasonal_period * 2:
st.warning(f"Holt-Winters требует минимум {seasonal_period * 2} наблюдений. У вас {len(s_train_transformed)}. Пропущено {model_name}")
continue
model_func = create_hw_add_model
model_display = f'Holt-Winters (additive, period={seasonal_period})'
elif model_name == 'Holt-Winters_mul':
if not (s_train_transformed > 0).all():
st.warning("Holt-Winters multiplicative требует положительные значения - пропущено")
continue
if seasonal_period is None or seasonal_period < 2:
st.warning(f"Holt-Winters требует период сезонности >= 2. Пропущено {model_name}")
continue
if len(s_train_transformed) < seasonal_period * 2:
st.warning(f"Holt-Winters требует минимум {seasonal_period * 2} наблюдений. У вас {len(s_train_transformed)}. Пропущено {model_name}")
continue
model_func = create_hw_mul_model
model_display = f'Holt-Winters (multiplicative, period={seasonal_period})'
else:
continue
for strategy in strategy_choice:
try:
alpha_param = conf_alpha if use_conf_int else None
if strategy == 'recursive':
forecast_result = recursive_forecast(model_func, s_train_transformed, horizon=min(horizon, len(s_test)), alpha=alpha_param)
elif strategy == 'direct':
forecast_result = direct_forecast(model_func, s_train_transformed, horizon=min(horizon, len(s_test)), alpha=alpha_param)
elif strategy == 'hybrid':
forecast_result = hybrid_forecast(model_func, s_train_transformed, horizon=min(horizon, len(s_test)), alpha=alpha_param)
else:
continue
# Извлекаем прогноз и доверительные интервалы
# Функции возвращают либо (forecast, None), либо (forecast, (lower, upper))
if isinstance(forecast_result, tuple) and len(forecast_result) == 2:
forecast_transformed, conf_int = forecast_result
# Если conf_int это кортеж из двух массивов, оставляем как есть
# Если это None, оставляем None
else:
# Если функция вернула просто массив (старый формат)
forecast_transformed = forecast_result
conf_int = None
# Применяем обратное преобразование к прогнозу
if transform_info.get('transformation') != 'none' or diff_order > 0 or (seasonal_diff and seasonal_diff > 0):
# Для обратного преобразования нужны последние значения преобразованного ряда
# Промежуточные значения уже сохранены в transform_info
last_train_vals_transformed = s_train_transformed.values
forecast = inverse_transformations(
forecast_transformed,
last_train_vals_transformed,
transform_info
)
# Применяем обратное преобразование к доверительным интервалам, если они есть
if conf_int is not None:
conf_lower_transformed = conf_int[0]
conf_upper_transformed = conf_int[1]
conf_lower = inverse_transformations(
conf_lower_transformed,
last_train_vals_transformed,
transform_info
)
conf_upper = inverse_transformations(
conf_upper_transformed,
last_train_vals_transformed,
transform_info
)
conf_int = (conf_lower, conf_upper)
else:
forecast = forecast_transformed
# Оцениваем метрики в исходных единицах
test_values = s_test_original.values[:len(forecast)]
metrics = evaluate_forecast(test_values, forecast)
metrics['model'] = model_display
metrics['strategy'] = strategy
all_results.append(metrics)
all_forecasts[f"{model_display}_{strategy}"] = forecast
all_forecasts_transformed[f"{model_display}_{strategy}"] = forecast_transformed
if conf_int is not None:
all_conf_intervals[f"{model_display}_{strategy}"] = conf_int
# Сохраняем модель для диагностики
fitted_model = model_func(s_train_transformed)
all_models[f"{model_display}_{strategy}"] = fitted_model
except Exception as e:
st.warning(f"{model_display} ({strategy}): {e}")
import traceback
st.code(traceback.format_exc())
# Наивный прогноз (в исходных единицах)
naive_pred = naive_forecast(s_train_original, min(horizon, len(s_test)))
naive_metrics = evaluate_forecast(s_test_original.values[:len(naive_pred)], naive_pred)
naive_metrics['model'] = 'Naive'
naive_metrics['strategy'] = 'naive'
all_results.append(naive_metrics)
all_forecasts['Naive'] = naive_pred
# Сравнение моделей
if all_results:
st.subheader("Сравнение моделей и стратегий")
results_df = pd.DataFrame(all_results)
results_pivot = results_df.pivot_table(
values=['MAE', 'RMSE', 'MAPE'],
index='model',
columns='strategy',
aggfunc='first'
)
st.dataframe(results_pivot)
# Визуализация прогнозов - показываем весь ряд с прогнозами
fig = go.Figure()
# 1. Обучающая выборка (исторические данные)
fig.add_trace(go.Scatter(
x=s_train_original.index,
y=s_train_original.values,
name='Исторические данные (train)',
line=dict(color='#1f77b4', width=2),
mode='lines'
))
# 2. Тестовая выборка (фактические значения)
test_len = min(horizon, len(s_test_original))
fig.add_trace(go.Scatter(
x=s_test_original.index[:test_len],
y=s_test_original.values[:test_len],
name='Фактические значения (test)',
line=dict(color='black', width=3),
mode='lines'
))
# 3. Прогнозы от каждой модели
colors = px.colors.qualitative.Set3
color_idx = 0
for name, forecast in all_forecasts.items():
if name == 'Naive':
continue # Наивный прогноз обработаем отдельно
color = colors[color_idx % len(colors)]
# Создаём индексы для прогноза (продолжение после обучающей выборки)
# Создаём индексы для прогноза (продолжение после обучающей выборки)
if len(forecast) > 0:
try:
# Правильное создание временных индексов для прогнозов
if hasattr(s_train_original.index, 'freq') and s_train_original.index.freq is not None:
freq = s_train_original.index.freq
else:
freq = pd.infer_freq(s_train_original.index) or 'D'
last_date = s_train_original.index[-1]
forecast_dates = pd.date_range(
start=last_date + pd.Timedelta(days=1),
periods=len(forecast),
freq=freq
)
except Exception as e:
# Fallback: используем индексы тестовой выборки
try:
forecast_dates = s_test_original.index[:len(forecast)]
except:
# Последний вариант - простой числовой индекс
forecast_dates = range(len(s_train_original), len(s_train_original) + len(forecast))
# Добавляем доверительные интервалы, если они есть
if name in all_conf_intervals:
conf_lower, conf_upper = all_conf_intervals[name]
# Преобразуем hex цвет в RGB для rgba
try:
if color.startswith('#'):
r = int(color[1:3], 16)
g = int(color[3:5], 16)
b = int(color[5:7], 16)
else:
r, g, b = 100, 100, 100
fillcolor = f'rgba({r}, {g}, {b}, 0.15)'
except:
fillcolor = 'rgba(100, 100, 100, 0.15)'
# Верхняя граница доверительного интервала
fig.add_trace(go.Scatter(
x=forecast_dates,
y=conf_upper,
mode='lines',
line=dict(width=0),
showlegend=False,
hoverinfo='skip',
name=f'{name} CI upper'
))
# Нижняя граница с заливкой
fig.add_trace(go.Scatter(
x=forecast_dates,
y=conf_lower,
mode='lines',
line=dict(width=0),
fill='tonexty',
fillcolor=fillcolor,
name=f'{name} (доверительный интервал)',
showlegend=True,
legendgroup=name
))
# Линия прогноза
fig.add_trace(go.Scatter(
x=forecast_dates,
y=forecast,
name=f'{name} (прогноз)',
line=dict(dash='dash', color=color, width=2.5),
mode='lines',
legendgroup=name
))
color_idx += 1
# Наивный прогноз (если есть)
if 'Naive' in all_forecasts:
naive_forecast_vals = all_forecasts['Naive']
try:
# Правильное создание временных индексов для прогнозов
if hasattr(s_train_original.index, 'freq') and s_train_original.index.freq is not None:
freq = s_train_original.index.freq
else:
freq = pd.infer_freq(s_train_original.index) or 'D'
last_date = s_train_original.index[-1]
naive_dates = pd.date_range(
start=last_date + pd.Timedelta(days=1),
periods=len(naive_forecast_vals),
freq=freq
)
except Exception as e:
try:
naive_dates = s_test_original.index[:len(naive_forecast_vals)]
except:
naive_dates = range(len(s_train_original), len(s_train_original) + len(naive_forecast_vals))
# Вертикальная линия, разделяющая train и test
if len(s_train_original) > 0:
split_date = s_train_original.index[-1]
# Преобразуем Timestamp в строку для plotly
if isinstance(split_date, pd.Timestamp):
split_date_str = split_date.strftime('%Y-%m-%d %H:%M:%S')
else:
split_date_str = str(split_date)
# Используем add_shape вместо add_vline для лучшей совместимости
fig.add_shape(
type="line",
x0=split_date,
x1=split_date,
y0=0,
y1=1,
yref="paper",
line=dict(dash="dot", color="red", width=1)
)
# Добавляем аннотацию отдельно
fig.add_annotation(
x=split_date,
y=1,
yref="paper",
text="Разделение train/test",
showarrow=False,
xanchor="center",
yanchor="bottom",
bgcolor="rgba(255,255,255,0.8)",
bordercolor="red",
borderwidth=1
)
fig.update_layout(
title=f'Прогнозирование временного ряда (horizon={horizon})',
height=600,
xaxis_title='Дата',
yaxis_title='Значение',
hovermode='x unified',
legend=dict(
orientation="v",
yanchor="top",
y=1,
xanchor="left",
x=1.02
),
template='plotly_white'
)
st.plotly_chart(fig, use_container_width=True)
# Экспорт прогнозов
st.subheader("Экспорт прогнозов")
export_model = st.selectbox("Выберите модель для экспорта", options=list(all_forecasts.keys()), key='export_model')
if export_model in all_forecasts:
forecast_export = all_forecasts[export_model]
# Создаём DataFrame с прогнозами
forecast_dates = pd.date_range(
start=s_test_original.index[0],
periods=len(forecast_export),
freq=pd.infer_freq(s_test_original.index) or 'D'
)
forecast_df = pd.DataFrame({
'date': forecast_dates,
'forecast': forecast_export,
'actual': s_test_original.values[:len(forecast_export)] if len(s_test_original) >= len(forecast_export) else None
})
forecast_csv = forecast_df.to_csv(index=False).encode('utf-8')
st.download_button(
f"Скачать прогноз ({export_model})",
data=forecast_csv,
file_name=f'forecast_{export_model.replace(" ", "_")}.csv',
mime='text/csv'
)
# Экспорт параметров модели
if export_model in all_models:
model = all_models[export_model]
params_dict = {
'model': export_model,
'horizon': horizon,
'transformation': transform_info.get('transformation', 'none'),
'lambda': transform_info.get('lambda', None),
'diff_order': transform_info.get('diff_order', 0),
'seasonal_diff': transform_info.get('seasonal_diff', None),
}
# Добавляем параметры модели, если доступны
if hasattr(model, 'params'):
params_dict['model_params'] = str(model.params)
if hasattr(model, 'aic'):
params_dict['aic'] = model.aic
if hasattr(model, 'bic'):
params_dict['bic'] = model.bic
params_df = pd.DataFrame([params_dict])
params_csv = params_df.to_csv(index=False).encode('utf-8')
st.download_button(
f"Скачать параметры модели ({export_model})",
data=params_csv,
file_name=f'model_params_{export_model.replace(" ", "_")}.csv',
mime='text/csv'
)
# Сохраняем результаты в session
st.session_state['forecast_results'] = all_results
st.session_state['forecasts'] = all_forecasts
st.session_state['forecasts_transformed'] = all_forecasts_transformed
st.session_state['models'] = all_models
st.session_state['s_train_transformed'] = s_train_transformed
st.session_state['s_train_original'] = s_train_original
st.session_state['s_test'] = s_test_original
st.session_state['transform_info'] = transform_info
except Exception as e:
st.error(f"❌ Ошибка при построении моделей: {e}")
st.info("""
**Возможные причины ошибки:**
1. Недостаточно данных для обучения модели
2. Проблемы с преобразованиями (например, отрицательные значения при логарифме)
3. Несовместимость параметров модели с данными
**Что попробовать:**
- Уменьшите размер обучающей выборки
- Измените параметры преобразований (попробуйте 'none' и diff_order=0)
- Попробуйте другую модель (например, только SES)
""")
import traceback
with st.expander("🔍 Детали ошибки (для отладки)"):
st.code(traceback.format_exc())
# Этап 7: Диагностика остатков
st.header("Этап 7: Диагностика адекватности моделей")
if 'models' in st.session_state and st.session_state['models']:
model_for_diagnosis = st.selectbox(
"Выберите модель для диагностики",
options=list(st.session_state['models'].keys()),
key='model_diagnosis'
)
if st.button("Выполнить диагностику остатков", key='btn_diagnosis'):
try:
model = st.session_state['models'][model_for_diagnosis]
s_train_transformed = st.session_state['s_train_transformed']
# Получаем остатки
fitted_values = model.fittedvalues
residuals = s_train_transformed - fitted_values
residuals = residuals.dropna()
if len(residuals) > 3:
# Диагностика
diagnosis = diagnose_model_residuals(residuals.values, lags=min(20, len(residuals)//4))
st.subheader("Результаты диагностики остатков")
# Тест Льюнга-Бокса
if 'ljung_box' in diagnosis:
lb = diagnosis['ljung_box']
if 'pvalue' in lb:
st.write(f"**Тест Льюнга-Бокса:**")
st.write(f"- Статистика: {lb.get('statistic', 'N/A'):.4f}")
st.write(f"- p-value: {lb.get('pvalue', 'N/A'):.4f}")
if lb.get('pvalue', 1) < 0.05:
st.warning("Остатки имеют автокорреляцию (p < 0.05)")
else:
st.success("Остатки не имеют значимой автокорреляции (p >= 0.05)")
# Тест нормальности
if 'shapiro_wilk' in diagnosis:
sw = diagnosis['shapiro_wilk']
st.write(f"**Тест Шапиро-Уилка (нормальность):**")
st.write(f"- Статистика: {sw.get('statistic', 'N/A'):.4f}")
st.write(f"- p-value: {sw.get('pvalue', 'N/A'):.4f}")
if sw.get('pvalue', 0) < 0.05:
st.warning("Остатки не распределены нормально (p < 0.05)")
else:
st.success("Остатки распределены нормально (p >= 0.05)")
elif 'normality_test' in diagnosis:
nt = diagnosis['normality_test']
st.write(f"**Тест нормальности ({nt.get('test', 'N/A')}):**")
st.write(f"- Статистика: {nt.get('statistic', 'N/A'):.4f}")
st.write(f"- p-value: {nt.get('pvalue', 'N/A'):.4f}")
# Стационарность остатков
if 'stationarity' in diagnosis:
st.write(f"**Стационарность остатков:**")
stn = diagnosis['stationarity']
if 'adf' in stn:
st.write(f"- ADF p-value: {stn['adf'].get('pvalue', 'N/A'):.4f}")
if 'kpss' in stn:
st.write(f"- KPSS p-value: {stn['kpss'].get('pvalue', 'N/A'):.4f}")
# Статистики остатков
if 'residual_stats' in diagnosis:
rs = diagnosis['residual_stats']
st.write(f"**Статистики остатков:**")
st.write(f"- Среднее: {rs.get('mean', 'N/A'):.6f}")
st.write(f"- Стд. отклонение: {rs.get('std', 'N/A'):.6f}")
st.write(f"- Min: {rs.get('min', 'N/A'):.4f}, Max: {rs.get('max', 'N/A'):.4f}")
# Визуализация остатков
st.subheader("Визуализация остатков")
col1, col2 = st.columns(2)
with col1:
# График остатков vs прогнозов (гомоскедастичность)
fig_resid = go.Figure()
fig_resid.add_trace(go.Scatter(
x=fitted_values.values,
y=residuals.values,
mode='markers',
name='Остатки'
))
fig_resid.add_hline(y=0, line_dash="dash", line_color="red")
fig_resid.update_layout(
title='Остатки vs Прогнозы (гомоскедастичность)',
xaxis_title='Прогноз',
yaxis_title='Остаток'
)
st.plotly_chart(fig_resid, use_container_width=True)
# Гистограмма остатков
fig_hist = px.histogram(
x=residuals.values,
nbins=30,
title='Распределение остатков'
)
st.plotly_chart(fig_hist, use_container_width=True)
with col2:
# Q-Q plot
qq_data = scipy_stats.probplot(residuals.values, dist="norm")
fig_qq = go.Figure()
fig_qq.add_trace(go.Scatter(
x=qq_data[0][0],
y=qq_data[0][1],
mode='markers',
name='Остатки'
))
fig_qq.add_trace(go.Scatter(
x=qq_data[0][0],
y=qq_data[1][1] + qq_data[1][0] * qq_data[0][0],
mode='lines',
name='Теоретическая линия',
line=dict(color='red', dash='dash')
))
fig_qq.update_layout(
title='Q-Q Plot (нормальность)',
xaxis_title='Теоретические квантили',
yaxis_title='Выборочные квантили'
)
st.plotly_chart(fig_qq, use_container_width=True)
# Временной ряд остатков
fig_time = px.line(
x=residuals.index,
y=residuals.values,
title='Временной ряд остатков'
)
fig_time.add_hline(y=0, line_dash="dash", line_color="red")
st.plotly_chart(fig_time, use_container_width=True)
except Exception as e:
st.error(f"Ошибка диагностики: {e}")
import traceback
st.code(traceback.format_exc())
else:
st.info("Сначала постройте модели, чтобы выполнить диагностику остатков")
# Этап 8: Выводы и рекомендации
st.header("📈 Этап 8: Сравнительный анализ и выводы")
st.markdown("""
**Что здесь происходит?**
В этом разделе вы видите итоговые результаты всех построенных моделей и можете сравнить их качество.
**Что означают метрики:**
- **MAE (Mean Absolute Error):** Средняя абсолютная ошибка. Чем меньше, тем лучше.
- **RMSE (Root Mean Squared Error):** Корень из средней квадратичной ошибки. Чем меньше, тем лучше. Более чувствительна к большим ошибкам.
- **MAPE (Mean Absolute Percentage Error):** Средняя абсолютная процентная ошибка. Показывает ошибку в процентах. Чем меньше, тем лучше.
**Что нужно сделать:**
1. Посмотрите на таблицу метрик - какая модель и стратегия показали лучшие результаты?
2. Обратите внимание на рекомендации ниже
3. Используйте эту информацию для выбора лучшей модели для ваших данных
""")
if 'forecast_results' in st.session_state:
st.subheader("📊 Итоговая таблица метрик")
final_df = pd.DataFrame(st.session_state['forecast_results'])
st.dataframe(final_df.set_index(['model', 'strategy']))
# Лучшая модель по каждой метрике
st.subheader("🏆 Лучшие модели по метрикам")
for metric in ['MAE', 'RMSE', 'MAPE']:
if metric in final_df.columns:
best_idx = final_df[metric].idxmin()
best = final_df.loc[best_idx]
st.write(f"**{metric}:** {best['model']} ({best['strategy']}) = {best[metric]:.4f}")
st.subheader("💡 Рекомендации")
st.info("""
**Общие рекомендации:**
- **Короткий горизонт (h < 7):** Рекурсивная стратегия обычно работает лучше
- **Длинный горизонт (h >= 30):** Прямая или гибридная стратегия могут быть предпочтительнее
- **Преобразование Бокса-Кокса:** Используйте, если дисперсия нестабильна
- **Дифференцирование:** Применяйте, если ряд нестационарен (попробуйте diff_order=1)
- **Диагностика остатков:** Убедитесь, что остатки не имеют автокорреляции и распределены нормально
**Как выбрать модель:**
1. Посмотрите, какая модель имеет наименьшие MAE, RMSE и MAPE
2. Проверьте диагностику остатков для этой модели (Этап 7)
3. Если остатки имеют проблемы, попробуйте другую модель или добавьте преобразования
""")
else:
st.warning("⚠️ Сначала постройте модели в разделе 'Этап 6-7', чтобы увидеть результаты сравнения.")
import streamlit.components.v1 as components
import pandas as pd
from pathlib import Path
# -----------------------------
# Streamlit: inline pipeline visualizer
# -----------------------------
# inverse helpers (вставь в display_pipeline_inline или в lab3_pipeline)
import numpy as _np
def inverse_boxcox_arr(arr, lmbda):
arr = _np.array(arr, dtype=float)
if lmbda is None:
return arr
if abs(lmbda) < 1e-8:
return _np.exp(arr)
else:
return _np.power(lmbda * arr + 1.0, 1.0 / lmbda)
def inverse_transform_preds(preds, meta, orig_series):
"""
Правильное инвертирование преобразований для прогнозов
"""
preds = np.array(preds, dtype=float)
# Если нет преобразований, возвращаем как есть
if meta.get('method') == 'none' and meta.get('diff_order', 0) == 0:
return preds
# Получаем информацию о преобразованиях
method = meta.get('method', 'none')
lam = meta.get('lambda', None)
diff_order = meta.get('diff_order', 0)
# Шаг 1: Обратное дифференцирование (если было)
if diff_order > 0:
# Для обратного дифференцирования нужны последние значения исходного ряда
orig_values = orig_series.dropna().values
if len(orig_values) < diff_order:
# Недостаточно данных для обратного дифференцирования
return preds
# Простое обратное дифференцирование первого порядка
if diff_order == 1:
last_value = float(orig_values[-1])
reconstructed = []
for i, diff_val in enumerate(preds):
if i == 0:
reconstructed.append(last_value + diff_val)
else:
reconstructed.append(reconstructed[-1] + diff_val)
preds = np.array(reconstructed)
# Шаг 2: Обратное преобразование Бокса-Кокса или логарифма
if method.startswith('boxcox'):
if lam is None:
lam = 0.0
# Обратное преобразование Бокса-Кокса
if abs(lam) < 1e-8:
preds = np.exp(preds)
else:
preds = np.power(lam * preds + 1, 1.0 / lam)
elif method == 'log':
preds = np.exp(preds)
return preds
def walk_forward_one_step_preds(model_label, model_obj, train_series, test_series, extra=None):
"""
Вернёт numpy array длины len(test_series) с one-step прогнозами.
"""
import numpy as _np
preds = []
history = train_series.copy() # pd.Series
for i in range(len(test_series)):
try:
if model_label == 'sarimax':
# Для SARIMAX используем предобученную модель и делаем только прогноз
# Не переобучаем модель на каждом шаге
if i == 0:
# Только на первом шаге используем обученную модель
order = model_obj.get('order', (1, 1, 1)) if isinstance(model_obj, dict) else (1, 1, 1)
seasonal_order = model_obj.get('seasonal_order', (0, 0, 0, 0)) if isinstance(model_obj, dict) else (
0, 0, 0, 0)
if len(history.dropna()) < 10:
pred_value = float(history.dropna().iloc[-1]) if len(history.dropna()) > 0 else 0.0
preds.append(pred_value)
history = pd.concat([history, pd.Series([test_series.iloc[i]], index=[test_series.index[i]])])
continue
# Обучаем модель один раз на обучающих данных
m = SARIMAX(history.dropna(), order=order, seasonal_order=seasonal_order,
enforce_stationarity=False, enforce_invertibility=False)
fitted_model = m.fit(disp=False, maxiter=50)
# Делаем прогноз на один шаг вперед
forecast_result = fitted_model.get_forecast(steps=1)
pred_value = float(forecast_result.predicted_mean.iloc[0])
# Проверяем на NaN/Inf
if np.isnan(pred_value) or np.isinf(pred_value):
pred_value = float(history.dropna().iloc[-1])
preds.append(pred_value)
# НЕ добавляем тестовые данные в историю для переобучения
# Это предотвращает "подглядывание" в будущее
# history остается неизменной после начального обучения
elif model_label == 'auto_arima':
try:
# Для auto_arima также обучаем один раз
if i == 0:
import pmdarima as pm_local
if len(history.dropna()) < 10:
pred_value = float(history.dropna().iloc[-1])
else:
auto_model = pm_local.auto_arima(history.dropna(), seasonal=False, error_action='ignore',
suppress_warnings=True, maxiter=50)
if i == 0 or not hasattr(auto_model, 'update'):
p = auto_model.predict(n_periods=1)
else:
# Обновляем модель с новыми данными (если поддерживается)
auto_model.update(pd.Series([test_series.iloc[i - 1]]))
p = auto_model.predict(n_periods=1)
pred_value = float(p[0]) if hasattr(p, '__iter__') else float(p)
# Проверяем на NaN/Inf
if np.isnan(pred_value) or np.isinf(pred_value):
pred_value = float(history.dropna().iloc[-1])
preds.append(pred_value)
except Exception as e:
pred_value = float(history.dropna().iloc[-1]) if len(history.dropna()) > 0 else 0.0
preds.append(pred_value)
elif model_label == 'SES':
from statsmodels.tsa.holtwinters import SimpleExpSmoothing
try:
# Для SES также обучаем один раз
if i == 0:
if len(history.dropna()) < 2:
ses_model = None
else:
ses_model = SimpleExpSmoothing(history.dropna()).fit(optimized=True)
if ses_model is None:
pred_value = float(history.dropna().iloc[-1])
else:
p = ses_model.forecast(1)
pred_value = float(p.iloc[0]) if hasattr(p, 'iloc') else float(p[0])
# Проверяем на NaN/Inf
if np.isnan(pred_value) or np.isinf(pred_value):
pred_value = float(history.dropna().iloc[-1])
preds.append(pred_value)
except Exception as e:
pred_value = float(history.dropna().iloc[-1]) if len(history.dropna()) > 0 else 0.0
preds.append(pred_value)
else:
# naive: forecast last observed
pred_value = float(history.dropna().iloc[-1]) if len(history.dropna()) > 0 else 0.0
preds.append(pred_value)
except Exception as e:
# fallback: append last value
try:
pred_value = float(history.dropna().iloc[-1]) if len(history.dropna()) > 0 else 0.0
except:
pred_value = 0.0
preds.append(pred_value)
return _np.array(preds, dtype=float)
def display_pipeline_inline(data_path: str, timestamp_col: str, target_col: str, freq: str = 'D'):
"""
Загружает данные через lab3_pipeline helpers и отображает ключевые этапы
"""
import importlib
try:
import lab3_pipeline as lp
importlib.reload(lp)
# Определяем PM_AVAILABLE из pipeline
PM_AVAILABLE = lp.PM_AVAILABLE
STATSMODELS_AVAILABLE = lp.STATSMODELS_AVAILABLE
except Exception as e:
st.error("Не удалось импортировать lab3_pipeline: " + str(e))
return
st.info("Загружаю данные...")
try:
df = lp.load_data(data_path, timestamp_col)
except Exception as e:
st.error("Ошибка загрузки данных: " + str(e))
return
# постараемся получить series
if target_col not in df.columns:
numeric_cols = df.select_dtypes(include=[np.number]).columns.tolist()
if not numeric_cols:
st.error("Не найдено числовых колонок для target.")
return
target_col = numeric_cols[0]
st.warning(f"Target не найден — использую {target_col}.")
series = df[target_col].astype(float).copy()
# Проверяем данные на наличие NaN и Inf
if series.isna().all():
st.error("Все значения в целевом ряду NaN. Проверьте данные.")
return
if (series == 0).all():
st.warning("Все значения в целевом ряду равны 0. Это может вызвать проблемы с моделями.")
# Заполняем пропуски простым методом
series = series.fillna(method='ffill').fillna(method='bfill').fillna(0)
st.write("Исходный ряд — первые и последние точки:")
st.write(pd.concat([series.head(5), series.tail(5)]).to_frame(target_col))
# ресемплим/интерполируем
try:
series_rs = lp.resample_and_interpolate(series.to_frame(), freq=freq).iloc[:, 0]
# Снова проверяем и заполняем пропуски после ресемплинга
series_rs = series_rs.fillna(method='ffill').fillna(method='bfill').fillna(0)
except Exception as e:
st.warning("Resample/interpolate failed, using original: " + str(e))
series_rs = series
st.subheader("1) Временной ряд")
fig, ax = plt.subplots(figsize=(10,3))
ax.plot(series_rs.index, series_rs.values, label='series')
ax.set_title("Временной ряд (после ресемплинга)")
ax.legend()
st.pyplot(fig)
# 3.1: transformations selection
st.subheader("2) Подбор преобразований (log / Box-Cox / differencing)")
try:
transformed, meta = lp.try_transformations_and_choose(series_rs, seasonal_period=7)
st.write("Выбранное преобразование:", meta)
except Exception as e:
st.warning("Не удалось выполнить подбор преобразований: " + str(e))
transformed, meta = series_rs, {'method': 'none'}
# Покажем original vs transformed
fig, ax = plt.subplots(1,1,figsize=(10,3))
ax.plot(series_rs.index[-200:], series_rs.values[-200:], label='original')
ax.plot(transformed.index[-200:], transformed.values[-200:], label='transformed')
ax.set_title("Original vs Transformed (последние 200 точек)")
ax.legend()
st.pyplot(fig)
# stationarity tests
st.subheader("3) Тесты на стационарность (ADF, KPSS)")
try:
tests_orig = lp.test_stationarity_pair(series_rs.fillna(method='ffill'))
tests_trans = lp.test_stationarity_pair(transformed.fillna(method='ffill'))
st.write("Оригинал:", tests_orig)
st.write("Преобразованный ряд:", tests_trans)
except Exception as e:
st.warning("Тесты стационарности не выполнены: " + str(e))
# decomposition
st.subheader("4) Декомпозиция ряда (additive)")
try:
if lp.STATSMODELS_AVAILABLE:
dec = lp.seasonal_decompose(transformed.dropna(), model='additive', period=7)
fig = dec.plot()
fig.set_size_inches(10,6)
st.pyplot(fig)
else:
st.info("statsmodels не доступен: декомпозиция пропущена.")
except Exception as e:
st.warning("Ошибка декомпозиции: " + str(e))
# ACF/PACF
st.subheader("5) ACF / PACF")
try:
if lp.STATSMODELS_AVAILABLE:
from statsmodels.graphics.tsaplots import plot_acf, plot_pacf
ser_ac = transformed.dropna()
if len(ser_ac) < 3:
st.info("Недостаточно точек для ACF/PACF (нужно >=3).")
else:
# ACF
fig_acf, ax_acf = plt.subplots(figsize=(10, 3))
plot_acf(ser_ac, lags=40, ax=ax_acf, zero=False)
ax_acf.set_title("ACF")
st.pyplot(fig_acf)
plt.close(fig_acf)
# PACF
fig_pacf, ax_pacf = plt.subplots(figsize=(10, 3))
# метод pacf можно изменить в зависимости от версии statsmodels ('ywm', 'ld', 'ols', ...)
try:
plot_pacf(ser_ac, lags=40, ax=ax_pacf, zero=False, method='ywm')
except Exception:
plot_pacf(ser_ac, lags=40, ax=ax_pacf, zero=False)
ax_pacf.set_title("PACF")
st.pyplot(fig_pacf)
plt.close(fig_pacf)
else:
st.info("statsmodels не доступен — ACF/PACF пропущены.")
except Exception as e:
st.warning("ACF/PACF error: " + str(e))
# 3.2 feature engineering demonstration
st.subheader("6) Feature engineering (лаги, скользящие, временные признаки)")
try:
df_all = transformed.to_frame(name=target_col)
df_all = lp.make_time_features(df_all)
df_all = lp.make_lags(df_all, target_col, [1,2,7,30])
df_all = lp.make_rolls(df_all, target_col, [7,30])
st.write("Превью признаков:")
st.dataframe(df_all.dropna().head(10))
except Exception as e:
st.warning("Ошибка при генерации признаков: " + str(e))
# split
st.subheader("7) Разбиение train/val/test")
try:
df_all = df_all.dropna()
train, val, test = lp.chronological_split(df_all, frac_train=0.7, frac_val=0)
st.write("Размеры:", {'train': len(train), 'val': len(val), 'test': len(test)})
except Exception as e:
st.warning("Ошибка разбиения: " + str(e))
return
# 3.3-3.5 Модели: несколько базовых (быстро и наглядно)
st.subheader("8) Быстрое обучение моделей и прогнозы (h=1,7,30)")
horizons = [1,7,30]
results = []
# benchmarks
for h in horizons:
try:
results.append({'model':'naive','h':h,'pred': lp.naive_forecast(train[target_col], h)})
if len(train[target_col])>=7:
results.append({'model':'seasonal_naive','h':h, 'pred': lp.seasonal_naive_forecast(train[target_col], season=7, steps=h)})
except Exception:
pass
# SES (если доступен)
try:
from statsmodels.tsa.holtwinters import SimpleExpSmoothing
ses = SimpleExpSmoothing(train[target_col].dropna()).fit(optimized=True)
for h in horizons:
results.append({'model':'SES','h':h,'pred': np.asarray(ses.forecast(h))})
except Exception:
pass
# SARIMAX baseline
try:
sar = lp.fit_sarimax_simple(train[target_col], order=(1,1,1))
for h in horizons:
p, ci = lp.forecast_sarimax(sar, steps=h)
results.append({'model':'SARIMAX(1,1,1)','h':h,'pred': np.asarray(p), 'ci': ci})
except Exception as e:
st.warning("SARIMAX skipped: " + str(e))
# pmdarima auto_arima
try:
if PM_AVAILABLE: # Теперь эта переменная определена
auto = lp.fit_auto_arima(train[target_col], seasonal=False)
for h in horizons:
results.append({'model':'auto_arima','h':h,'pred': np.asarray(auto.predict(n_periods=h))})
except Exception:
pass
# VAR if multivariate
try:
if lp.STATSMODELS_AVAILABLE and df.select_dtypes(include=[np.number]).shape[1] >= 2:
num_df = df.select_dtypes(include=[np.number]).dropna()
var_res = lp.fit_var(num_df.iloc[:int(len(num_df)*0.7)], maxlags=5)
fut = lp.forecast_var(var_res, steps=30)
results.append({'model':'VAR','h':30,'pred': np.asarray(fut.iloc[:,0])})
except Exception:
pass
# Evaluate models on test
st.subheader("9) Оценка моделей на тесте")
eval_rows = []
for rec in results:
pred = np.asarray(rec['pred']).ravel()
y_true = test[target_col].values[:len(pred)]
if len(y_true)==0:
continue
row = {
'model': rec['model'],
'h': rec['h'],
'MAE': float(lp.mae(y_true, pred)),
'RMSE': float(lp.rmse(y_true, pred)),
'MAPE': float(lp.mape(y_true, pred))
}
eval_rows.append(row)
if eval_rows:
eval_df = pd.DataFrame(eval_rows)
st.dataframe(eval_df.sort_values(['h','RMSE']))
else:
st.info("Нет результатов для оценки.")
# Получим walk-forward прогнозы для каждого обученного метода (если модель была навчена)
# Получим walk-forward прогнозы для каждого обученного метода
st.subheader("10) Полная визуализация прогнозов по тесту (walk-forward one-step)")
# выберем модели, которые были обучены
for rec in results:
model_name = rec['model']
# только те, для которых хотим full series
if model_name not in ('SARIMAX(1,1,1)', 'auto_arima', 'SES', 'naive', 'seasonal_naive'):
continue
st.write("Обрабатываем модель:", model_name)
# Подготавливаем параметры модели для walk_forward
model_args = {}
if model_name.startswith('SARIMAX'):
model_args = {'order': (1, 1, 1), 'seasonal_order': (0, 0, 0, 0)}
model_type = 'sarimax'
elif model_name == 'auto_arima' and PM_AVAILABLE:
model_type = 'auto_arima'
elif model_name == 'SES':
model_type = 'SES'
elif model_name == 'naive':
model_type = 'naive'
elif model_name == 'seasonal_naive':
model_type = 'naive' # используем naive как fallback
else:
continue
# вызов walk_forward для этой модели
try:
full_preds = walk_forward_one_step_preds(model_type, model_args, train[target_col], test[target_col])
# Отладочная информация
st.write(f"Длина прогнозов: {len(full_preds)}")
st.write(f"Количество NaN в прогнозах: {np.isnan(full_preds).sum()}")
if len(full_preds) > 0 and not np.all(np.isnan(full_preds)):
st.write("Пример первых 5 raw preds:", full_preds[:5].tolist())
# инвертируем трансформации, если они были
try:
preds_inv = inverse_transform_preds(full_preds, meta, series_rs)
st.write("Пример первых 5 инвертированных preds:", preds_inv[:5].tolist())
except Exception as e:
st.warning(f"inverse_transform failed: {e}")
preds_inv = full_preds
# отрисуем
fig, ax = plt.subplots(figsize=(12, 4))
# history (последние 100 точек для наглядности)
hist_points = min(100, len(train))
ax.plot(train.index[-hist_points:], train[target_col].values[-hist_points:],
label='train (tail)', linewidth=2)
# test
test_to_show = min(len(test), len(full_preds))
ax.plot(test.index[:test_to_show], test[target_col].values[:test_to_show],
label='test', linewidth=2, alpha=0.7)
# preds
try:
ax.plot(test.index[:len(preds_inv)], preds_inv,
label=f'pred_{model_name}', linewidth=2, linestyle='--')
except Exception as e:
ax.plot(range(len(preds_inv)), preds_inv,
label=f'pred_{model_name}', linewidth=2, linestyle='--')
ax.set_title(f'Walk-forward one-step forecasts — {model_name}')
ax.legend()
ax.grid(True, alpha=0.3)
st.pyplot(fig)
plt.close(fig)
# Вычислим метрики качества
if len(preds_inv) > 0 and len(test) >= len(preds_inv):
y_true = test[target_col].values[:len(preds_inv)]
y_pred = preds_inv
mae_val = np.mean(np.abs(y_true - y_pred))
rmse_val = np.sqrt(np.mean((y_true - y_pred) ** 2))
st.write(f"**Метрики качества для {model_name}:**")
st.write(f"- MAE: {mae_val:.4f}")
st.write(f"- RMSE: {rmse_val:.4f}")
else:
st.warning(f"Модель {model_name} вернула все NaN прогнозы")
except Exception as e:
st.warning(f"Не удалось посчитать full_preds для {model_name}: {e}")
continue
# Диагностика остатков для SARIMAX (если обучен)
st.subheader("11) Диагностика остатков (SARIMAX если есть)")
try:
if 'sar' in locals():
resid = sar.resid.dropna()
fig, ax = plt.subplots(figsize=(10,3))
ax.plot(resid); ax.set_title("Residuals")
st.pyplot(fig)
# Ljung-Box
try:
lb = lp.ljung_box_test(resid, lags=[10])
st.write("Ljung-Box (lag=10):")
st.dataframe(lb)
except Exception:
st.info("acorr_ljungbox недоступен")
# Shapiro
try:
sh = lp.shapiro_test(resid)
st.write("Shapiro test:", sh)
except Exception:
st.info("scipy/shapiro недоступен")
else:
st.info("SARIMAX модель не была обучена — диагностика пропущена.")
except Exception as e:
st.warning("Error in residual diagnostics: " + str(e))
# опционально: создать и предложить скачать HTML-отчёт
st.subheader("12) Сгенерировать HTML-отчёт")
try:
if st.button("Сохранить HTML-отчёт (lab3_report.html)"):
out_report = "lab3_report.html"
lp.generate_report_html(out_report, plots=[], tables={'evaluation': pd.DataFrame(eval_rows)})
st.success("Отчёт сохранён: " + out_report)
try:
with open(out_report, 'r', encoding='utf-8') as f:
html = f.read()
st.components.v1.html(html, height=600, scrolling=True)
except Exception:
st.info("Отчёт создан, но не удалось отобразить его в Streamlit.")
except Exception:
pass
st.success("Inline pipeline выполнен.")
def render_lab3():
st.title("🧪 ЛР №3: Сравнительный анализ классических моделей")
if not LAB3_AVAILABLE:
st.error("Модуль lab3_functions / lab3_pipeline недоступен. Добавьте файлы в папку src.")
if '_LAB3_IMPORT_ERROR' in globals():
st.write("Ошибка импорта:", _LAB3_IMPORT_ERROR)
return
st.info("Загрузи/выбери датасет в ЛР1 и запусти preprocess — затем сюда.")
if 'df_clean' not in st.session_state:
st.info("Сначала выполните ЛР1: загрузите и предобработайте датасет.")
return
df = st.session_state['df_clean'].copy()
numeric_cols = df.select_dtypes(include=[np.number]).columns.tolist() if 'pd' in globals() else df.select_dtypes(include=['number']).columns.tolist()
if not numeric_cols:
st.error("Нет числовых колонок.")
return
target = st.selectbox("Выберите target для ЛР3", numeric_cols)
# Убедимся, что timestamp есть в датафрейме (если он в индексе — reset_index сделает колонку)
if 'timestamp' not in df.columns:
try:
df_reset = df.reset_index()
if 'timestamp' in df_reset.columns:
df = df_reset
else:
st.error("Колонка 'timestamp' не найдена. Убедитесь, что в df_clean есть временная метка.")
return
except Exception:
st.error("Не удалось найти колонку 'timestamp'.")
return
series = df.set_index('timestamp')[target].dropna()
st.write("Длина ряда:", len(series))
# ---- существующие экспандеры ARIMA/VAR оставляем без изменений ----
# === ARIMA / auto_arima expander ===
with st.expander("ARIMA / auto_arima (SARIMAX)"):
import numpy as _np
import pandas as _pd
import matplotlib.pyplot as _plt
use_auto = st.checkbox("Использовать auto_arima (pmdarima)", value=False)
col_freq = st.selectbox("Частота ряда (inference если None)", ["auto", "D", "W", "M"], index=0)
if use_auto:
max_p = st.number_input("max_p (для auto_arima)", min_value=0, max_value=10, value=3, step=1)
max_d = st.number_input("max_d", min_value=0, max_value=3, value=2, step=1)
max_q = st.number_input("max_q", min_value=0, max_value=10, value=3, step=1)
seasonal = st.checkbox("Ищем сезонность (seasonal)", value=False)
seasonal_period = st.number_input("m (период сезонности, если seasonal=True)", min_value=0, max_value=366, value=7, step=1)
if st.button("Запустить auto_arima"):
try:
with st.spinner("Подбираю модель auto_arima..."):
model = lab3.fit_auto_arima(series, seasonal=seasonal, m=int(seasonal_period),
start_p=0, start_q=0, max_p=int(max_p), max_q=int(max_q),
max_d=int(max_d))
st.success("auto_arima выполнен")
# pmdarima object имеет summary или order/seasonal_order
try:
st.text(str(model.summary()))
except Exception:
st.write("Выбран порядок:", getattr(model, "order", None))
st.write("seasonal_order:", getattr(model, "seasonal_order", None))
st.session_state['_lab3_last_model'] = ("auto_arima", model)
except Exception as e:
st.error("Ошибка auto_arima: " + str(e))
else:
# ручная SARIMAX
st.write("Параметры SARIMAX (ручной режим)")
p = st.number_input("p (AR)", min_value=0, max_value=10, value=1, step=1)
d = st.number_input("d (diff)", min_value=0, max_value=3, value=0, step=1)
q = st.number_input("q (MA)", min_value=0, max_value=10, value=0, step=1)
sp = st.number_input("P (seasonal AR)", min_value=0, max_value=5, value=0, step=1)
sd = st.number_input("D (seasonal diff)", min_value=0, max_value=2, value=0, step=1)
sq = st.number_input("Q (seasonal MA)", min_value=0, max_value=5, value=0, step=1)
s = st.number_input("s (seasonal period)", min_value=0, max_value=366, value=7, step=1)
if st.button("Построить SARIMAX (ручной)"):
try:
with st.spinner("Обучаю SARIMAX..."):
res = lab3.fit_sarimax(series, order=(int(p), int(d), int(q)),
seasonal_order=(int(sp), int(sd), int(sq), int(s)))
st.success("SARIMAX обучен")
try:
st.text(res.summary().as_text())
except Exception:
st.write("Модель успешно обучена. Объект результата:", type(res))
st.session_state['_lab3_last_model'] = ("sarimax", res)
except Exception as e:
st.error("Ошибка SARIMAX: " + str(e))
# Forecast controls (общие)
st.markdown("**Прогноз (forecast)**")
fh = st.slider("Горизонт прогнозирования (h)", min_value=1, max_value=90, value=7)
if st.button("Сделать прогноз (последние точки + future)"):
if '_lab3_last_model' not in st.session_state:
st.error("Сначала обучите модель (auto_arima или SARIMAX).")
else:
mtype, mobj = st.session_state['_lab3_last_model']
try:
if mtype == "auto_arima":
# pmdarima.predict возвращает массив
preds = mobj.predict(n_periods=fh)
# создаём индекс для будущих дат
if col_freq == "auto":
freq = _pd.infer_freq(series.index) or "D"
else:
freq = col_freq
last = series.index[-1]
future_index = _pd.date_range(start=last + _pd.tseries.frequencies.to_offset(freq), periods=fh, freq=freq)
pred_series = _pd.Series(preds, index=future_index, name=f"pred_auto_arima_h{fh}")
st.line_chart(_pd.concat([series.tail(200), pred_series]))
st.write(pred_series.to_frame("forecast"))
else:
# SARIMAX result
mean, (low, high) = lab3.forecast_sarimax(mobj, steps=fh)
if col_freq == "auto":
freq = _pd.infer_freq(series.index) or "D"
else:
freq = col_freq
last = series.index[-1]
future_index = _pd.date_range(start=last + _pd.tseries.frequencies.to_offset(freq), periods=fh, freq=freq)
pred_series = _pd.Series(mean, index=future_index)
low_s = _pd.Series(low, index=future_index)
high_s = _pd.Series(high, index=future_index)
fig, ax = _plt.subplots(figsize=(10, 4))
ax.plot(series.tail(200).index, series.tail(200).values, label="history")
ax.plot(pred_series.index, pred_series.values, label="forecast")
ax.fill_between(pred_series.index, low_s.values, high_s.values, alpha=0.25, label="CI")
ax.legend()
st.pyplot(fig)
st.write(pred_series.to_frame("forecast"))
except Exception as e:
st.error("Ошибка при прогнозе: " + str(e))
# Residuals diagnostics
if st.checkbox("Показать диагностику остатков (последняя обученная модель)"):
if '_lab3_last_model' not in st.session_state:
st.info("Нет обученной модели в сессии.")
else:
try:
mtype, mobj = st.session_state['_lab3_last_model']
if mtype == "sarimax":
resid = mobj.resid.dropna()
elif mtype == "auto_arima":
# у pmdarima есть attribute resid_ или можно считать inplace prediction
try:
resid = _pd.Series(mobj.resid())
except Exception:
resid = None
else:
resid = None
if resid is None or len(resid) == 0:
st.warning("Не удалось получить остатки для этой модели.")
else:
fig1, ax1 = _plt.subplots(1, 1, figsize=(8, 2.5))
ax1.plot(resid)
ax1.set_title("Остатки (time series)")
st.pyplot(fig1)
fig2, ax2 = _plt.subplots(1, 1, figsize=(6, 3))
ax2.hist(resid, bins=30)
ax2.set_title("Гистограмма остатков")
st.pyplot(fig2)
# Ljung-Box, если доступен
try:
from statsmodels.stats.diagnostic import acorr_ljungbox
lb = acorr_ljungbox(resid, lags=[10], return_df=True)
st.write("Ljung-Box (lag=10):")
st.write(lb)
except Exception:
st.info("acorr_ljungbox недоступен в окружении.")
except Exception as e:
st.error("Ошибка диагностики: " + str(e))
# === VAR (multivariate) expander ===
with st.expander("VAR (векторная авторегрессия) — multivariate"):
import pandas as _pd
import matplotlib.pyplot as _plt
st.write("Выберите колонки для VAR (минимум 2):")
var_cols = st.multiselect("Колонки для VAR", options=numeric_cols, default=numeric_cols[:3])
maxlags = st.number_input("maxlags (select_order)", min_value=1, max_value=20, value=5, step=1)
if st.button("Построить VAR"):
if len(var_cols) < 2:
st.error("Нужно хотя бы 2 колонки для VAR.")
else:
try:
df_var = df.set_index('timestamp')[var_cols].dropna()
with st.spinner("Обучаю VAR..."):
var_res = lab3.fit_var(df_var, maxlags=int(maxlags))
st.success("VAR обучен, lag order = %s" % getattr(var_res, "k_ar", "unknown"))
try:
st.text(var_res.summary().as_text())
except Exception:
st.write("VAR results object:", type(var_res))
st.session_state['_lab3_last_var'] = var_res
except Exception as e:
st.error("Ошибка VAR: " + str(e))
# Forecast for VAR
st.markdown("**Прогноз VAR**")
if st.button("Сделать VAR-прогноз (30 шагов)"):
if '_lab3_last_var' not in st.session_state:
st.error("Сначала обучите VAR.")
else:
try:
var_res = st.session_state['_lab3_last_var']
steps = st.number_input("h (шагов вперёд)", min_value=1, max_value=365, value=30, step=1)
fut = lab3.forecast_var(var_res, steps=int(steps))
st.write("Прогноз (DataFrame):")
st.dataframe(fut)
# Нарисуем прогноз по каждой переменной вместе с последними историческими точками
df_var = df.set_index('timestamp')[var_cols].dropna()
last_hist = df_var.tail(50)
fig, ax = _plt.subplots(len(var_cols), 1, figsize=(10, 3*len(var_cols)), sharex=True)
if len(var_cols) == 1:
ax = [ax]
for i, col in enumerate(var_cols):
ax[i].plot(last_hist.index, last_hist[col].values, label=f"history_{col}")
# построим future index
freq = _pd.infer_freq(df_var.index) or "D"
future_index = _pd.date_range(start=df_var.index[-1] + _pd.tseries.frequencies.to_offset(freq),
periods=len(fut), freq=freq)
ax[i].plot(future_index, fut[col].values, label=f"forecast_{col}")
ax[i].legend()
st.pyplot(fig)
except Exception as e:
st.error("Ошибка при VAR-прогнозе: " + str(e))
# --- Новый блок: полный pipeline (run_pipeline) ---
st.markdown("---")
st.subheader("Запустить полный pipeline ЛР3 (генерация HTML-отчёта)")
out_report = st.text_input("Путь для отчёта (HTML)", "./lab3_report.html")
freq = st.selectbox("Частота ресемплинга", ['D','W','M'])
if st.button("Запустить полный pipeline и сформировать отчёт (и показать результаты)"):
tmp = "./_lab3_input_tmp.csv"
df.to_csv(tmp, index=False)
display_pipeline_inline(tmp, timestamp_col='timestamp', target_col=target, freq=freq)
tmp = "./_lab3_input_tmp.csv"
# сохраняем df с колонкой timestamp
df.to_csv(tmp, index=False)
st.info("Запускаю pipeline — это может занять время.")
try:
# run_pipeline ожидает путь к файлу
run_pipeline(tmp, timestamp_col='timestamp', target_col=target, out_report=out_report, freq=freq)
st.success("Готово — отчёт сохранён: " + out_report)
if Path(out_report).exists():
with open(out_report, 'r', encoding='utf-8') as f:
html = f.read()
components.html(html, height=800, scrolling=True)
else:
st.info("Отчёт создан, но файл не найден по указанному пути: " + out_report)
except Exception as e:
st.error("Ошибка при выполнении pipeline: " + str(e))
def render_lab4():
st.title("🧪 ЛР №4: ML-модели")
if not LAB4_AVAILABLE:
st.error("Модуль lab4_functions недоступен.")
return
if 'df_clean' not in st.session_state:
st.info("Сначала выполните ЛР1: загрузите и предобработайте датасет.")
return
df = st.session_state['df_clean']
numeric_cols = df.select_dtypes(include=[np.number]).columns.tolist()
target = st.selectbox("Target", numeric_cols)
if st.button("Сгенерировать простые лаги и обучить модели"):
dfr = lab4.make_lag_features(df, target, lags=[1,7,30])
X = dfr.drop(columns=['timestamp', target])
y = dfr[target]
models = lab4.train_baselines(X, y)
st.write("Обученные модели:", list(models.keys()))
for name, m in models.items():
st.write(name, getattr(m, 'score', lambda X,y: None)(X, y) )
def render_lab5():
st.title("🧪 ЛР №5: Deep Learning")
if not LAB5_AVAILABLE:
st.error("Модуль lab5_functions недоступен.")
return
if 'df_clean' not in st.session_state:
st.info("Сначала выполните ЛР1.")
return
df = st.session_state['df_clean']
numeric_cols = df.select_dtypes(include=[np.number]).columns.tolist()
target = st.selectbox("Target для DL", numeric_cols)
if st.button("Обучить простой LSTM (demo)"):
s = df.set_index('timestamp')[target].dropna().values
try:
model = lab5.train_lstm(s, lookback=30, epochs=5)
st.success("Модель обучена (demo).")
except Exception as e:
st.error(str(e))
# ---------------- Главный код: выбор лабораторной работы ----------------
if lab_choice == "ЛР №1: Введение в анализ временных рядов":
render_lab1()
elif lab_choice == "ЛР №2: Прогнозирование временных рядов":
render_lab2()
elif lab_choice == "ЛР №3: Классические модели":
render_lab3()
elif lab_choice == "ЛР №4: ML для TS":
render_lab4()
elif lab_choice == "ЛР №5: Deep Learning для TS":
render_lab5()
else:
st.info("Выберите лабораторную работу в боковой панели")