TimeSeriesHomework1_2 / src /streamlit_app.py
Kolesnikov Dmitry
debug: Доработки через дипсик
7c4a2f3
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"<h1>Отчёт по временным рядам — target: {target}</h1>")
parts.append(f"<p>Параметры: {params}</p>")
# include time series fig
if 'series' in figs:
parts.append('<h2>Временной ряд</h2>')
parts.append(figs['series'].to_html(full_html=False, include_plotlyjs='cdn'))
if 'decomp' in figs:
parts.append('<h2>Декомпозиция</h2>')
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('<h2>Матрица корреляций</h2>')
parts.append(figs['corr'].to_html(full_html=False, include_plotlyjs='cdn'))
if 'acf' in figs and 'pacf' in figs:
parts.append('<h2>ACF / PACF</h2>')
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'<h3>{name}</h3>')
parts.append(table.to_html(classes="table table-striped", index=True))
html = '<html><head><meta charset="utf-8"></head><body>' + ''.join(parts) + '</body></html>'
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("Выберите лабораторную работу в боковой панели")