Spaces:
No application file
No application file
| 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("Выберите лабораторную работу в боковой панели") | |