import os from typing import Optional, List, Tuple, Dict import numpy as np import pandas as pd import pytz import streamlit as st import plotly.express as px import plotly.graph_objects as go import matplotlib.pyplot as plt from scipy import stats as scipy_stats from statsmodels.tsa.stattools import adfuller, kpss, acf as sm_acf, pacf as sm_pacf from statsmodels.graphics.tsaplots import plot_acf, plot_pacf from statsmodels.tsa.seasonal import seasonal_decompose from statsmodels.stats.outliers_influence import variance_inflation_factor from statsmodels.tools import add_constant import sys import os # Добавляем папку с модулями (путь к файлу streamlit_app.py) # Это гарантирует, что Python найдёт lab3_pipeline и lab3_functions в той же папке src sys.path.append(os.path.dirname(os.path.abspath(__file__))) try: import lab3_functions as lab3 from lab3_pipeline import run_pipeline LAB3_AVAILABLE = True except Exception as e: LAB3_AVAILABLE = False lab3 = None _LAB3_IMPORT_ERROR = str(e) # Для дебага выведем в консоль (и в Streamlit, если он загружен) print("lab3 import failed:", _LAB3_IMPORT_ERROR) try: import streamlit as _st _st.warning("lab3 import failed: " + _LAB3_IMPORT_ERROR) except Exception: pass st.set_page_config(page_title="Анализ временных рядов", layout="wide", initial_sidebar_state="expanded") MOSCOW = pytz.timezone("Europe/Moscow") # Импорт функций для ЛР №2 import sys sys.path.append(os.path.dirname(os.path.abspath(__file__))) try: from lab2_functions import ( create_advanced_features, apply_transformations, apply_boxcox_transform, inverse_boxcox_transform, inverse_transformations, recursive_forecast, direct_forecast, hybrid_forecast, create_exponential_smoothing_model, evaluate_forecast, naive_forecast, time_series_cv_sliding_window, time_series_cv_expanding_window, diagnose_model_residuals, calculate_mape ) LAB2_AVAILABLE = True except ImportError as e: LAB2_AVAILABLE = False st.warning(f"Функции ЛР №2 недоступны: {e}") # после импорта lab2_functions try: import lab3_functions as lab3 LAB3_AVAILABLE = True except Exception: LAB3_AVAILABLE = False try: import lab4_functions as lab4 LAB4_AVAILABLE = True except Exception: LAB4_AVAILABLE = False try: import lab5_functions as lab5 LAB5_AVAILABLE = True except Exception: LAB5_AVAILABLE = False # Навигация между лабораторными работами st.sidebar.title("🧪 Лабораторные работы") lab_choice = st.sidebar.radio( "Выберите лабораторную работу:", ["ЛР №1: Введение в анализ временных рядов", "ЛР №2: Прогнозирование временных рядов", "ЛР №3: Классические модели", "ЛР №4: ML для TS", "ЛР №5: Deep Learning для TS" ], index=0 ) # ---------------- Utilities ---------------- def detect_date_column(df: pd.DataFrame) -> Optional[str]: candidates = [c for c in df.columns if any(k in c.lower() for k in ("date", "time", "timestamp", "dt", "day"))] if candidates: pref = [c for c in candidates if 'date' in c.lower()] return pref[0] if pref else candidates[0] scores = {} for c in df.columns: parsed = pd.to_datetime(df[c], errors='coerce', dayfirst=True, infer_datetime_format=True) scores[c] = parsed.notna().mean() best, score = max(scores.items(), key=lambda x: x[1]) return best if score > 0.5 else None def try_parse_dates(series: pd.Series) -> pd.Series: s = series.astype(str).replace('nan', pd.NA) parsed = pd.to_datetime(s, errors='coerce', infer_datetime_format=True) parsed = parsed.fillna(pd.to_datetime(s, format='%d.%m.%Y', errors='coerce')) parsed = parsed.fillna(pd.to_datetime(s, format='%Y-%m-%d', errors='coerce')) return parsed def localize_to_moscow(ts: pd.Series, assume_tz: str = 'local') -> pd.Series: ts = pd.to_datetime(ts, errors='coerce') if ts.dt.tz is None: if assume_tz == 'utc': ts = ts.dt.tz_localize('UTC').dt.tz_convert('Europe/Moscow') elif assume_tz == 'local': ts = ts.dt.tz_localize('Europe/Moscow') else: pass else: ts = ts.dt.tz_convert('Europe/Moscow') return ts def detect_outliers_iqr(col: pd.Series) -> pd.Series: q1 = col.quantile(0.25) q3 = col.quantile(0.75) iqr = q3 - q1 lo = q1 - 1.5 * iqr hi = q3 + 1.5 * iqr return (col < lo) | (col > hi) def winsorize_series(col: pd.Series, lower_q: float = 0.01, upper_q: float = 0.99) -> pd.Series: low = col.quantile(lower_q) high = col.quantile(upper_q) return col.clip(lower=low, upper=high) # ---------------- Preprocessing (3.2) ---------------- def preprocess_timeseries( df: pd.DataFrame, date_col: str, tz_assume: str = 'local', numeric_missing_strategy: str = 'interpolate', cat_missing_strategy: str = 'mode', outlier_strategy: str = 'interpolate', resample_freq: Optional[str] = None, ) -> Tuple[pd.DataFrame, Dict]: info: Dict = {} df2 = df.copy() parsed = try_parse_dates(df2[date_col]) info['parse_success'] = float(parsed.notna().mean()) df2['timestamp'] = parsed df2['timestamp'] = localize_to_moscow(df2['timestamp'], assume_tz=tz_assume) before = len(df2) df2 = df2.dropna(subset=['timestamp']).reset_index(drop=True) info['dropped_no_timestamp'] = before - len(df2) df2 = df2.sort_values('timestamp').drop_duplicates(subset=['timestamp']).reset_index(drop=True) num_cols = df2.select_dtypes(include=[np.number]).columns.tolist() cat_cols = [c for c in df2.columns if c not in num_cols and c != 'timestamp' and c != date_col] info['num_cols'] = num_cols info['cat_cols'] = cat_cols info['missing_before'] = df2[num_cols].isna().sum().to_dict() if numeric_missing_strategy == 'drop': df2 = df2.dropna(subset=num_cols).reset_index(drop=True) elif numeric_missing_strategy == 'interpolate': df2 = df2.set_index('timestamp') df2[num_cols] = df2[num_cols].interpolate(method='time', limit_direction='both') df2 = df2.reset_index() elif numeric_missing_strategy == 'rolling': for c in num_cols: df2[c] = df2[c].fillna(df2[c].rolling(window=7, min_periods=1).mean()) else: raise ValueError('unknown numeric_missing_strategy') for c in cat_cols: if cat_missing_strategy == 'mode': mode = df2[c].mode() fill = mode[0] if not mode.empty else 'unknown' df2[c] = df2[c].fillna(fill) else: df2[c] = df2[c].fillna('unknown') info['missing_after'] = df2[num_cols].isna().sum().to_dict() outlier_summary = [] for c in num_cols: col = df2[c] iqr_mask = detect_outliers_iqr(col) outlier_summary.append({'column': c, 'iqr_count': int(iqr_mask.sum())}) info['outlier_summary'] = outlier_summary if outlier_strategy == 'mark': pass elif outlier_strategy == 'interpolate': df2 = df2.set_index('timestamp') for c in num_cols: mask = detect_outliers_iqr(df2[c]) df2.loc[mask, c] = np.nan df2[num_cols] = df2[num_cols].interpolate(method='time', limit_direction='both') df2 = df2.reset_index() elif outlier_strategy == 'winsorize': for c in num_cols: df2[c] = winsorize_series(df2[c]) elif outlier_strategy == 'drop': for c in num_cols: mask = detect_outliers_iqr(df2[c]) df2 = df2.loc[~mask].reset_index(drop=True) else: raise ValueError('unknown outlier_strategy') if resample_freq is not None: df2 = df2.set_index('timestamp') agg = {} for c in num_cols: lname = c.lower() if any(k in lname for k in ('case', 'count', 'death', 'new', 'confirmed', 'positive', 'tests')): agg[c] = 'sum' else: agg[c] = 'mean' res = df2.resample(resample_freq).agg(agg) for c in cat_cols: res[c] = df2[c].resample(resample_freq).first() res = res.reset_index() df2 = res if 'timestamp' in df2.columns: ts = pd.to_datetime(df2['timestamp']) if ts.dt.tz is None: df2['timestamp'] = ts.dt.tz_localize('Europe/Moscow') else: df2['timestamp'] = ts.dt.tz_convert('Europe/Moscow') info['final_shape'] = df2.shape return df2, info # ---------------- Descriptive (3.3) ---------------- def descriptive_statistics(df: pd.DataFrame, numeric_cols: List[str]) -> pd.DataFrame: rows = [] for c in numeric_cols: s = df[c].dropna() rows.append({ 'column': c, 'count': int(s.count()), 'mean': float(s.mean()) if not s.empty else None, 'median': float(s.median()) if not s.empty else None, 'std': float(s.std()) if not s.empty else None, 'min': float(s.min()) if not s.empty else None, 'q1': float(s.quantile(0.25)) if not s.empty else None, 'q3': float(s.quantile(0.75)) if not s.empty else None, 'max': float(s.max()) if not s.empty else None, 'skew': float(s.skew()) if not s.empty else None, 'kurtosis': float(s.kurtosis()) if not s.empty else None, 'missing_pct': float(df[c].isna().mean()) }) return pd.DataFrame(rows).set_index('column') # ---------------- Stationarity (3.4) helpers ---------------- def run_adf(series: pd.Series) -> Dict: try: res = adfuller(series.dropna().values, autolag='AIC') return {'statistic': res[0], 'pvalue': res[1], 'usedlag': res[2], 'nobs': res[3]} except Exception as e: return {'error': str(e)} def run_kpss(series: pd.Series) -> Dict: try: res = kpss(series.dropna().values, nlags='auto') return {'statistic': res[0], 'pvalue': res[1], 'nlags': res[2]} except Exception as e: return {'error': str(e)} # ---------------- Lag & Rolling (3.5) ---------------- def create_lags_and_rolls(df: pd.DataFrame, target: str, lags: List[int], roll_windows: List[int], extra_features: List[str] = None) -> pd.DataFrame: df2 = df.copy().set_index('timestamp') df2 = df2.sort_index() for l in lags: df2[f'{target}_lag_{l}'] = df2[target].shift(l) if extra_features: for feat in extra_features: for l in lags: df2[f'{feat}_lag_{l}'] = df2[feat].shift(l) for w in roll_windows: df2[f'{target}_roll_mean_{w}'] = df2[target].rolling(window=w, min_periods=1).mean() df2[f'{target}_roll_std_{w}'] = df2[target].rolling(window=w, min_periods=1).std() return df2.reset_index() def compute_lag_correlations(df: pd.DataFrame, target: str, lags: List[int]) -> pd.DataFrame: cols = [f'{target}_lag_{l}' for l in lags if f'{target}_lag_{l}' in df.columns] corr_rows = [] for c in cols: corr = df[[target, c]].dropna().corr().iloc[0, 1] corr_rows.append({'lag_col': c, 'corr_with_target': float(corr) if pd.notna(corr) else None}) return pd.DataFrame(corr_rows).set_index('lag_col') def compute_vif(df: pd.DataFrame, features: List[str]) -> pd.DataFrame: X = df[features].dropna() if X.shape[0] == 0: return pd.DataFrame({'feature': features, 'VIF': [None] * len(features)}).set_index('feature') X_const = add_constant(X) vif_vals = [] for i, col in enumerate(X.columns): try: v = variance_inflation_factor(X_const.values, i + 1) except Exception: v = np.nan vif_vals.append({'feature': col, 'VIF': float(v) if pd.notna(v) else None}) return pd.DataFrame(vif_vals).set_index('feature') # ---------------- ACF/PACF helpers (3.6) ---------------- def get_acf_pacf_with_conf(series: pd.Series, nlags: int = 40, alpha: float = 0.05): acf_vals, acf_confint = sm_acf(series.dropna().values, nlags=nlags, alpha=alpha) pacf_vals, pacf_confint = sm_pacf(series.dropna().values, nlags=nlags, alpha=alpha) return acf_vals, acf_confint, pacf_vals, pacf_confint def significant_lags_from_conf(vals: np.ndarray, confint: np.ndarray) -> List[int]: sig = [] for i in range(1, len(vals)): lower, upper = confint[i] v = vals[i] if (v < lower) or (v > upper): sig.append(i) return sig def plotly_acf_pacf(acf_vals, acf_conf, pacf_vals, pacf_conf, max_lag, title_prefix=''): # build ACF bar + conf intervals lags = list(range(len(acf_vals)))[: max_lag + 1] acf_fig = go.Figure() acf_fig.add_trace(go.Bar(x=lags, y=acf_vals[:len(lags)], name='ACF')) # conf intervals as lines if acf_conf is not None and len(acf_conf) >= len(lags): lower = [acf_conf[i][0] for i in lags] upper = [acf_conf[i][1] for i in lags] acf_fig.add_trace(go.Scatter(x=lags, y=upper, mode='lines', line=dict(width=1), name='conf_upper')) acf_fig.add_trace(go.Scatter(x=lags, y=lower, mode='lines', line=dict(width=1), name='conf_lower')) acf_fig.update_layout(title=f'{title_prefix} ACF', xaxis_title='lag') lags_p = list(range(len(pacf_vals)))[: max_lag + 1] pacf_fig = go.Figure() pacf_fig.add_trace(go.Bar(x=lags_p, y=pacf_vals[:len(lags_p)], name='PACF')) if pacf_conf is not None and len(pacf_conf) >= len(lags_p): lowerp = [pacf_conf[i][0] for i in lags_p] upperp = [pacf_conf[i][1] for i in lags_p] pacf_fig.add_trace(go.Scatter(x=lags_p, y=upperp, mode='lines', line=dict(width=1), name='conf_upper')) pacf_fig.add_trace(go.Scatter(x=lags_p, y=lowerp, mode='lines', line=dict(width=1), name='conf_lower')) pacf_fig.update_layout(title=f'{title_prefix} PACF', xaxis_title='lag') return acf_fig, pacf_fig # ---------------- Report generation (3.8 helpers) ---------------- def generate_html_report( df: pd.DataFrame, target: str, features: List[str], params: Dict, figs: Dict[str, any], tables: Dict[str, pd.DataFrame] ) -> str: parts = [] parts.append(f"

Отчёт по временным рядам — target: {target}

") parts.append(f"

Параметры: {params}

") # include time series fig if 'series' in figs: parts.append('

Временной ряд

') parts.append(figs['series'].to_html(full_html=False, include_plotlyjs='cdn')) if 'decomp' in figs: parts.append('

Декомпозиция

') parts.append(figs['decomp_observed'].to_html(full_html=False, include_plotlyjs='cdn')) parts.append(figs['decomp_trend'].to_html(full_html=False, include_plotlyjs='cdn')) parts.append(figs['decomp_seasonal'].to_html(full_html=False, include_plotlyjs='cdn')) parts.append(figs['decomp_resid'].to_html(full_html=False, include_plotlyjs='cdn')) if 'corr' in figs: parts.append('

Матрица корреляций

') parts.append(figs['corr'].to_html(full_html=False, include_plotlyjs='cdn')) if 'acf' in figs and 'pacf' in figs: parts.append('

ACF / PACF

') parts.append(figs['acf'].to_html(full_html=False, include_plotlyjs='cdn')) parts.append(figs['pacf'].to_html(full_html=False, include_plotlyjs='cdn')) # tables for name, table in tables.items(): parts.append(f'

{name}

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