Kolesnikov Dmitry commited on
Commit
6db55a4
·
1 Parent(s): 8e3da31

feat: Готовый проект

Browse files
Dockerfile CHANGED
@@ -8,7 +8,8 @@ RUN apt-get update && apt-get install -y \
8
  git \
9
  && rm -rf /var/lib/apt/lists/*
10
 
11
- COPY requirements.txt ./
 
12
 
13
  RUN pip3 install -r requirements.txt
14
 
 
8
  git \
9
  && rm -rf /var/lib/apt/lists/*
10
 
11
+ COPY src/requirements.txt ./
12
+ COPY src/ ./src/
13
 
14
  RUN pip3 install -r requirements.txt
15
 
final_dataset.csv → src/final_dataset.csv RENAMED
File without changes
requirements.txt → src/requirements.txt RENAMED
File without changes
russia_covid_dataset.csv → src/russia_covid_dataset.csv RENAMED
File without changes
streamlit_app.py → src/streamlit_app.py RENAMED
File without changes
streamlit_preprocess_app.py DELETED
@@ -1,833 +0,0 @@
1
- # streamlit_preprocess_app.py
2
- """
3
- Streamlit-приложение: предобработка (3.2), описательный анализ (3.3), тесты стационарности (3.4), генерация лагов/скользящих признаков (3.5), ACF/PACF (3.6), декомпозиция (3.7) и экспорт/веб-интерфейс (3.8).
4
-
5
- Запуск:
6
- pip install pandas numpy streamlit pytz plotly statsmodels scikit-learn
7
- streamlit run streamlit_preprocess_app.py
8
-
9
- Файл создан для Дмитрия: сохраняет результаты в st.session_state, чтобы при смене виджетов
10
- результаты не пропадали.
11
- """
12
- import os
13
- import io
14
- import base64
15
- from typing import Optional, List, Tuple, Dict
16
-
17
- import numpy as np
18
- import pandas as pd
19
- import pytz
20
- import streamlit as st
21
- import plotly.express as px
22
- import plotly.graph_objects as go
23
- import matplotlib.pyplot as plt
24
- from statsmodels.tsa.stattools import adfuller, kpss, acf as sm_acf, pacf as sm_pacf
25
- from statsmodels.graphics.tsaplots import plot_acf, plot_pacf
26
- from statsmodels.tsa.seasonal import seasonal_decompose
27
- from statsmodels.stats.outliers_influence import variance_inflation_factor
28
- from statsmodels.tools import add_constant
29
-
30
- st.set_page_config(page_title="TS Preprocess & EDA (3.2–3.8)", layout="wide")
31
- MOSCOW = pytz.timezone("Europe/Moscow")
32
-
33
-
34
- # ---------------- Utilities ----------------
35
- def detect_date_column(df: pd.DataFrame) -> Optional[str]:
36
- candidates = [c for c in df.columns if any(k in c.lower() for k in ("date", "time", "timestamp", "dt", "day"))]
37
- if candidates:
38
- pref = [c for c in candidates if 'date' in c.lower()]
39
- return pref[0] if pref else candidates[0]
40
- scores = {}
41
- for c in df.columns:
42
- parsed = pd.to_datetime(df[c], errors='coerce', dayfirst=True, infer_datetime_format=True)
43
- scores[c] = parsed.notna().mean()
44
- best, score = max(scores.items(), key=lambda x: x[1])
45
- return best if score > 0.5 else None
46
-
47
-
48
- def try_parse_dates(series: pd.Series) -> pd.Series:
49
- s = series.astype(str).replace('nan', pd.NA)
50
- parsed = pd.to_datetime(s, errors='coerce', infer_datetime_format=True)
51
- parsed = parsed.fillna(pd.to_datetime(s, format='%d.%m.%Y', errors='coerce'))
52
- parsed = parsed.fillna(pd.to_datetime(s, format='%Y-%m-%d', errors='coerce'))
53
- return parsed
54
-
55
-
56
- def localize_to_moscow(ts: pd.Series, assume_tz: str = 'local') -> pd.Series:
57
- ts = pd.to_datetime(ts, errors='coerce')
58
- if ts.dt.tz is None:
59
- if assume_tz == 'utc':
60
- ts = ts.dt.tz_localize('UTC').dt.tz_convert('Europe/Moscow')
61
- elif assume_tz == 'local':
62
- ts = ts.dt.tz_localize('Europe/Moscow')
63
- else:
64
- pass
65
- else:
66
- ts = ts.dt.tz_convert('Europe/Moscow')
67
- return ts
68
-
69
-
70
- def detect_outliers_iqr(col: pd.Series) -> pd.Series:
71
- q1 = col.quantile(0.25)
72
- q3 = col.quantile(0.75)
73
- iqr = q3 - q1
74
- lo = q1 - 1.5 * iqr
75
- hi = q3 + 1.5 * iqr
76
- return (col < lo) | (col > hi)
77
-
78
-
79
- def winsorize_series(col: pd.Series, lower_q: float = 0.01, upper_q: float = 0.99) -> pd.Series:
80
- low = col.quantile(lower_q)
81
- high = col.quantile(upper_q)
82
- return col.clip(lower=low, upper=high)
83
-
84
-
85
- # ---------------- Preprocessing (3.2) ----------------
86
- def preprocess_timeseries(
87
- df: pd.DataFrame,
88
- date_col: str,
89
- tz_assume: str = 'local',
90
- numeric_missing_strategy: str = 'interpolate',
91
- cat_missing_strategy: str = 'mode',
92
- outlier_strategy: str = 'interpolate',
93
- resample_freq: Optional[str] = None,
94
- ) -> Tuple[pd.DataFrame, Dict]:
95
- info: Dict = {}
96
- df2 = df.copy()
97
- parsed = try_parse_dates(df2[date_col])
98
- info['parse_success'] = float(parsed.notna().mean())
99
- df2['timestamp'] = parsed
100
- df2['timestamp'] = localize_to_moscow(df2['timestamp'], assume_tz=tz_assume)
101
- before = len(df2)
102
- df2 = df2.dropna(subset=['timestamp']).reset_index(drop=True)
103
- info['dropped_no_timestamp'] = before - len(df2)
104
- df2 = df2.sort_values('timestamp').drop_duplicates(subset=['timestamp']).reset_index(drop=True)
105
-
106
- num_cols = df2.select_dtypes(include=[np.number]).columns.tolist()
107
- cat_cols = [c for c in df2.columns if c not in num_cols and c != 'timestamp' and c != date_col]
108
- info['num_cols'] = num_cols
109
- info['cat_cols'] = cat_cols
110
-
111
- info['missing_before'] = df2[num_cols].isna().sum().to_dict()
112
-
113
- if numeric_missing_strategy == 'drop':
114
- df2 = df2.dropna(subset=num_cols).reset_index(drop=True)
115
- elif numeric_missing_strategy == 'interpolate':
116
- df2 = df2.set_index('timestamp')
117
- df2[num_cols] = df2[num_cols].interpolate(method='time', limit_direction='both')
118
- df2 = df2.reset_index()
119
- elif numeric_missing_strategy == 'rolling':
120
- for c in num_cols:
121
- df2[c] = df2[c].fillna(df2[c].rolling(window=7, min_periods=1).mean())
122
- else:
123
- raise ValueError('unknown numeric_missing_strategy')
124
-
125
- for c in cat_cols:
126
- if cat_missing_strategy == 'mode':
127
- mode = df2[c].mode()
128
- fill = mode[0] if not mode.empty else 'unknown'
129
- df2[c] = df2[c].fillna(fill)
130
- else:
131
- df2[c] = df2[c].fillna('unknown')
132
-
133
- info['missing_after'] = df2[num_cols].isna().sum().to_dict()
134
-
135
- outlier_summary = []
136
- for c in num_cols:
137
- col = df2[c]
138
- iqr_mask = detect_outliers_iqr(col)
139
- outlier_summary.append({'column': c, 'iqr_count': int(iqr_mask.sum())})
140
- info['outlier_summary'] = outlier_summary
141
-
142
- if outlier_strategy == 'mark':
143
- pass
144
- elif outlier_strategy == 'interpolate':
145
- df2 = df2.set_index('timestamp')
146
- for c in num_cols:
147
- mask = detect_outliers_iqr(df2[c])
148
- df2.loc[mask, c] = np.nan
149
- df2[num_cols] = df2[num_cols].interpolate(method='time', limit_direction='both')
150
- df2 = df2.reset_index()
151
- elif outlier_strategy == 'winsorize':
152
- for c in num_cols:
153
- df2[c] = winsorize_series(df2[c])
154
- elif outlier_strategy == 'drop':
155
- for c in num_cols:
156
- mask = detect_outliers_iqr(df2[c])
157
- df2 = df2.loc[~mask].reset_index(drop=True)
158
- else:
159
- raise ValueError('unknown outlier_strategy')
160
-
161
- if resample_freq is not None:
162
- df2 = df2.set_index('timestamp')
163
- agg = {}
164
- for c in num_cols:
165
- lname = c.lower()
166
- if any(k in lname for k in ('case', 'count', 'death', 'new', 'confirmed', 'positive', 'tests')):
167
- agg[c] = 'sum'
168
- else:
169
- agg[c] = 'mean'
170
- res = df2.resample(resample_freq).agg(agg)
171
- for c in cat_cols:
172
- res[c] = df2[c].resample(resample_freq).first()
173
- res = res.reset_index()
174
- df2 = res
175
-
176
- if 'timestamp' in df2.columns:
177
- ts = pd.to_datetime(df2['timestamp'])
178
- if ts.dt.tz is None:
179
- df2['timestamp'] = ts.dt.tz_localize('Europe/Moscow')
180
- else:
181
- df2['timestamp'] = ts.dt.tz_convert('Europe/Moscow')
182
-
183
- info['final_shape'] = df2.shape
184
- return df2, info
185
-
186
-
187
- # ---------------- Descriptive (3.3) ----------------
188
- def descriptive_statistics(df: pd.DataFrame, numeric_cols: List[str]) -> pd.DataFrame:
189
- rows = []
190
- for c in numeric_cols:
191
- s = df[c].dropna()
192
- rows.append({
193
- 'column': c,
194
- 'count': int(s.count()),
195
- 'mean': float(s.mean()) if not s.empty else None,
196
- 'median': float(s.median()) if not s.empty else None,
197
- 'std': float(s.std()) if not s.empty else None,
198
- 'min': float(s.min()) if not s.empty else None,
199
- 'q1': float(s.quantile(0.25)) if not s.empty else None,
200
- 'q3': float(s.quantile(0.75)) if not s.empty else None,
201
- 'max': float(s.max()) if not s.empty else None,
202
- 'skew': float(s.skew()) if not s.empty else None,
203
- 'kurtosis': float(s.kurtosis()) if not s.empty else None,
204
- 'missing_pct': float(df[c].isna().mean())
205
- })
206
- return pd.DataFrame(rows).set_index('column')
207
-
208
-
209
- # ---------------- Stationarity (3.4) helpers ----------------
210
- def run_adf(series: pd.Series) -> Dict:
211
- try:
212
- res = adfuller(series.dropna().values, autolag='AIC')
213
- return {'statistic': res[0], 'pvalue': res[1], 'usedlag': res[2], 'nobs': res[3]}
214
- except Exception as e:
215
- return {'error': str(e)}
216
-
217
-
218
- def run_kpss(series: pd.Series) -> Dict:
219
- try:
220
- res = kpss(series.dropna().values, nlags='auto')
221
- return {'statistic': res[0], 'pvalue': res[1], 'nlags': res[2]}
222
- except Exception as e:
223
- return {'error': str(e)}
224
-
225
-
226
- # ---------------- Lag & Rolling (3.5) ----------------
227
- def create_lags_and_rolls(df: pd.DataFrame, target: str, lags: List[int], roll_windows: List[int], extra_features: List[str] = None) -> pd.DataFrame:
228
- df2 = df.copy().set_index('timestamp')
229
- df2 = df2.sort_index()
230
- for l in lags:
231
- df2[f'{target}_lag_{l}'] = df2[target].shift(l)
232
- if extra_features:
233
- for feat in extra_features:
234
- for l in lags:
235
- df2[f'{feat}_lag_{l}'] = df2[feat].shift(l)
236
- for w in roll_windows:
237
- df2[f'{target}_roll_mean_{w}'] = df2[target].rolling(window=w, min_periods=1).mean()
238
- df2[f'{target}_roll_std_{w}'] = df2[target].rolling(window=w, min_periods=1).std()
239
- return df2.reset_index()
240
-
241
-
242
- def compute_lag_correlations(df: pd.DataFrame, target: str, lags: List[int]) -> pd.DataFrame:
243
- cols = [f'{target}_lag_{l}' for l in lags if f'{target}_lag_{l}' in df.columns]
244
- corr_rows = []
245
- for c in cols:
246
- corr = df[[target, c]].dropna().corr().iloc[0, 1]
247
- corr_rows.append({'lag_col': c, 'corr_with_target': float(corr) if pd.notna(corr) else None})
248
- return pd.DataFrame(corr_rows).set_index('lag_col')
249
-
250
-
251
- def compute_vif(df: pd.DataFrame, features: List[str]) -> pd.DataFrame:
252
- X = df[features].dropna()
253
- if X.shape[0] == 0:
254
- return pd.DataFrame({'feature': features, 'VIF': [None] * len(features)}).set_index('feature')
255
- X_const = add_constant(X)
256
- vif_vals = []
257
- for i, col in enumerate(X.columns):
258
- try:
259
- v = variance_inflation_factor(X_const.values, i + 1)
260
- except Exception:
261
- v = np.nan
262
- vif_vals.append({'feature': col, 'VIF': float(v) if pd.notna(v) else None})
263
- return pd.DataFrame(vif_vals).set_index('feature')
264
-
265
-
266
- # ---------------- ACF/PACF helpers (3.6) ----------------
267
- def get_acf_pacf_with_conf(series: pd.Series, nlags: int = 40, alpha: float = 0.05):
268
- acf_vals, acf_confint = sm_acf(series.dropna().values, nlags=nlags, alpha=alpha)
269
- pacf_vals, pacf_confint = sm_pacf(series.dropna().values, nlags=nlags, alpha=alpha)
270
- return acf_vals, acf_confint, pacf_vals, pacf_confint
271
-
272
-
273
- def significant_lags_from_conf(vals: np.ndarray, confint: np.ndarray) -> List[int]:
274
- sig = []
275
- for i in range(1, len(vals)):
276
- lower, upper = confint[i]
277
- v = vals[i]
278
- if (v < lower) or (v > upper):
279
- sig.append(i)
280
- return sig
281
-
282
-
283
- def plotly_acf_pacf(acf_vals, acf_conf, pacf_vals, pacf_conf, max_lag, title_prefix=''):
284
- # build ACF bar + conf intervals
285
- lags = list(range(len(acf_vals)))[: max_lag + 1]
286
- acf_fig = go.Figure()
287
- acf_fig.add_trace(go.Bar(x=lags, y=acf_vals[:len(lags)], name='ACF'))
288
- # conf intervals as lines
289
- if acf_conf is not None and len(acf_conf) >= len(lags):
290
- lower = [acf_conf[i][0] for i in lags]
291
- upper = [acf_conf[i][1] for i in lags]
292
- acf_fig.add_trace(go.Scatter(x=lags, y=upper, mode='lines', line=dict(width=1), name='conf_upper'))
293
- acf_fig.add_trace(go.Scatter(x=lags, y=lower, mode='lines', line=dict(width=1), name='conf_lower'))
294
- acf_fig.update_layout(title=f'{title_prefix} ACF', xaxis_title='lag')
295
-
296
- lags_p = list(range(len(pacf_vals)))[: max_lag + 1]
297
- pacf_fig = go.Figure()
298
- pacf_fig.add_trace(go.Bar(x=lags_p, y=pacf_vals[:len(lags_p)], name='PACF'))
299
- if pacf_conf is not None and len(pacf_conf) >= len(lags_p):
300
- lowerp = [pacf_conf[i][0] for i in lags_p]
301
- upperp = [pacf_conf[i][1] for i in lags_p]
302
- pacf_fig.add_trace(go.Scatter(x=lags_p, y=upperp, mode='lines', line=dict(width=1), name='conf_upper'))
303
- pacf_fig.add_trace(go.Scatter(x=lags_p, y=lowerp, mode='lines', line=dict(width=1), name='conf_lower'))
304
- pacf_fig.update_layout(title=f'{title_prefix} PACF', xaxis_title='lag')
305
- return acf_fig, pacf_fig
306
-
307
-
308
- # ---------------- Report generation (3.8 helpers) ----------------
309
- def generate_html_report(
310
- df: pd.DataFrame,
311
- target: str,
312
- features: List[str],
313
- params: Dict,
314
- figs: Dict[str, any],
315
- tables: Dict[str, pd.DataFrame]
316
- ) -> str:
317
- parts = []
318
- parts.append(f"<h1>Отчёт по временным рядам — target: {target}</h1>")
319
- parts.append(f"<p>Параметры: {params}</p>")
320
-
321
- # include time series fig
322
- if 'series' in figs:
323
- parts.append('<h2>Временной ряд</h2>')
324
- parts.append(figs['series'].to_html(full_html=False, include_plotlyjs='cdn'))
325
-
326
- if 'decomp' in figs:
327
- parts.append('<h2>Декомпозиция</h2>')
328
- parts.append(figs['decomp_observed'].to_html(full_html=False, include_plotlyjs='cdn'))
329
- parts.append(figs['decomp_trend'].to_html(full_html=False, include_plotlyjs='cdn'))
330
- parts.append(figs['decomp_seasonal'].to_html(full_html=False, include_plotlyjs='cdn'))
331
- parts.append(figs['decomp_resid'].to_html(full_html=False, include_plotlyjs='cdn'))
332
-
333
- if 'corr' in figs:
334
- parts.append('<h2>Матрица корреляций</h2>')
335
- parts.append(figs['corr'].to_html(full_html=False, include_plotlyjs='cdn'))
336
-
337
- if 'acf' in figs and 'pacf' in figs:
338
- parts.append('<h2>ACF / PACF</h2>')
339
- parts.append(figs['acf'].to_html(full_html=False, include_plotlyjs='cdn'))
340
- parts.append(figs['pacf'].to_html(full_html=False, include_plotlyjs='cdn'))
341
-
342
- # tables
343
- for name, table in tables.items():
344
- parts.append(f'<h3>{name}</h3>')
345
- parts.append(table.to_html(classes="table table-striped", index=True))
346
-
347
- html = '<html><head><meta charset="utf-8"></head><body>' + ''.join(parts) + '</body></html>'
348
- return html
349
-
350
-
351
- # ---------------- Streamlit UI ----------------
352
- st.title("Временные ряды — предобработка, EDA, стационарность, лаги, ACF/PACF, декомпозиция и экспорт (3.2–3.8)")
353
-
354
- # Sidebar
355
- st.sidebar.header("Настройки")
356
- uploaded_file = st.sidebar.file_uploader("Загрузите CSV/Parquet", type=['csv', 'parquet'])
357
-
358
- # small built-in example option (uses local file if present)
359
- sample_option = None
360
- if os.path.exists('russia_covid_dataset.csv'):
361
- sample_option = 'russia_covid_dataset.csv'
362
- sample_choice = st.sidebar.selectbox('Или выбрать предзагруженный пример', options=[None, sample_option] if sample_option else [None])
363
-
364
- tz_assume = st.sidebar.selectbox("Как трактовать tz-naive метки?",
365
- options=['local', 'utc', 'keep'], index=0,
366
- format_func=lambda x: {'local': 'локально (Europe/Moscow)', 'utc': 'UTC->Moscow', 'keep': 'не трогать'}[x])
367
- numeric_missing_strategy = st.sidebar.selectbox("Заполнение пропусков (числ.)", options=['interpolate', 'drop', 'rolling'], index=0)
368
- cat_missing_strategy = st.sidebar.selectbox("Заполнение пропусков (категор.)", options=['mode', 'unknown'], index=0)
369
- outlier_strategy = st.sidebar.selectbox("Обработка выбросов", options=['interpolate', 'winsorize', 'drop', 'mark'], index=0)
370
- resample_freq = st.sidebar.selectbox("Ресемплить к частоте (если нужно)", options=[None, 'D', 'W', 'M'], index=1)
371
-
372
- # load dataset and persist
373
- if 'df_in' not in st.session_state:
374
- st.session_state['df_in'] = None
375
-
376
- if uploaded_file is not None:
377
- try:
378
- if uploaded_file.name.endswith('.parquet'):
379
- df_in = pd.read_parquet(uploaded_file)
380
- else:
381
- df_in = pd.read_csv(uploaded_file, low_memory=False)
382
- st.session_state['df_in'] = df_in
383
- st.success(f"Загружен файл: {uploaded_file.name} ({df_in.shape[0]}×{df_in.shape[1]})")
384
- except Exception as e:
385
- st.error(f"Ошибка загрузки: {e}")
386
- st.stop()
387
- elif sample_choice:
388
- st.session_state['df_in'] = pd.read_csv(sample_choice, low_memory=False)
389
- st.info(f"Выбран пример: {sample_choice}")
390
- else:
391
- local_path = 'russia_covid_dataset.csv'
392
- if st.session_state['df_in'] is None and os.path.exists(local_path):
393
- st.session_state['df_in'] = pd.read_csv(local_path, low_memory=False)
394
- st.info(f"Авто-загружен локальный файл {local_path}")
395
- elif st.session_state['df_in'] is None:
396
- st.info("Загрузите файл или поместите russia_covid_dataset.csv в рабочую папку.")
397
- st.stop()
398
-
399
-
400
- df_in = st.session_state['df_in']
401
- st.subheader("Preview входного датасета")
402
- st.dataframe(df_in.head(8))
403
-
404
- # detect date column
405
- detected = detect_date_column(df_in)
406
- col_for_date = st.text_input("Колонка с временной меткой", value=detected if detected else "")
407
- if not col_for_date:
408
- st.error("Укажите колонку с временной меткой.")
409
- st.stop()
410
-
411
- # Run buttons
412
- col1, col2 = st.columns([1, 1])
413
- with col1:
414
- run_btn = st.button("Run Preprocessing")
415
- with col2:
416
- force_btn = st.button("Force Recompute (пересчитать)")
417
-
418
- # session keys
419
- st.session_state.setdefault('preprocessed', False)
420
- st.session_state.setdefault('df_clean', None)
421
- st.session_state.setdefault('info', {})
422
- st.session_state.setdefault('df_lags', None)
423
-
424
- if run_btn or force_btn or (not st.session_state['preprocessed'] and st.session_state['df_clean'] is None):
425
- df_clean, info = preprocess_timeseries(
426
- df_in,
427
- date_col=col_for_date,
428
- tz_assume=tz_assume,
429
- numeric_missing_strategy=numeric_missing_strategy,
430
- cat_missing_strategy=cat_missing_strategy,
431
- outlier_strategy=outlier_strategy,
432
- resample_freq=resample_freq,
433
- )
434
- st.session_state['df_clean'] = df_clean
435
- st.session_state['info'] = info
436
- st.session_state['preprocessed'] = True
437
-
438
- # Main UI after preprocess
439
- if st.session_state.get('preprocessed'):
440
- df_clean = st.session_state['df_clean']
441
- info = st.session_state['info']
442
-
443
- st.subheader("Финальный датасет (первые строки)")
444
- st.dataframe(df_clean.head(10))
445
- st.markdown(f"**Размер до/после:** {df_in.shape} → {info.get('final_shape')}")
446
- st.markdown(f"**Доля распарсенных дат:** {info.get('parse_success', 0):.2%}")
447
- st.markdown(f"**Удалено строк без даты:** {info.get('dropped_no_timestamp', 0)}")
448
-
449
- st.download_button("Скачать final_dataset.csv", data=df_clean.to_csv(index=False).encode('utf-8'), file_name='final_dataset.csv', mime='text/csv')
450
-
451
- # 3.3 Descriptive
452
- st.header("Этап 3.3 — Описательная статистика и визуализация")
453
- numeric_cols = df_clean.select_dtypes(include=[np.number]).columns.tolist()
454
- if not numeric_cols:
455
- st.warning("Нет числовых колонок для анализа.")
456
- else:
457
- stats_df = descriptive_statistics(df_clean, numeric_cols)
458
- st.subheader("Дескриптивная статистика")
459
- st.dataframe(stats_df)
460
-
461
- st.subheader("Гистограммы / Boxplot / Pairwise")
462
- sel = st.multiselect("Выбрать колонки для графиков", numeric_cols, default=numeric_cols[:3])
463
- for c in sel:
464
- c1, c2 = st.columns(2)
465
- with c1:
466
- fig = px.histogram(df_clean, x=c, nbins=60, title=f'Histogram: {c}')
467
- st.plotly_chart(fig, use_container_width=True)
468
- with c2:
469
- figb = go.Figure()
470
- figb.add_trace(go.Box(y=df_clean[c], name=c))
471
- st.plotly_chart(figb, use_container_width=True)
472
-
473
- if len(sel) >= 2:
474
- st.subheader("Scatter matrix")
475
- figm = px.scatter_matrix(df_clean, dimensions=sel[:6], title='Scatter matrix (часть признаков)')
476
- st.plotly_chart(figm, use_container_width=True)
477
-
478
- st.subheader("Матрица корреляций")
479
- corr_method = st.selectbox("Тип корреляции", options=['pearson', 'spearman'], index=0)
480
- corr = df_clean[numeric_cols].corr(method=corr_method)
481
- figc = px.imshow(corr, text_auto=True, title=f'Correlation ({corr_method})')
482
- st.plotly_chart(figc, use_container_width=True)
483
-
484
- # 3.4 Stationarity
485
- st.header("Этап 3.4 — Проверка на стационарность (ADF/KPSS) и визуальная диагностика")
486
- if not numeric_cols:
487
- st.info("Нет числовых колонок для тестов.")
488
- else:
489
- station_target = st.selectbox("Выберите колонку для тестов", options=numeric_cols, index=0, key='station_target')
490
- window1 = st.number_input("Окно rolling mean/std (точки)", min_value=3, max_value=365, value=30)
491
- s = df_clean.set_index('timestamp')[station_target].dropna()
492
- fig = go.Figure()
493
- fig.add_trace(go.Scatter(x=s.index, y=s.values, name='series'))
494
- roll_mean = s.rolling(window=window1, min_periods=1).mean()
495
- roll_std = s.rolling(window=window1, min_periods=1).std()
496
- fig.add_trace(go.Scatter(x=roll_mean.index, y=roll_mean.values, name=f'rolling_mean_{window1}'))
497
- fig.update_layout(title=f'Series & rolling mean ({station_target})', height=400)
498
- st.plotly_chart(fig, use_container_width=True)
499
- fig2 = go.Figure()
500
- fig2.add_trace(go.Scatter(x=roll_std.index, y=roll_std.values, name=f'rolling_std_{window1}'))
501
- fig2.update_layout(title=f'Rolling std ({station_target})', height=300)
502
- st.plotly_chart(fig2, use_container_width=True)
503
-
504
- if st.button("Run stationarity tests"):
505
- adf_res = run_adf(s)
506
- kpss_res = run_kpss(s)
507
- alpha = 0.05
508
- adf_stationary = ('pvalue' in adf_res) and (adf_res['pvalue'] < alpha)
509
- kpss_stationary = ('pvalue' in kpss_res) and (kpss_res['pvalue'] > alpha)
510
- st.subheader("Результаты тестов")
511
- st.write("ADF:", adf_res)
512
- st.write("KPSS:", kpss_res)
513
- st.markdown(f"Интерпретация при α={alpha}: ")
514
- st.write(f"- ADF говорит, что ряд {'стационарен' if adf_stationary else 'НЕ стационарен'} (p={adf_res.get('pvalue','?')})")
515
- st.write(f"- KPSS говорит, что ряд {'стационарен' if kpss_stationary else 'НЕ стационарен'} (p={kpss_res.get('pvalue','?')})")
516
- if adf_stationary and kpss_stationary:
517
- st.success("Оба теста согласны: ряд, скорее всего, стационарен.")
518
- elif (not adf_stationary) and (not kpss_stationary):
519
- st.warning("Оба теста указывают на нестационарность → рекомендуем дифференцирование / детренд / лог-трансформацию.")
520
- else:
521
- st.info("Тесты противоречат друг другу — смотрите графики rolling mean/std и пробуйте трансформации (log/diff).")
522
-
523
- st.subheader("Применить дифференцирование и повторить тесты")
524
- diff_order = st.number_input("Порядок дифференцирования (целое >=1)", min_value=1, max_value=5, value=1, step=1)
525
- if st.button("Apply diff & Re-test"):
526
- s_diff = s.diff(periods=diff_order).dropna()
527
- adf_res = run_adf(s_diff)
528
- kpss_res = run_kpss(s_diff)
529
- st.write(f"Результаты для {diff_order}-го диффа:")
530
- st.write("ADF:", adf_res)
531
- st.write("KPSS:", kpss_res)
532
- figd = px.line(x=s_diff.index, y=s_diff.values, title=f'Differenced series (order={diff_order})')
533
- st.plotly_chart(figd, use_container_width=True)
534
- if st.checkbox("Сохранить дифференцированный ряд в session (переопределит final_dataset)", value=False):
535
- df_store = df_clean.copy()
536
- df_store[station_target] = df_store[station_target].diff(periods=diff_order)
537
- df_store = df_store.dropna(subset=[station_target]).reset_index(drop=True)
538
- st.session_state['df_clean'] = df_store
539
- st.success("Дифференцированный ряд сохранён в final_dataset (session).")
540
-
541
- # 3.5 Lag & Rolling features
542
- st.header("Этап 3.5 — Создание лагов и скользящих статистик")
543
- if not numeric_cols:
544
- st.info("Нет числовых колонок для создания лагов.")
545
- else:
546
- st.subheader("Параметры генерации лагов/скользящих")
547
- target_col = st.selectbox("Выберите целевую колонку (target)", options=numeric_cols, index=0, key='lag_target')
548
- default_lags = st.text_input("Список лагов через запятую (напр. 1,7,30)", value='1,7,30')
549
- default_rolls = st.text_input("Список окон для скользящих через запятую (напр. 7,30)", value='7,30')
550
- extra_feats_raw = st.text_input("Доп. признаки для лагов (через запятую), необязательно", value='')
551
-
552
- try:
553
- lags = [int(x.strip()) for x in default_lags.split(',') if x.strip()]
554
- except Exception:
555
- lags = [1, 7, 30]
556
- try:
557
- rolls = [int(x.strip()) for x in default_rolls.split(',') if x.strip()]
558
- except Exception:
559
- rolls = [7, 30]
560
- extra_feats = [x.strip() for x in extra_feats_raw.split(',') if x.strip()]
561
- extra_feats = [f for f in extra_feats if f in df_clean.columns]
562
-
563
- if st.button('Generate lags & rolls'):
564
- df_lags = create_lags_and_rolls(df_clean, target_col, lags, rolls, extra_features=extra_feats)
565
- st.session_state['df_lags'] = df_lags
566
- st.success(f'Создан датасет с лагами: shape={df_lags.shape}')
567
-
568
- if st.session_state.get('df_lags') is not None:
569
- df_lags = st.session_state['df_lags']
570
- st.subheader('Первые строки с лагами')
571
- st.dataframe(df_lags.head(10))
572
-
573
- st.subheader('Корреляция лагов с target')
574
- corr_lags = compute_lag_correlations(df_lags, target_col, lags)
575
- st.dataframe(corr_lags)
576
-
577
- st.subheader('Heatmap корреляций (лаги + target + дополнительные фичи)')
578
- lag_cols = [f'{target_col}_lag_{l}' for l in lags if f'{target_col}_lag_{l}' in df_lags.columns]
579
- numeric_subset = [target_col] + lag_cols + [c for c in extra_feats if c in df_lags.select_dtypes(include=[np.number]).columns]
580
- if len(numeric_subset) >= 2:
581
- corr2 = df_lags[numeric_subset].corr()
582
- figh = px.imshow(corr2, text_auto=True, title='Lag correlations heatmap')
583
- st.plotly_chart(figh, use_container_width=True)
584
-
585
- st.subheader('Проверка мультиколлинеарности (VIF) для признаков с лагами')
586
- candidate_feats = st.multiselect('Выберите признаки для VIF (по умолчанию lag-колонки)', options=numeric_subset, default=lag_cols)
587
- if candidate_feats:
588
- vif_df = compute_vif(df_lags, candidate_feats)
589
- st.dataframe(vif_df)
590
-
591
- st.download_button('Скачать датасет с лагами (CSV)', data=df_lags.to_csv(index=False).encode('utf-8'), file_name='dataset_with_lags.csv', mime='text/csv')
592
-
593
- if st.checkbox('Сохранить датасет с лагами в session (df_clean <- df_lags конвертировать)', value=False):
594
- st.session_state['df_clean'] = df_lags
595
- st.success('final_dataset в session заменён на датасет с лагами.')
596
-
597
- # 3.6 ACF / PACF
598
- st.header("Этап 3.6 — Анализ автокорреляции: ACF и PACF")
599
- if not numeric_cols:
600
- st.info('Нет числовых колонок для ACF/PACF.')
601
- else:
602
- acf_target = st.selectbox('Выберите колонку для ACF/PACF', options=numeric_cols, index=0, key='acf_target')
603
- max_lag = st.number_input('Максимальный лаг (nlags)', min_value=10, max_value=500, value=40, step=1)
604
- alpha = st.slider('Уровень значимости для доверительного интервала (alpha)', min_value=0.01, max_value=0.2, value=0.05, step=0.01)
605
-
606
- s_acf = df_clean.set_index('timestamp')[acf_target].dropna()
607
- if len(s_acf) < 2:
608
- st.warning('Недостаточно наблюдений для ACF/PACF.')
609
- else:
610
- try:
611
- acf_vals, acf_conf, pacf_vals, pacf_conf = get_acf_pacf_with_conf(s_acf, nlags=int(max_lag), alpha=float(alpha))
612
- except Exception as e:
613
- st.error(f'Ошибка при вычислении ACF/PACF: {e}')
614
- acf_vals = pacf_vals = np.array([])
615
- acf_conf = pacf_conf = np.array([])
616
-
617
- fig_acf = plt.figure(figsize=(10, 4))
618
- plot_acf(s_acf.values, lags=int(max_lag), alpha=alpha, zero=True, title=f'ACF: {acf_target}', ax=fig_acf.gca())
619
- st.pyplot(fig_acf)
620
-
621
- fig_pacf = plt.figure(figsize=(10, 4))
622
- plot_pacf(s_acf.values, lags=int(max_lag), alpha=alpha, method='ywm', title=f'PACF: {acf_target}', ax=fig_pacf.gca())
623
- st.pyplot(fig_pacf)
624
-
625
- sig_acf = significant_lags_from_conf(acf_vals, acf_conf) if acf_vals.size else []
626
- sig_pacf = significant_lags_from_conf(pacf_vals, pacf_conf) if pacf_vals.size else []
627
-
628
- st.subheader('Статистически значимые лаги (по доверительным интервалам)')
629
- st.write('ACF значимые лаги:', sig_acf)
630
- st.write('PACF значимые лаги:', sig_pacf)
631
-
632
- acf_rows = []
633
- for i in range(min(len(acf_vals), int(max_lag) + 1)):
634
- lower, upper = acf_conf[i] if acf_conf.size else (None, None)
635
- 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})
636
- pacf_rows = []
637
- for i in range(min(len(pacf_vals), int(max_lag) + 1)):
638
- lower, upper = pacf_conf[i] if pacf_conf.size else (None, None)
639
- 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})
640
-
641
- st.subheader('ACF values (таблица)')
642
- st.dataframe(pd.DataFrame(acf_rows).set_index('lag'))
643
- st.subheader('PACF values (таблица)')
644
- st.dataframe(pd.DataFrame(pacf_rows).set_index('lag'))
645
-
646
- st.markdown('**Интерпретация (упрощённо):** - Резкий обрыв в PACF на лаге p → возможный порядок AR(p). - Плавное затухание в ACF → возможный порядок MA(q). - Лаги, выходящие за доверительный интервал — статистически значимы.')
647
-
648
- # 3.7 Decomposition
649
- st.header("Этап 3.7 — Декомпозиция временного ряда")
650
- if not numeric_cols:
651
- st.info('Нет числовых колонок для декомпозиции.')
652
- else:
653
- decomp_target = st.selectbox('Выберите колонку для декомпозиции', options=numeric_cols, index=0, key='decomp_target')
654
- model_choice = st.radio('Модель декомпозиции', options=['additive', 'multiplicative'], index=0)
655
- period_option = st.selectbox('Период сезонности (если известен)', options=['auto', '7', '30', '365', 'custom'], index=0)
656
- custom_period = None
657
- if period_option == 'custom':
658
- custom_period = st.number_input('Введите период (целое >1)', min_value=2, value=30, step=1)
659
- if period_option == 'auto':
660
- inferred = None
661
- try:
662
- tmp = df_clean.set_index('timestamp')[decomp_target].dropna()
663
- inferred_freq = pd.infer_freq(tmp.index)
664
- if inferred_freq in ('D', 'B'):
665
- suggested = 7
666
- elif inferred_freq == 'W':
667
- suggested = 52
668
- else:
669
- suggested = None
670
- inferred = suggested
671
- except Exception:
672
- inferred = None
673
- else:
674
- inferred = int(period_option) if period_option in ('7', '30', '365') else None
675
- period = custom_period if custom_period is not None else inferred
676
-
677
- st.write(f'Выбранная модель: {model_choice}. Период: {period if period is not None else "не задан (нужен для правильной декомпозиции)"}.')
678
-
679
- if st.button('Run decomposition'):
680
- s = df_clean.set_index('timestamp')[decomp_target].dropna()
681
- if period is None:
682
- st.error('Период не определён. Укажите период (например 7 для недельной сезонности) или используйте custom.')
683
- elif len(s) < period * 2:
684
- st.error(f'Недостаточно точек для надёжной декомпозиции при периоде={period}. Нужно >= 2*period наблюдений. У вас {len(s)}.')
685
- else:
686
- try:
687
- decomp = seasonal_decompose(s, period=int(period), model=model_choice, extrapolate_trend='freq')
688
- st.session_state['decomp'] = decomp
689
- comp_df = pd.DataFrame({'timestamp': s.index, 'observed': decomp.observed, 'trend': decomp.trend, 'seasonal': decomp.seasonal, 'resid': decomp.resid}).reset_index(drop=True)
690
- st.session_state['decomp_df'] = comp_df
691
-
692
- st.subheader('Графики компонентов')
693
- st.plotly_chart(px.line(comp_df, x='timestamp', y='observed', title='Observed'), use_container_width=True)
694
- st.plotly_chart(px.line(comp_df, x='timestamp', y='trend', title='Trend'), use_container_width=True)
695
- st.plotly_chart(px.line(comp_df, x='timestamp', y='seasonal', title='Seasonal'), use_container_width=True)
696
- st.plotly_chart(px.line(comp_df, x='timestamp', y='resid', title='Residuals'), use_container_width=True)
697
-
698
- st.success('Декомпозиция выполнена и сохранена в сессии (decomp, decomp_df).')
699
-
700
- st.subheader('Анализ компонентов')
701
- trend_nonnull = comp_df['trend'].dropna()
702
- if len(trend_nonnull) > 2:
703
- xnum = np.arange(len(trend_nonnull))
704
- coef = np.polyfit(xnum, trend_nonnull.values, 1)
705
- slope = coef[0]
706
- st.write(f'- Приблизительный линейный наклон тренда: {slope:.6f} ({"вырос" if slope>0 else "упал"}).')
707
- else:
708
- st.write('- Слишком мало данных в компоненте trend для оценки наклона.')
709
-
710
- seasonal = comp_df['seasonal'].dropna()
711
- if not seasonal.empty:
712
- amp = seasonal.max() - seasonal.min()
713
- st.write(f'- Амплитуда сезонной компоненты: {amp:.4f} (max={seasonal.max():.4f}, min={seasonal.min():.4f}).')
714
-
715
- resid = comp_df['resid'].dropna()
716
- st.subheader('Диагностика остатков')
717
- st.write(f'- Длина остатков: {len(resid)}')
718
- if len(resid) > 3:
719
- adf_r = run_adf(resid)
720
- kpss_r = run_kpss(resid)
721
- st.write('ADF (resid):', adf_r)
722
- st.write('KPSS (resid):', kpss_r)
723
- a_stat = ('pvalue' in adf_r) and (adf_r['pvalue'] < 0.05)
724
- k_stat = ('pvalue' in kpss_r) and (kpss_r['pvalue'] > 0.05)
725
- if a_stat and k_stat:
726
- st.success('Остатки выглядят стационарными по ADF и KPSS — декомпозиция адекватна.')
727
- else:
728
- st.warning('Остатки, возможно, нестационарны. Посмотрите на график остатков и подумайте о дополнительных преобразованиях или изменении периода/модели.')
729
- else:
730
- st.info('Недостаточно данных для тестов остатков.')
731
-
732
- st.download_button('Скачать компоненты (CSV)', data=comp_df.to_csv(index=False).encode('utf-8'), file_name='decomposition_components.csv', mime='text/csv')
733
-
734
- except Exception as e:
735
- st.error(f'Ошибка при декомпозиции: {e}')
736
-
737
- st.info('Этап 3.7 завершён. Дальше можно делать ACF/PACF на остатках, моделирование или формирование отчёта.')
738
-
739
- # ---------------- 3.8 Web interface & report export ----------------
740
- st.header('Этап 3.8 — Веб-интерфейс, конфигурация и экспорт отчёта')
741
- st.markdown('Здесь собраны управляющие элементы для быстрой генерации HTML-отчёта и экспорта результатов. Отчёт включает: график ряда, скользящее среднее, матрицу корреляций, ACF/PACF и декомпозицию.')
742
-
743
- # Unified controls
744
- with st.expander('Параметры для отчёта'):
745
- report_target = st.selectbox('Target для отчёта', options=numeric_cols, index=0)
746
- report_features = st.multiselect('Доп. признаки для отчёта (включаются в корреляции)', options=numeric_cols, default=[c for c in numeric_cols if c != report_target][:2])
747
- report_roll = st.number_input('Окно для скользящего среднего в отчёте', min_value=2, max_value=365, value=30)
748
- report_acf_lags = st.number_input('nlags для ACF/PACF в отчёте', min_value=10, max_value=500, value=40)
749
- report_period = st.selectbox('Период для декомпозиции в отчёте', options=[None, 7, 30, 365], index=1)
750
-
751
- if st.button('Сгенерировать и показать отчёт (вкладки ниже)'):
752
- # prepare figures
753
- figs = {}
754
- # time series with rolling
755
- s = df_clean.set_index('timestamp')[report_target].dropna()
756
- fig_series = go.Figure()
757
- fig_series.add_trace(go.Scatter(x=s.index, y=s.values, mode='lines', name='observed'))
758
- 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}'))
759
- fig_series.update_layout(title=f'Series: {report_target}', height=350)
760
- figs['series'] = fig_series
761
-
762
- # corr
763
- corr_cols = [report_target] + report_features
764
- corr_df = df_clean[corr_cols].corr()
765
- figs['corr'] = px.imshow(corr_df, text_auto=True, title='Correlation matrix')
766
-
767
- # decomposition (if available)
768
- if 'decomp_df' in st.session_state:
769
- comp_df = st.session_state['decomp_df']
770
- figs['decomp_observed'] = px.line(comp_df, x='timestamp', y='observed', title='Observed')
771
- figs['decomp_trend'] = px.line(comp_df, x='timestamp', y='trend', title='Trend')
772
- figs['decomp_seasonal'] = px.line(comp_df, x='timestamp', y='seasonal', title='Seasonal')
773
- figs['decomp_resid'] = px.line(comp_df, x='timestamp', y='resid', title='Residuals')
774
- else:
775
- figs['decomp_observed'] = figs['decomp_trend'] = figs['decomp_seasonal'] = figs['decomp_resid'] = None
776
-
777
- # acf/pacf (plotly version)
778
- try:
779
- acf_vals, acf_conf, pacf_vals, pacf_conf = get_acf_pacf_with_conf(s, nlags=int(report_acf_lags), alpha=0.05)
780
- 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)
781
- figs['acf'] = acf_fig
782
- figs['pacf'] = pacf_fig
783
- except Exception:
784
- figs['acf'] = figs['pacf'] = None
785
-
786
- # tables
787
- tables = {'Descriptive': descriptive_statistics(df_clean, corr_cols), 'Correlation': corr_df}
788
-
789
- # show in tabs
790
- tab1, tab2, tab3 = st.tabs(['Графики', 'Таблицы', 'Экспорт'])
791
- with tab1:
792
- st.subheader('Временной ряд и rolling')
793
- st.plotly_chart(figs['series'], use_container_width=True)
794
- st.subheader('Матрица корреляций')
795
- st.plotly_chart(figs['corr'], use_container_width=True)
796
- if figs.get('decomp_observed') is not None:
797
- st.subheader('Декомпозиция')
798
- st.plotly_chart(figs['decomp_observed'], use_container_width=True)
799
- st.plotly_chart(figs['decomp_trend'], use_container_width=True)
800
- st.plotly_chart(figs['decomp_seasonal'], use_container_width=True)
801
- st.plotly_chart(figs['decomp_resid'], use_container_width=True)
802
- if figs.get('acf') is not None:
803
- st.subheader('ACF / PACF')
804
- st.plotly_chart(figs['acf'], use_container_width=True)
805
- st.plotly_chart(figs['pacf'], use_container_width=True)
806
-
807
- with tab2:
808
- st.subheader('Таблицы')
809
- for name, table in tables.items():
810
- st.write(name)
811
- st.dataframe(table)
812
-
813
- with tab3:
814
- st.subheader('Экспорт отчёта')
815
- params = {'roll': int(report_roll), 'acf_lags': int(report_acf_lags), 'period': report_period}
816
- html = generate_html_report(df_clean, report_target, report_features, params, figs, tables)
817
- html_bytes = html.encode('utf-8')
818
- st.download_button('Скачать HTML-отчёт', data=html_bytes, file_name='ts_report.html', mime='text/html')
819
-
820
- # try PDF (if pdfkit available)
821
- try:
822
- import pdfkit
823
-
824
- # Попытка конвертировать HTML в PDF (требует установленного wkhtmltopdf)
825
- pdf_bytes = pdfkit.from_string(html, False)
826
- st.download_button('Скачать PDF-отчёт', data=pdf_bytes, file_name='ts_report.pdf',
827
- mime='application/pdf')
828
- except Exception:
829
- st.info(
830
- 'PDF-конверсия недоступна (pdfkit/wkhtmltopdf не установлены). Скачайте HTML и конвертируйте локально, если нужно.')
831
-
832
- st.info(
833
- 'Этап 3.8 завершён — приложение покрывает 3.2–3.8. Проверьте, всё ли работает локально и пришлите ошибки, если будут.')