global #2

Merged
dan merged 3 commits from global into main 2025-12-16 02:06:06 +03:00
121 changed files with 5644 additions and 3802 deletions

View File

@@ -1,9 +1,74 @@
# dano2025
# FinalTry.exe — исследование коммуникаций (2025/2026)
dano 2025/2026 solve by FinalTry.exe team
## 1. Описание проекта
- Исследовательский репозиторий команды FinalTry.exe.
- Задача: изучить связь между коммуникациями пользователей и их заказами, проверить гипотезы о форме зависимости (тренды, квадратика), посмотреть по категориям.
- Результат: собранная SQLite-база, набор статичных и интерактивных графиков, сравнения по категориям/total.
## Dataset migrations
- Запуск всех миграций: `python migrate.py`
- Посмотреть список и статус: `python migrate.py --list`
- Принудительно переисполнить уже примененные миграции: `python migrate.py --force`
- По умолчанию миграции работают с `dataset/ds.csv` и создают SQLite базу `dataset/ds.sqlite` (таблица `communications`).
## 2. Структура репозитория
- `dataset/` — исходный CSV (`ds.csv`) и собранный SQLite (`ds.sqlite`, таблица `communications`).
- `migrations/` + `migrate.py` — пошаговая сборка данных: CSV → SQLite, правка значений заказов.
- `preanalysis/` — утилиты EDA (нормализация, агрегаты по дням и клиентам).
- `main_hypot/` — основной пайплайн: подготовка клиентских фичей, облака, тренды, квадратичные регрессии, интерактивные графики.
- `new_divided_scatters.py` — интерактивные облака для активных/пассивных/общих показов.
- `old_data/` — архивный подвал с ранними скриптами/данными; не входит в основной пайплайн, оставлен для истории и воспроизводимости.
## 3. Подготовка данных
```bash
# 1) положите исходник
dataset/ds.csv
# 2) соберите SQLite
python migrate.py
# или вручную:
python migrations/0001_csv_to_sqlite.py
python migrations/0002_cap_orders_to_one.py
```
Итог: `dataset/ds.sqlite` с таблицей `communications`.
## 4. Установка зависимостей
Обязательные пакеты:
- pandas
- numpy
- scipy
- statsmodels
- scikit-learn
- matplotlib
- seaborn
- altair
- vl-convert-python *(для сохранения Altair в HTML; альтернатива — `altair_saver` + установленный node)*
## 5. Запуск и работа
- Миграции:
```bash
python migrate.py # прогнать все шаги
python migrate.py --list # посмотреть статус
```
- Базовые облака и тренды (PNG):
```bash
python main_hypot/best_model_and_plots.py
```
Результаты: `main_hypot/orders_amt_total/`.
- Общая квадратичная регрессия (PNG):
```bash
python main_hypot/quadreg.py
```
Результаты: `main_hypot/orders_amt_total/`.
- Категорийные корреляции и квадратика (PNG):
```bash
python main_hypot/category_quadreg.py
```
Результаты: `main_hypot/category_analysis/`.
- Интерактивные графики Altair (HTML):
```bash
python main_hypot/new_plots.py
python new_divided_scatters.py
```
Результаты: `main_hypot/new_plots/` и `new_plots/final_result/`.
## 6. Примечания
- Все скрипты опираются на структуру `dataset/ds.sqlite`; без миграций данные не загрузятся.
- Если в `ds.csv` нет ожидаемых колонок или файл пустой, миграции упадут.
- Altair требует `vl-convert-python` (или node + altair_saver) для `Chart.save`.
- Савицкий–Голай окна по умолчанию большие (≈501); на малых выборках стоит снижать окно, иначе будет ошибка.
- Проект исследовательский: код может быть не оптимизирован под продакшн, а `old_data/` — лишь исторический архив.

View File

@@ -1,53 +0,0 @@
Коммуникации в Городе
Город в Т-Банке — это группа сервисов, которые помогают пользователям решать
ежедневные задачи: купить билеты в кино, заказать продукты, путешествовать.
В банке есть множество видов коммуникаций с клиентами. Первое разделение —
на внутренние и внешние. Внешние демонстрируются за пределами банка: например,
реклама в интернете, на телевидении и т. п. Внутренние — в приложении банка:
например, пуши, баннеры и т. д. Причем внутренние делятся еще на активные
и пассивные каналы. К активным каналам относятся пуши, СМС, имейлы, чаты;
к пассивным — баннеры, сторис и фулскрины.
В текущем датасете вам дано число коммуникаций в разбивке на активные и пассивные
и то, как клиенты реагировали на них: открыли, совершили покупку или просто получили.
Учтите, что клиент мог сделать заказ не только день в день коммуникации, но и позже.
Это в большей степени касается тяжелых сервисов, например путешествий.
Описание переменных
id уникальный идентификатор клиента
business_dt Дата
active_imp_ent Показы активных коммуникаций (Развлечения)
active_click_ent Касания активных коммуникаций (Развлечения)
active_imp_super Показы активных коммуникаций (Супермаркеты)
active_click_super Касания активных коммуникаций (Супермаркеты)
active_imp_transport Показы активных коммуникаций (Транспорт)
active_click_transport Касания активных коммуникаций (Транспорт)
active_imp_shopping Показы активных коммуникаций (Шопинг)
active_click_shopping Касания активных коммуникаций (Шопинг)
active_imp_hotel Показы активных коммуникаций (Отели)
active_click_hotel Касания активных коммуникаций (Отели)
active_imp_avia Показы активных коммуникаций (Авиабилеты)
active_click_avia Касания активных коммуникаций (Авиабилеты)
passive_imp_ent Показы пассивных коммуникаций (Развлечения)
passive_click_ent Касания пассивных коммуникаций (Развлечения)
passive_imp_super Показы пассивных коммуникаций (Супермаркеты)
passive_click_super Касания пассивных коммуникаций (Супермаркеты)
passive_imp_transport Показы пассивных коммуникаций (Транспорт)
passive_click_transport
Касания пассивных коммуникаций (Транспорт)
passive_imp_shopping Показы пассивных коммуникаций (Шопинг)
passive_click_
shopping
Касания пассивных коммуникаций (Шопинг)
passive_imp_hotel Показы пассивных коммуникаций (Отели)
passive_click_hotel Касания пассивных коммуникаций (Отели)
passive_imp_avia Показы пассивных коммуникаций (Авиабилеты)
passive_click_avia Касания пассивных коммуникаций (Авиабилеты)
orders_amt_ent Число заказов (Развлечения)
orders_amt_super Число заказов (Супермаркеты)
orders_amt_transport Число заказов (Транспорт)
orders_amt_shopping Число заказов (Шопинг)
orders_amt_hotel Число заказов (Отели)
orders_amt_avia Число заказов (Авиабилеты)
gender_cd Пол (M/F)
age Возраст в годах
device_platform_cd Обратная связь клиента

View File

@@ -1,4 +0,0 @@
Т-Банк: коммуникации в городе
Датасет содержит данные о коммуникациях с клиентами внутри экосистемы Город Т-Банка, включая активные и пассивные каналы и реакцию пользователей на них. Для каждого клиента по дням указано количество показов, кликов и совершенных заказов по разным категориям сервисов: развлечения, транспорт, шопинг, отели, супермаркеты и авиабилеты. Клиент мог совершить покупку как в день коммуникации, так и позже, что особенно важно для сложных сервисов вроде путешествий. Дополнительно доступны демографические признаки клиентов и информация об устройстве.

View File

@@ -1,3 +1,5 @@
"""Базовый набор расчётов и графиков: загрузка клиентов, фильтрация выбросов и построение трендов/квадратики."""
import sqlite3
from pathlib import Path
import sys

View File

@@ -1,3 +1,5 @@
"""Категорийный анализ: собирает агрегаты по категориям и строит корреляции/квадратичную регрессию по заказам."""
import sqlite3
from pathlib import Path
import sys
@@ -47,6 +49,7 @@ COMBINED = {
def load_raw(db_path: Path) -> pd.DataFrame:
# Загружаем полную таблицу коммуникаций из SQLite
conn = sqlite3.connect(db_path)
df = pd.read_sql_query("select * from communications", conn, parse_dates=["business_dt"])
conn.close()
@@ -54,6 +57,7 @@ def load_raw(db_path: Path) -> pd.DataFrame:
def build_client_by_category(df: pd.DataFrame) -> pd.DataFrame:
# Агрегируем метрики по клиенту для каждой категории и считаем средние показы в день
agg_spec = {f"{col}_{cat}": "sum" for col in BASE_COLUMNS for cat in CATEGORIES}
client = (
df.groupby("id")
@@ -82,6 +86,7 @@ def add_combined_category(client: pd.DataFrame, name: str, cats: list[str]) -> p
def plot_category_correlation(client: pd.DataFrame, cat: str, out_dir: Path) -> None:
# Быстрая тепловая карта корреляций для одной категории
cols = [f"{base}_{cat}" for base in BASE_COLUMNS]
corr = client[cols].corr()
@@ -190,6 +195,7 @@ def plot_quad_for_category(
q_high_overrides: dict | None = None,
iqr_overrides: dict | None = None,
) -> None:
# Строим облако, тренд и квадратичную регрессию для конкретной категории с опциональными настройками
y_col = f"orders_amt_{cat}"
x_col = f"avg_imp_per_day_{cat}"
out_dir = base_out_dir / y_col

477
main_hypot/new_plots.py Normal file
View File

@@ -0,0 +1,477 @@
from __future__ import annotations
"""Генерация интерактивных Altair-графиков на базе клиентских и категорийных агрегатов."""
from pathlib import Path
import sys
from typing import Dict, Iterable, Optional, Tuple
import altair as alt
import numpy as np
import pandas as pd
import statsmodels.api as sm
from sklearn.metrics import roc_auc_score, r2_score
PROJECT_ROOT = Path(__file__).resolve().parent
sys.path.append(str(PROJECT_ROOT / "main_hypot"))
import best_model_and_plots as bmp
from category_quadreg import (
BASE_COLUMNS,
CATEGORIES,
COMBINED,
add_combined_category,
build_client_by_category,
)
OUTPUT_DIR = PROJECT_ROOT / "new_plots"
FONT_PATH = Path("/Users/dan/Downloads/AyuGram Desktop/SegoeUIVF.ttf")
def inject_font_css(html_path: Path) -> None:
"""Inject @font-face for SegoeUIVF into saved HTML if font exists."""
if not FONT_PATH.exists():
return
font_face = (
"@font-face{font-family:'Segoe UI Variable'; "
f"src: url('{FONT_PATH.as_uri()}') format('truetype'); "
"font-weight:100 900; font-style:normal;}\n"
)
css = f"<style>{font_face}body, text, .vega-bindings {{font-family:'Segoe UI Variable','Segoe UI',sans-serif;}}</style>"
html = html_path.read_text(encoding="utf-8")
if css in html:
return
if "</head>" in html:
html = html.replace("</head>", css + "\n</head>", 1)
else:
html = css + html
html_path.write_text(html, encoding="utf-8")
# Используем тематику/шрифты из примера
def configure_chart(chart: alt.Chart, title: str, width: int = 700, height: int = 500) -> alt.Chart:
# Приводим внешний вид графиков к единому стилю и шрифту
alt.theme.enable("dark")
return (
chart.properties(
title=title,
width=width,
height=height,
padding=30,
)
.configure_title(
fontSize=18,
font="Segoe UI Variable",
fontWeight=600,
anchor="start",
)
.configure_axis(
grid=True,
labelFont="Segoe UI Variable",
titleFont="Segoe UI Variable",
labelFontSize=16,
titleFontSize=18,
labelFontWeight=400,
titleFontWeight=600,
)
.configure_legend(
labelFont="Segoe UI Variable",
titleFont="Segoe UI Variable",
)
)
def prepare_client_data() -> pd.DataFrame:
"""Поднимаем агрегаты по клиентам из существующего скрипта."""
return bmp.load_client_level(bmp.DB_PATH)
def prepare_category_client_data() -> pd.DataFrame:
# Собираем клиентские показатели по категориям и добавляем комбинированные группы
raw = pd.read_sql_query("select * from communications", bmp.sqlite3.connect(bmp.DB_PATH), parse_dates=["business_dt"])
client = build_client_by_category(raw)
for combo_name, cats in COMBINED.items():
client = add_combined_category(client, combo_name, cats)
return client
def filter_and_trend(
df: pd.DataFrame,
y_col: str,
*,
x_col: str = bmp.X_COL,
x_max: float = bmp.DEFAULT_X_MAX,
y_max: float = bmp.DEFAULT_Y_MAX,
q_low: float = bmp.DEFAULT_Q_LOW,
q_high: float = bmp.DEFAULT_Q_HIGH,
iqr_k: float = bmp.DEFAULT_IQR_K,
trend_method: str = bmp.DEFAULT_TREND_METHOD,
trend_frac: float = bmp.DEFAULT_TREND_FRAC,
savgol_window: int = bmp.DEFAULT_SAVGOL_WINDOW,
) -> Tuple[pd.DataFrame, Tuple[np.ndarray, np.ndarray]]:
# Очищаем данные по IQR, обрезаем хвосты и считаем тренд для последующей регрессии
base = df[[x_col, y_col]].dropna()
in_range = bmp.filter_x_range(base, x_col, x_max)
cleaned = bmp.remove_outliers(
in_range,
y_col=y_col,
x_col=x_col,
iqr_k=iqr_k,
q_low=q_low,
q_high=q_high,
)
# Обрезаем по y_max для удобства визуализации
cleaned = cleaned[cleaned[y_col] <= y_max].copy()
tx, ty = bmp.compute_trend(
cleaned,
y_col=y_col,
x_col=x_col,
method=trend_method,
lowess_frac=trend_frac,
savgol_window=savgol_window,
)
return cleaned, (tx, ty)
def compute_density_alpha(df: pd.DataFrame, x_col: str, y_col: str, x_max: float, y_max: float) -> pd.Series:
# Пересчитываем прозрачность точек по плотности, чтобы облака читались в html
alphas = bmp.compute_density_alpha(
df,
x_col=x_col,
y_col=y_col,
x_max=x_max,
bins_x=bmp.DEFAULT_BINS_X,
bins_y=bmp.DEFAULT_BINS_Y,
alpha_min=bmp.DEFAULT_ALPHA_MIN,
alpha_max=bmp.DEFAULT_ALPHA_MAX,
y_min=bmp.DEFAULT_Y_MIN,
y_max_limit=y_max,
)
if len(alphas) == 0:
return pd.Series([bmp.DEFAULT_ALPHA] * len(df), index=df.index)
return pd.Series(alphas, index=df.index)
def fit_quadratic(
df: pd.DataFrame,
y_col: str,
trend_data: Tuple[np.ndarray, np.ndarray],
*,
x_col: str = bmp.X_COL,
x_max: float = bmp.DEFAULT_X_MAX,
force_negative_b2: bool = False,
) -> Tuple[Optional[sm.regression.linear_model.RegressionResultsWrapper], dict]:
# Фитим y ~ 1 + x + x^2 и считаем AUC/R2 по тренду, если хватило точек
if len(df) < 3:
return None, {}
x = df[x_col].to_numpy()
y = df[y_col].to_numpy()
quad_term = -x**2 if force_negative_b2 else x**2
X_design = sm.add_constant(np.column_stack([x, quad_term]))
model = sm.OLS(y, X_design).fit(cov_type="HC3")
# AUC по бинарному флагу заказа
auc = np.nan
binary = (y > 0).astype(int)
if len(np.unique(binary)) > 1:
auc = roc_auc_score(binary, model.predict(X_design))
# R2 по тренду
tx, ty = trend_data
r2_trend = np.nan
if tx is not None and len(tx) >= 3:
mask = (tx <= x_max) & ~np.isnan(ty)
tx = tx[mask]
ty = ty[mask]
if len(tx) >= 3 and np.nanvar(ty) > 0:
quad_trend = -tx**2 if force_negative_b2 else tx**2
X_trend = sm.add_constant(np.column_stack([tx, quad_trend]))
y_hat_trend = model.predict(X_trend)
r2_trend = r2_score(ty, y_hat_trend)
return model, {"auc": auc, "r2_trend": r2_trend}
def build_annotation(
params: np.ndarray,
pvals: np.ndarray,
metrics: dict,
n: int,
*,
b2_effective: Optional[float] = None,
x_pos: float = 0.5,
) -> pd.DataFrame:
# Готовим подписи с метриками для вывода на график
b2_val = b2_effective if b2_effective is not None else params[2]
lines = [
f"R2_trend={metrics.get('r2_trend', np.nan):.3f}",
f"AUC={metrics.get('auc', np.nan):.3f}",
f"b1={params[1]:.3f} (p={pvals[1]:.3g})",
f"b2={b2_val:.3f} (p={pvals[2]:.3g})",
f"n={n}",
]
return pd.DataFrame(
{
"x": [x_pos] * len(lines),
"y": [metrics.get("y_max_for_anno", 0) - i * 0.4 for i in range(len(lines))],
"label": lines,
}
)
def save_scatter_trend_quad(
df: pd.DataFrame,
y_col: str,
out_path: Path,
*,
x_col: str = bmp.X_COL,
x_max: float = bmp.DEFAULT_X_MAX,
y_max: float = bmp.DEFAULT_Y_MAX,
force_negative_b2: bool = False,
savgol_window: int = bmp.DEFAULT_SAVGOL_WINDOW,
title: str = "",
) -> None:
# Полный пайплайн: фильтрация, тренд, квадратика и сохранение HTML
cleaned, trend_data = filter_and_trend(
df,
y_col=y_col,
x_col=x_col,
x_max=x_max,
y_max=y_max,
trend_method=bmp.DEFAULT_TREND_METHOD,
trend_frac=bmp.DEFAULT_TREND_FRAC,
savgol_window=savgol_window,
)
if trend_data[0] is None:
print(f"[{y_col}] нет тренда/данных для построения")
return
cleaned = cleaned.copy()
cleaned["alpha"] = compute_density_alpha(cleaned, x_col, y_col, x_max, y_max)
model, metrics = fit_quadratic(cleaned, y_col, trend_data, x_col=x_col, x_max=x_max, force_negative_b2=force_negative_b2)
if model is None:
print(f"[{y_col}] недостаточно точек для квадрата")
return
params = model.params
pvals = model.pvalues
b2_effective = -abs(params[2]) if force_negative_b2 else params[2]
x_grid = np.linspace(0, x_max, 400)
quad_term = -x_grid**2 if force_negative_b2 else x_grid**2
quad_df = pd.DataFrame(
{
x_col: x_grid,
"quad": model.predict(sm.add_constant(np.column_stack([x_grid, quad_term]))),
}
)
trend_df = pd.DataFrame({x_col: trend_data[0], "trend": trend_data[1]})
metrics["y_max_for_anno"] = y_max * 0.95
metrics_text = [
f"R2_trend={metrics['r2_trend']:.3f}",
f"AUC={metrics['auc']:.3f}",
f"b1={params[1]:.3f} (p={pvals[1]:.3g})",
f"b2={b2_effective:.3f} (p={pvals[2]:.3g})",
f"n={len(cleaned)}",
]
x_scale = alt.Scale(domain=(0, x_max), clamp=True, nice=False, domainMin=0, domainMax=x_max)
y_scale = alt.Scale(domain=(bmp.DEFAULT_Y_MIN, y_max), clamp=True, nice=False)
points = alt.Chart(cleaned).mark_circle(size=40).encode(
x=alt.X(x_col, title="Среднее число показов в день", scale=x_scale),
y=alt.Y(y_col, title=y_col, scale=y_scale),
opacity=alt.Opacity("alpha:Q", scale=alt.Scale(domain=(0, 1), clamp=True)),
color=alt.value(bmp.DEFAULT_SCATTER_COLOR),
tooltip=[x_col, y_col],
)
trend_line = alt.Chart(trend_df).mark_line(color=bmp.DEFAULT_TREND_COLOR, strokeWidth=2.5).encode(
x=alt.X(x_col, scale=x_scale),
y=alt.Y("trend", scale=y_scale),
)
quad_line = alt.Chart(quad_df).mark_line(color="blue", strokeWidth=2.2, strokeDash=[6, 4]).encode(
x=alt.X(x_col, scale=x_scale),
y=alt.Y("quad", scale=y_scale),
)
subtitle = "".join(metrics_text)
chart = alt.layer(points, trend_line, quad_line).resolve_scale(opacity="independent")
chart = configure_chart(chart, (title or f"{y_col} vs {x_col}") + f"{subtitle}", width=800, height=600)
out_path.parent.mkdir(parents=True, exist_ok=True)
chart.save(out_path)
inject_font_css(out_path)
print(f"Saved {out_path}")
def save_correlation_heatmap(df: pd.DataFrame, cols: Iterable[str], title: str, out_path: Path) -> None:
# Отрисовываем корреляции по выбранным столбцам и сохраняем в HTML
corr = df[list(cols)].corr()
corr_long = corr.reset_index().melt(id_vars="index", var_name="col", value_name="corr")
corr_long = corr_long.rename(columns={"index": "row"})
chart = (
alt.Chart(corr_long)
.mark_rect()
.encode(
x=alt.X("col:N", title=""),
y=alt.Y("row:N", title=""),
color=alt.Color("corr:Q", scale=alt.Scale(domain=(-1, 1), scheme="redblue"), legend=alt.Legend(title="corr")),
tooltip=["row", "col", alt.Tooltip("corr:Q", format=".3f")],
)
)
chart = configure_chart(chart, title, width=400, height=400)
out_path.parent.mkdir(parents=True, exist_ok=True)
chart.save(out_path)
inject_font_css(out_path)
print(f"Saved {out_path}")
def generate_total_plots() -> None:
# Главный сценарий для общих заказов: строим облако и тренд
df = prepare_client_data()
out_base = OUTPUT_DIR / "orders_amt_total"
save_scatter_trend_quad(
df,
y_col="orders_amt_total",
out_path=out_base / "scatter_trend_quad.html",
x_max=bmp.DEFAULT_X_MAX,
y_max=bmp.DEFAULT_Y_MAX,
savgol_window=bmp.DEFAULT_SAVGOL_WINDOW,
title="Заказы vs средние показы (все клиенты)",
)
def generate_category_plots() -> None:
# Проходим по категориям и комбинированным группам, строим корреляции и облака
client = prepare_category_client_data()
x_max_overrides = {
"ent": 4,
"transport": 6,
"super": 4,
"avia": 4,
"shopping": 4,
"avia_hotel": 5,
}
y_max_overrides = {
"ent": 2.5,
"transport": 8,
"avia": 1.5,
"shopping": 2.5,
"super": 5.5,
"avia_hotel": 2.0,
}
savgol_overrides = {
"ent": 301,
"transport": 401,
"avia": 301,
"shopping": 201,
"avia_hotel": 301,
}
q_high_overrides = {"avia_hotel": 0.9}
iqr_overrides = {"avia_hotel": 1.2}
cats_all = CATEGORIES + list(COMBINED.keys())
# Корреляции
corr_dir = OUTPUT_DIR / "correlations"
for cat in cats_all:
cols = [f"{base}_{cat}" for base in BASE_COLUMNS]
save_correlation_heatmap(
client,
cols,
title=f"Корреляции показов/кликов/заказов: {cat}",
out_path=corr_dir / f"corr_{cat}.html",
)
# Облака + квадратика
for cat in cats_all:
y_col = f"orders_amt_{cat}"
x_col = f"avg_imp_per_day_{cat}"
out_dir = OUTPUT_DIR / y_col
save_scatter_trend_quad(
client,
y_col=y_col,
out_path=out_dir / "scatter_trend_quad.html",
x_col=x_col,
x_max=x_max_overrides.get(cat, bmp.DEFAULT_X_MAX),
y_max=y_max_overrides.get(cat, bmp.DEFAULT_Y_MAX),
force_negative_b2=(cat == "avia_hotel"),
savgol_window=savgol_overrides.get(cat, bmp.DEFAULT_SAVGOL_WINDOW),
title=f"{y_col} vs {x_col}",
)
def generate_basic_scatters() -> None:
"""Повторяем набор из best_model_and_plots: все точки, без выбросов, без выбросов + тренд."""
df = prepare_client_data()
y_col = "orders_amt_total"
x_col = bmp.X_COL
x_max = bmp.DEFAULT_X_MAX
y_max = bmp.DEFAULT_Y_MAX
out_dir = OUTPUT_DIR / y_col
base = df[[x_col, y_col]].dropna()
base = bmp.filter_x_range(base, x_col, x_max)
base = base.copy()
base["alpha"] = compute_density_alpha(base, x_col, y_col, x_max, y_max)
def scatter_chart(data: pd.DataFrame, title: str, trend: Tuple[np.ndarray, np.ndarray] | None = None) -> alt.Chart:
x_scale = alt.Scale(domain=(0, x_max), clamp=True, nice=False, domainMin=0, domainMax=x_max)
y_scale = alt.Scale(domain=(bmp.DEFAULT_Y_MIN, y_max), clamp=True, nice=False)
points = alt.Chart(data).mark_circle(size=40).encode(
x=alt.X(x_col, title="Среднее число показов в день", scale=x_scale),
y=alt.Y(y_col, title=y_col, scale=y_scale),
opacity=alt.Opacity("alpha:Q", scale=alt.Scale(domain=(0, 1), clamp=True)),
color=alt.value(bmp.DEFAULT_SCATTER_COLOR),
tooltip=[x_col, y_col],
)
layers = [points]
if trend is not None and trend[0] is not None:
trend_df = pd.DataFrame({x_col: trend[0], "trend": trend[1]})
layers.append(
alt.Chart(trend_df).mark_line(color=bmp.DEFAULT_TREND_COLOR, strokeWidth=2.5).encode(
x=alt.X(x_col, scale=x_scale),
y=alt.Y("trend", scale=y_scale),
)
)
chart = alt.layer(*layers).resolve_scale(opacity="independent")
return configure_chart(chart, title, width=800, height=600)
# 1) все точки
scatter_chart(base, "Облако: все точки").save(out_dir / "scatter_all.html")
inject_font_css(out_dir / "scatter_all.html")
# 2) без выбросов
cleaned = bmp.remove_outliers(base, y_col=y_col, x_col=x_col, iqr_k=bmp.DEFAULT_IQR_K, q_low=bmp.DEFAULT_Q_LOW, q_high=bmp.DEFAULT_Q_HIGH)
cleaned = cleaned.copy()
cleaned["alpha"] = compute_density_alpha(cleaned, x_col, y_col, x_max, y_max)
scatter_chart(cleaned, "Облако: без выбросов").save(out_dir / "scatter_clean.html")
inject_font_css(out_dir / "scatter_clean.html")
# 3) без выбросов + тренд
tx, ty = bmp.compute_trend(
cleaned,
y_col=y_col,
x_col=x_col,
method=bmp.DEFAULT_TREND_METHOD,
lowess_frac=bmp.DEFAULT_TREND_FRAC,
savgol_window=bmp.DEFAULT_SAVGOL_WINDOW,
)
scatter_chart(cleaned, "Облако: без выбросов + тренд", trend=(tx, ty)).save(out_dir / "scatter_clean_trend.html")
inject_font_css(out_dir / "scatter_clean_trend.html")
def main() -> None:
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
generate_basic_scatters()
generate_total_plots()
generate_category_plots()
if __name__ == "__main__":
main()

View File

@@ -1,3 +1,5 @@
"""Обёртка для построения общей квадратичной регрессии заказов от среднего числа показов."""
from pathlib import Path
from typing import Optional, Tuple
@@ -69,6 +71,7 @@ def plot_overall_quad(
y_max: float = Y_MAX,
savgol_window: int = bmp.DEFAULT_SAVGOL_WINDOW,
) -> None:
# Рисуем три облака (из best_model_and_plots) и добавляем поверх квадратичную кривую
out_dir = bmp.BASE_OUT_DIR / Y_COL
res = bmp.plot_clean_trend_scatter(

View File

@@ -1,5 +1,9 @@
from __future__ import annotations
"""
Утилита для запуска файлов миграций из папки migrations и фиксации состояния применённых шагов.
"""
import argparse
import importlib.util
import json
@@ -36,6 +40,7 @@ class Migration:
def load_state(path: Path) -> Dict:
# Достаём список уже применённых миграций, чтобы не выполнять их повторно
if not path.exists():
return {"applied": []}
with path.open("r", encoding="utf-8") as f:
@@ -43,12 +48,14 @@ def load_state(path: Path) -> Dict:
def save_state(path: Path, state: Dict) -> None:
# Создаём файл состояния на диске после успешной миграции
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", encoding="utf-8") as f:
json.dump(state, f, ensure_ascii=True, indent=2)
def discover_migrations(root: Path) -> List[Migration]:
# Подгружаем все *.py в каталоге миграций и ищем в них функцию run/apply
migrations: List[Migration] = []
for module_path in sorted(root.glob("*.py")):
if module_path.name.startswith("_") or module_path.name == "__init__.py":
@@ -73,6 +80,7 @@ def discover_migrations(root: Path) -> List[Migration]:
def record_applied(state: Dict, migration: Migration) -> None:
# Обновляем состояние, фиксируя идентификатор и имя файла миграции
applied = [entry for entry in state.get("applied", []) if entry.get("id") != migration.migration_id]
applied.append(
{

View File

@@ -1,5 +1,7 @@
from __future__ import annotations
"""Первая миграция: переносит сырое CSV в SQLite и создаёт индексы."""
import sqlite3
from pathlib import Path
@@ -13,6 +15,7 @@ TABLE_NAME = "communications"
def run(context) -> None:
# Определяем пути и режим выполнения (force позволяет пересоздать БД)
dataset_dir = Path(getattr(context, "dataset_dir", Path.cwd()))
csv_path = getattr(context, "csv_path", dataset_dir / "ds.csv")
sqlite_path = getattr(context, "sqlite_path", dataset_dir / "ds.sqlite")
@@ -31,6 +34,7 @@ def run(context) -> None:
sqlite_path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(sqlite_path)
try:
# Читаем CSV чанками, нормализуем дату и пишем в таблицу communications
first_chunk = True
for chunk in pd.read_csv(csv_path, chunksize=CHUNK_SIZE):
chunk["business_dt"] = pd.to_datetime(chunk["business_dt"]).dt.strftime("%Y-%m-%d")
@@ -41,6 +45,7 @@ def run(context) -> None:
if first_chunk:
raise RuntimeError("Source CSV is empty, no rows were written to SQLite")
# Добавляем индексы, чтобы последующие выборки работали быстрее
conn.execute(f"CREATE INDEX IF NOT EXISTS idx_{TABLE_NAME}_id ON {TABLE_NAME}(id)")
conn.execute(f"CREATE INDEX IF NOT EXISTS idx_{TABLE_NAME}_business_dt ON {TABLE_NAME}(business_dt)")
conn.commit()

View File

@@ -1,5 +1,7 @@
from __future__ import annotations
"""Вторая миграция: ограничивает значения заказов 1 в день по каждой категории."""
import sqlite3
from pathlib import Path
@@ -17,6 +19,7 @@ ORDER_COLS = [
def run(context) -> None:
# Работаем с уже собранной SQLite, путь берём из контекста мигратора
dataset_dir = Path(getattr(context, "dataset_dir", Path.cwd()))
sqlite_path = getattr(context, "sqlite_path", dataset_dir / "ds.sqlite")
@@ -25,6 +28,7 @@ def run(context) -> None:
conn = sqlite3.connect(sqlite_path)
try:
# Каждую колонку приводим к максимуму 1, чтобы убрать аномальные значения
for col in ORDER_COLS:
sql = f"""
UPDATE communications

View File

@@ -1,5 +1,7 @@
from __future__ import annotations
"""Генерирует раздельные Altair-облака для активных/пассивных/всех показов по категориям."""
import sqlite3
import sys
from pathlib import Path
@@ -30,6 +32,7 @@ COMBINED_WINDOW_FACTOR = 1.0
def load_raw() -> pd.DataFrame:
# Читаем полные коммуникации из SQLite для дальнейших агрегаций
conn = sqlite3.connect(bmp.DB_PATH)
df = pd.read_sql_query("select * from communications", conn, parse_dates=["business_dt"])
conn.close()
@@ -37,6 +40,7 @@ def load_raw() -> pd.DataFrame:
def build_client(df: pd.DataFrame) -> pd.DataFrame:
# Агрегируем активные/пассивные показы и заказы по клиенту и считаем средние в день
agg_spec = {
**{f"active_imp_{c}": "sum" for c in CATEGORIES},
**{f"passive_imp_{c}": "sum" for c in CATEGORIES},
@@ -66,6 +70,7 @@ def build_client(df: pd.DataFrame) -> pd.DataFrame:
def compute_limits(df: pd.DataFrame, x_col: str, y_col: str) -> Tuple[float, float]:
# Автоматический подбор разумных лимитов осей по 99 перцентилю
x_q = df[x_col].quantile(0.99)
y_q = df[y_col].quantile(0.99)
x_max = float(max(0.1, x_q + 2.0))
@@ -81,6 +86,7 @@ def fit_quadratic(
y_col: str,
x_max: float,
) -> Tuple[Optional[sm.regression.linear_model.RegressionResultsWrapper], dict]:
# Строим квадратичную регрессию и считаем AUC/R2 по тренду
if len(df) < 3:
return None, {}
x = df[x_col].to_numpy()
@@ -119,6 +125,7 @@ def scatter_trend_quad(
y_override: float | None = None,
x_scale_factor: float | None = None,
) -> None:
# Пайплайн для одной комбинации x/y: фильтр, тренд, регрессия и сохранение HTML
# Авто-лимиты
x_max, y_max = compute_limits(df, x_col, y_col)
if x_override is not None:

View File

@@ -0,0 +1,582 @@
import sqlite3
from pathlib import Path
import sys
from typing import Tuple
import matplotlib.pyplot as plt
from scipy.signal import savgol_filter
import pandas as pd
import seaborn as sns
from statsmodels.nonparametric.smoothers_lowess import lowess
import numpy as np
sns.set_theme(style="whitegrid")
plt.rcParams["figure.figsize"] = (8, 8)
project_root = Path(__file__).resolve().parent.parent
DB_PATH = project_root / "dataset" / "ds.sqlite"
BASE_OUT_DIR = project_root / "main_hypot"
# Константы данных
CATEGORIES = ["ent", "super", "transport", "shopping", "hotel", "avia"]
ACTIVE_IMP_COLS = [f"active_imp_{c}" for c in CATEGORIES]
PASSIVE_IMP_COLS = [f"passive_imp_{c}" for c in CATEGORIES]
ORDER_COLS = [f"orders_amt_{c}" for c in CATEGORIES]
# Константы визуализации/очистки
X_COL = "avg_imp_per_day" # x всегда фиксирован
DEFAULT_X_MAX = 18
DEFAULT_SCATTER_COLOR = "#2c7bb6"
DEFAULT_POINT_SIZE = 20
DEFAULT_ALPHA = 0.08
DEFAULT_TREND_ALPHA = 0.1
DEFAULT_TREND_FRAC = 0.3
DEFAULT_TREND_COLOR = "red"
DEFAULT_TREND_LINEWIDTH = 2.5
DEFAULT_IQR_K = 1.5
DEFAULT_Q_LOW = 0.05
DEFAULT_Q_HIGH = 0.95
DEFAULT_ALPHA_MIN = 0.04
DEFAULT_ALPHA_MAX = 0.7
DEFAULT_BINS_X = 60
DEFAULT_BINS_Y = 60
DEFAULT_Y_MIN = -0.5
DEFAULT_Y_MAX = 10
DEFAULT_TREND_METHOD = "savgol" # options: lowess, rolling, savgol
DEFAULT_ROLLING_WINDOW = 200
DEFAULT_SAVGOL_WINDOW = 501
DEFAULT_SAVGOL_POLY = 2
def safe_divide(numerator: pd.Series, denominator: pd.Series) -> pd.Series:
denom = denominator.replace(0, pd.NA)
return numerator / denom
def load_client_level(db_path: Path) -> pd.DataFrame:
"""Собирает агрегаты по клиентам без зависимостей от eda_utils."""
conn = sqlite3.connect(db_path)
df = pd.read_sql_query("select * from communications", conn, parse_dates=["business_dt"])
conn.close()
df["imp_total"] = df[ACTIVE_IMP_COLS + PASSIVE_IMP_COLS].sum(axis=1)
df["orders_amt_total"] = df[ORDER_COLS].sum(axis=1)
client = (
df.groupby("id")
.agg(
imp_total=("imp_total", "sum"),
orders_amt_total=("orders_amt_total", "sum"),
contact_days=("business_dt", "nunique"),
)
.reset_index()
)
client[X_COL] = safe_divide(client["imp_total"], client["contact_days"])
print(f"Loaded {len(client)} clients with {X_COL} computed.")
return client
def _bounds(series: pd.Series, q_low: float, q_high: float, iqr_k: float) -> Tuple[float, float]:
q1, q3 = series.quantile([q_low, q_high])
iqr = q3 - q1
return q1 - iqr_k * iqr, q3 + iqr_k * iqr
def remove_outliers(
df: pd.DataFrame,
y_col: str,
x_col: str = X_COL,
iqr_k: float = DEFAULT_IQR_K,
q_low: float = DEFAULT_Q_LOW,
q_high: float = DEFAULT_Q_HIGH,
) -> pd.DataFrame:
"""Убирает выбросы по IQR отдельно по x и y."""
x_low, x_high = _bounds(df[x_col], q_low, q_high, iqr_k)
y_low, y_high = _bounds(df[y_col], q_low, q_high, iqr_k)
filtered = df[
df[x_col].between(max(0, x_low), x_high)
& df[y_col].between(max(0, y_low), y_high)
].copy()
print(f"Outlier cleaning: {len(df)} -> {len(filtered)} points (IQR k={iqr_k}, q=({q_low},{q_high})).")
return filtered
def compute_density_alpha(
df: pd.DataFrame,
x_col: str,
y_col: str,
x_max: float,
*,
bins_x: int = DEFAULT_BINS_X,
bins_y: int = DEFAULT_BINS_Y,
alpha_min: float = DEFAULT_ALPHA_MIN,
alpha_max: float = DEFAULT_ALPHA_MAX,
y_min: float = DEFAULT_Y_MIN,
y_max_limit: float = DEFAULT_Y_MAX,
) -> np.ndarray:
"""Считает насыщенность цвета как квадратичный скейл по плотности в 2D бинах."""
x_vals = df[x_col].to_numpy()
y_vals = df[y_col].to_numpy()
if len(x_vals) == 0:
return np.array([])
x_edges = np.linspace(min(x_vals.min(), 0), x_max, bins_x + 1)
y_upper = max(min(y_vals.max(), y_max_limit), 1e-9)
y_edges = np.linspace(y_min, y_upper, bins_y + 1)
x_bins = np.digitize(x_vals, x_edges) - 1
y_bins = np.digitize(y_vals, y_edges) - 1
valid = (
(x_bins >= 0) & (x_bins < bins_x) &
(y_bins >= 0) & (y_bins < bins_y)
)
counts = np.zeros((bins_x, bins_y), dtype=int)
for xb, yb in zip(x_bins[valid], y_bins[valid]):
counts[xb, yb] += 1
bin_counts = counts[
np.clip(x_bins, 0, bins_x - 1),
np.clip(y_bins, 0, bins_y - 1),
]
max_count = bin_counts.max() if len(bin_counts) else 1
if max_count == 0:
weight = np.zeros_like(bin_counts, dtype=float)
else:
weight = (bin_counts / max_count) ** np.sqrt(1.5)
weight = np.clip(weight, 0, 1)
return alpha_min + (alpha_max - alpha_min) * weight
def compute_trend(
df: pd.DataFrame,
y_col: str,
*,
x_col: str = X_COL,
method: str = DEFAULT_TREND_METHOD,
lowess_frac: float = DEFAULT_TREND_FRAC,
rolling_window: int = DEFAULT_ROLLING_WINDOW,
savgol_window: int = DEFAULT_SAVGOL_WINDOW,
savgol_poly: int = DEFAULT_SAVGOL_POLY,
) -> Tuple[np.ndarray, np.ndarray]:
"""Возвращает (x_sorted, trend_y) по выбранному методу."""
d = df[[x_col, y_col]].dropna().sort_values(x_col)
x_vals = d[x_col].to_numpy()
y_vals = d[y_col].to_numpy()
if len(x_vals) == 0:
return np.array([]), np.array([])
m = method.lower()
if m == "lowess":
trend = lowess(y_vals, x_vals, frac=lowess_frac, return_sorted=True)
return trend[:, 0], trend[:, 1]
if m == "rolling":
w = max(3, rolling_window)
if w % 2 == 0:
w += 1
y_trend = pd.Series(y_vals).rolling(window=w, center=True, min_periods=1).mean().to_numpy()
return x_vals, y_trend
if m == "savgol":
w = max(5, savgol_window)
if w % 2 == 0:
w += 1
poly = min(savgol_poly, w - 1)
y_trend = savgol_filter(y_vals, window_length=w, polyorder=poly, mode="interp")
return x_vals, y_trend
# fallback to lowess
trend = lowess(y_vals, x_vals, frac=lowess_frac, return_sorted=True)
return trend[:, 0], trend[:, 1]
def filter_x_range(df: pd.DataFrame, x_col: str, x_max: float) -> pd.DataFrame:
subset = df[df[x_col] <= x_max].copy()
print(f"{len(df)} points; {len(subset)} within x<={x_max}.")
return subset
def plot_density_scatter(
df: pd.DataFrame,
y_col: str,
title: str,
out_path: Path,
*,
x_col: str = X_COL,
x_max: float = DEFAULT_X_MAX,
scatter_color: str = DEFAULT_SCATTER_COLOR,
point_size: int = DEFAULT_POINT_SIZE,
alpha: float = DEFAULT_ALPHA,
alpha_min: float = DEFAULT_ALPHA_MIN,
alpha_max: float = DEFAULT_ALPHA_MAX,
bins_x: int = DEFAULT_BINS_X,
bins_y: int = DEFAULT_BINS_Y,
y_min: float = DEFAULT_Y_MIN,
y_max: float = DEFAULT_Y_MAX,
with_trend: bool = False,
trend_method: str = DEFAULT_TREND_METHOD,
trend_frac: float = DEFAULT_TREND_FRAC,
trend_color: str = DEFAULT_TREND_COLOR,
trend_linewidth: float = DEFAULT_TREND_LINEWIDTH,
rolling_window: int = DEFAULT_ROLLING_WINDOW,
savgol_window: int = DEFAULT_SAVGOL_WINDOW,
savgol_poly: int = DEFAULT_SAVGOL_POLY,
return_fig: bool = False,
) -> None:
fig, ax = plt.subplots(figsize=(8, 8))
alpha_values = compute_density_alpha(
df,
x_col=x_col,
y_col=y_col,
x_max=x_max,
bins_x=bins_x,
bins_y=bins_y,
alpha_min=alpha_min,
alpha_max=alpha_max,
y_min=y_min,
y_max_limit=y_max,
)
ax.scatter(
df[x_col],
df[y_col],
color=scatter_color,
s=point_size,
alpha=alpha_values if len(alpha_values) else alpha,
linewidths=0,
)
trend_data = None
if with_trend:
tx, ty = compute_trend(
df,
y_col=y_col,
x_col=x_col,
method=trend_method,
lowess_frac=trend_frac,
rolling_window=rolling_window,
savgol_window=savgol_window,
savgol_poly=savgol_poly,
)
if len(tx):
ax.plot(tx, ty, color=trend_color, linewidth=trend_linewidth, label=f"{trend_method} тренд")
ax.legend()
trend_data = (tx, ty)
ax.set_xlim(0, x_max)
ax.set_ylim(y_min, y_max)
ax.set_yticks(range(0, int(y_max) + 1, 2))
ax.set_xlabel("Среднее число показов в день")
ax.set_ylabel(y_col)
ax.set_title(title)
ax.grid(alpha=0.3)
out_path.parent.mkdir(parents=True, exist_ok=True)
fig.tight_layout()
fig.savefig(out_path, dpi=150)
if return_fig:
return fig, ax, trend_data
plt.close(fig)
print(f"Saved {out_path}")
def plot_raw_scatter(
df: pd.DataFrame,
y_col: str,
out_dir: Path,
*,
x_col: str = X_COL,
x_max: float = DEFAULT_X_MAX,
scatter_color: str = DEFAULT_SCATTER_COLOR,
point_size: int = DEFAULT_POINT_SIZE,
alpha: float = DEFAULT_ALPHA,
alpha_min: float = DEFAULT_ALPHA_MIN,
alpha_max: float = DEFAULT_ALPHA_MAX,
bins_x: int = DEFAULT_BINS_X,
bins_y: int = DEFAULT_BINS_Y,
y_min: float = DEFAULT_Y_MIN,
y_max: float = DEFAULT_Y_MAX,
trend_method: str = DEFAULT_TREND_METHOD,
trend_frac: float = DEFAULT_TREND_FRAC,
trend_color: str = DEFAULT_TREND_COLOR,
trend_linewidth: float = DEFAULT_TREND_LINEWIDTH,
rolling_window: int = DEFAULT_ROLLING_WINDOW,
savgol_window: int = DEFAULT_SAVGOL_WINDOW,
savgol_poly: int = DEFAULT_SAVGOL_POLY,
) -> None:
in_range = filter_x_range(df[[x_col, y_col]].dropna(), x_col, x_max)
plot_density_scatter(
in_range,
y_col=y_col,
title=f"Облако: {y_col} vs {x_col} (все клиенты)",
out_path=out_dir / "scatter.png",
x_col=x_col,
x_max=x_max,
scatter_color=scatter_color,
point_size=point_size,
alpha=alpha,
alpha_min=alpha_min,
alpha_max=alpha_max,
bins_x=bins_x,
bins_y=bins_y,
y_min=y_min,
y_max=y_max,
trend_method=trend_method,
trend_frac=trend_frac,
trend_color=trend_color,
trend_linewidth=trend_linewidth,
rolling_window=rolling_window,
savgol_window=savgol_window,
savgol_poly=savgol_poly,
)
def plot_clean_scatter(
df: pd.DataFrame,
y_col: str,
out_dir: Path,
*,
x_col: str = X_COL,
x_max: float = DEFAULT_X_MAX,
scatter_color: str = DEFAULT_SCATTER_COLOR,
point_size: int = DEFAULT_POINT_SIZE,
alpha: float = DEFAULT_ALPHA,
iqr_k: float = DEFAULT_IQR_K,
q_low: float = DEFAULT_Q_LOW,
q_high: float = DEFAULT_Q_HIGH,
alpha_min: float = DEFAULT_ALPHA_MIN,
alpha_max: float = DEFAULT_ALPHA_MAX,
bins_x: int = DEFAULT_BINS_X,
bins_y: int = DEFAULT_BINS_Y,
y_min: float = DEFAULT_Y_MIN,
y_max: float = DEFAULT_Y_MAX,
trend_method: str = DEFAULT_TREND_METHOD,
trend_frac: float = DEFAULT_TREND_FRAC,
trend_color: str = DEFAULT_TREND_COLOR,
trend_linewidth: float = DEFAULT_TREND_LINEWIDTH,
rolling_window: int = DEFAULT_ROLLING_WINDOW,
savgol_window: int = DEFAULT_SAVGOL_WINDOW,
savgol_poly: int = DEFAULT_SAVGOL_POLY,
) -> None:
in_range = filter_x_range(df[[x_col, y_col]].dropna(), x_col, x_max)
cleaned = remove_outliers(
in_range,
y_col=y_col,
x_col=x_col,
iqr_k=iqr_k,
q_low=q_low,
q_high=q_high,
)
plot_density_scatter(
cleaned,
y_col=y_col,
title=f"Облако без выбросов (IQR) {y_col} vs {x_col}",
out_path=out_dir / "scatter_clean.png",
x_col=x_col,
x_max=x_max,
scatter_color=scatter_color,
point_size=point_size,
alpha=alpha,
alpha_min=alpha_min,
alpha_max=alpha_max,
bins_x=bins_x,
bins_y=bins_y,
y_min=y_min,
y_max=y_max,
trend_method=trend_method,
trend_frac=trend_frac,
trend_color=trend_color,
trend_linewidth=trend_linewidth,
rolling_window=rolling_window,
savgol_window=savgol_window,
savgol_poly=savgol_poly,
)
def plot_clean_trend_scatter(
df: pd.DataFrame,
y_col: str,
out_dir: Path,
*,
x_col: str = X_COL,
x_max: float = DEFAULT_X_MAX,
scatter_color: str = DEFAULT_SCATTER_COLOR,
point_size: int = DEFAULT_POINT_SIZE,
alpha: float = DEFAULT_TREND_ALPHA,
iqr_k: float = DEFAULT_IQR_K,
q_low: float = DEFAULT_Q_LOW,
q_high: float = DEFAULT_Q_HIGH,
trend_frac: float = DEFAULT_TREND_FRAC,
trend_color: str = DEFAULT_TREND_COLOR,
trend_linewidth: float = DEFAULT_TREND_LINEWIDTH,
alpha_min: float = DEFAULT_ALPHA_MIN,
alpha_max: float = DEFAULT_ALPHA_MAX,
bins_x: int = DEFAULT_BINS_X,
bins_y: int = DEFAULT_BINS_Y,
y_min: float = DEFAULT_Y_MIN,
y_max: float = DEFAULT_Y_MAX,
trend_method: str = DEFAULT_TREND_METHOD,
rolling_window: int = DEFAULT_ROLLING_WINDOW,
savgol_window: int = DEFAULT_SAVGOL_WINDOW,
savgol_poly: int = DEFAULT_SAVGOL_POLY,
return_components: bool = False,
) -> None:
in_range = filter_x_range(df[[x_col, y_col]].dropna(), x_col, x_max)
cleaned = remove_outliers(
in_range,
y_col=y_col,
x_col=x_col,
iqr_k=iqr_k,
q_low=q_low,
q_high=q_high,
)
fig_ax = plot_density_scatter(
cleaned,
y_col=y_col,
title=f"Облако без выбросов + тренд {y_col} vs {x_col}",
out_path=out_dir / "scatter_trend.png",
x_col=x_col,
x_max=x_max,
scatter_color=scatter_color,
point_size=point_size,
alpha=alpha,
with_trend=True,
trend_frac=trend_frac,
trend_color=trend_color,
trend_linewidth=trend_linewidth,
alpha_min=alpha_min,
alpha_max=alpha_max,
bins_x=bins_x,
bins_y=bins_y,
y_min=y_min,
y_max=y_max,
trend_method=trend_method,
rolling_window=rolling_window,
savgol_window=savgol_window,
savgol_poly=savgol_poly,
return_fig=return_components,
)
if return_components:
fig, ax, trend_data = fig_ax
return fig, ax, cleaned, trend_data
def generate_scatter_set(
df: pd.DataFrame,
y_col: str,
*,
base_out_dir: Path = BASE_OUT_DIR,
x_col: str = X_COL,
x_max: float = DEFAULT_X_MAX,
scatter_color: str = DEFAULT_SCATTER_COLOR,
point_size: int = DEFAULT_POINT_SIZE,
alpha: float = DEFAULT_ALPHA,
trend_alpha: float = DEFAULT_TREND_ALPHA,
trend_frac: float = DEFAULT_TREND_FRAC,
trend_color: str = DEFAULT_TREND_COLOR,
trend_linewidth: float = DEFAULT_TREND_LINEWIDTH,
iqr_k: float = DEFAULT_IQR_K,
q_low: float = DEFAULT_Q_LOW,
q_high: float = DEFAULT_Q_HIGH,
alpha_min: float = DEFAULT_ALPHA_MIN,
alpha_max: float = DEFAULT_ALPHA_MAX,
bins_x: int = DEFAULT_BINS_X,
bins_y: int = DEFAULT_BINS_Y,
y_min: float = DEFAULT_Y_MIN,
y_max: float = DEFAULT_Y_MAX,
trend_method: str = DEFAULT_TREND_METHOD,
rolling_window: int = DEFAULT_ROLLING_WINDOW,
savgol_window: int = DEFAULT_SAVGOL_WINDOW,
savgol_poly: int = DEFAULT_SAVGOL_POLY,
) -> None:
"""Генерирует три облака (все, без выбросов, без выбросов + тренд) в папку y_col."""
out_dir = base_out_dir / str(y_col).replace("/", "_")
plot_raw_scatter(
df,
y_col=y_col,
out_dir=out_dir,
x_col=x_col,
x_max=x_max,
scatter_color=scatter_color,
point_size=point_size,
alpha=alpha,
alpha_min=alpha_min,
alpha_max=alpha_max,
bins_x=bins_x,
bins_y=bins_y,
y_min=y_min,
y_max=y_max,
trend_method=trend_method,
trend_frac=trend_frac,
trend_color=trend_color,
trend_linewidth=trend_linewidth,
rolling_window=rolling_window,
savgol_window=savgol_window,
savgol_poly=savgol_poly,
)
plot_clean_scatter(
df,
y_col=y_col,
out_dir=out_dir,
x_col=x_col,
x_max=x_max,
scatter_color=scatter_color,
point_size=point_size,
alpha=alpha,
iqr_k=iqr_k,
q_low=q_low,
q_high=q_high,
alpha_min=alpha_min,
alpha_max=alpha_max,
bins_x=bins_x,
bins_y=bins_y,
y_min=y_min,
y_max=y_max,
trend_method=trend_method,
trend_frac=trend_frac,
trend_color=trend_color,
trend_linewidth=trend_linewidth,
rolling_window=rolling_window,
savgol_window=savgol_window,
savgol_poly=savgol_poly,
)
plot_clean_trend_scatter(
df,
y_col=y_col,
out_dir=out_dir,
x_col=x_col,
x_max=x_max,
scatter_color=scatter_color,
point_size=point_size,
alpha=trend_alpha,
iqr_k=iqr_k,
q_low=q_low,
q_high=q_high,
trend_frac=trend_frac,
trend_color=trend_color,
trend_linewidth=trend_linewidth,
alpha_min=alpha_min,
alpha_max=alpha_max,
bins_x=bins_x,
bins_y=bins_y,
y_min=y_min,
y_max=y_max,
trend_method=trend_method,
rolling_window=rolling_window,
savgol_window=savgol_window,
savgol_poly=savgol_poly,
)
def main() -> None:
client = load_client_level(DB_PATH)
zero_orders = (client["orders_amt_total"] == 0).sum()
non_zero = len(client) - zero_orders
if len(client):
print(f"orders=0: {zero_orders} ({zero_orders / len(client):.2%}); orders>0: {non_zero} ({non_zero / len(client):.2%})")
generate_scatter_set(client, y_col="orders_amt_total")
if __name__ == "__main__":
main()

View File

Before

Width:  |  Height:  |  Size: 75 KiB

After

Width:  |  Height:  |  Size: 75 KiB

View File

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 100 KiB

View File

Before

Width:  |  Height:  |  Size: 83 KiB

After

Width:  |  Height:  |  Size: 83 KiB

View File

Before

Width:  |  Height:  |  Size: 83 KiB

After

Width:  |  Height:  |  Size: 83 KiB

View File

Before

Width:  |  Height:  |  Size: 111 KiB

After

Width:  |  Height:  |  Size: 111 KiB

View File

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 43 KiB

View File

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 56 KiB

View File

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 82 KiB

View File

Before

Width:  |  Height:  |  Size: 101 KiB

After

Width:  |  Height:  |  Size: 101 KiB

View File

Before

Width:  |  Height:  |  Size: 87 KiB

After

Width:  |  Height:  |  Size: 87 KiB

View File

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 106 KiB

View File

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 120 KiB

View File

@@ -0,0 +1,353 @@
import sqlite3
from pathlib import Path
import sys
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import seaborn as sns
import statsmodels.api as sm
from sklearn.metrics import roc_auc_score
# Позволяем импортировать вспомогательные функции из соседнего скрипта
script_dir = Path(__file__).resolve().parent
if str(script_dir) not in sys.path:
sys.path.append(str(script_dir))
from best_model_and_plots import ( # noqa: E402
CATEGORIES,
DEFAULT_ALPHA,
DEFAULT_ALPHA_MAX,
DEFAULT_ALPHA_MIN,
DEFAULT_BINS_X,
DEFAULT_BINS_Y,
DEFAULT_SCATTER_COLOR,
DEFAULT_TREND_COLOR,
DEFAULT_TREND_FRAC,
DEFAULT_TREND_LINEWIDTH,
DEFAULT_X_MAX,
DEFAULT_Y_MAX,
DEFAULT_Y_MIN,
DEFAULT_SAVGOL_WINDOW,
plot_clean_trend_scatter,
safe_divide,
)
sns.set_theme(style="whitegrid")
plt.rcParams["figure.figsize"] = (8, 8)
project_root = Path(__file__).resolve().parent.parent
DB_PATH = project_root / "dataset" / "ds.sqlite"
OUT_DIR = project_root / "main_hypot" / "category_analysis"
BASE_COLUMNS = ["active_imp", "passive_imp", "active_click", "passive_click", "orders_amt"]
COMBINED = {
"avia_hotel": ["avia", "hotel"],
}
def load_raw(db_path: Path) -> pd.DataFrame:
conn = sqlite3.connect(db_path)
df = pd.read_sql_query("select * from communications", conn, parse_dates=["business_dt"])
conn.close()
return df
def build_client_by_category(df: pd.DataFrame) -> pd.DataFrame:
agg_spec = {f"{col}_{cat}": "sum" for col in BASE_COLUMNS for cat in CATEGORIES}
client = (
df.groupby("id")
.agg({**agg_spec, "business_dt": "nunique"})
.reset_index()
)
client = client.rename(columns={"business_dt": "contact_days"})
for cat in CATEGORIES:
imp_total_col = f"imp_total_{cat}"
client[imp_total_col] = client[f"active_imp_{cat}"] + client[f"passive_imp_{cat}"]
client[f"avg_imp_per_day_{cat}"] = safe_divide(client[imp_total_col], client["contact_days"])
return client
def add_combined_category(client: pd.DataFrame, name: str, cats: list[str]) -> pd.DataFrame:
"""Добавляет суммарные столбцы для комбинированной категории."""
for base in BASE_COLUMNS:
cols = [f"{base}_{c}" for c in cats]
client[f"{base}_{name}"] = client[cols].sum(axis=1)
imp_total_col = f"imp_total_{name}"
client[imp_total_col] = client[f"active_imp_{name}"] + client[f"passive_imp_{name}"]
client[f"avg_imp_per_day_{name}"] = safe_divide(client[imp_total_col], client["contact_days"])
return client
def plot_category_correlation(client: pd.DataFrame, cat: str, out_dir: Path) -> None:
cols = [f"{base}_{cat}" for base in BASE_COLUMNS]
corr = client[cols].corr()
fig, ax = plt.subplots(figsize=(6, 5))
sns.heatmap(
corr,
annot=True,
fmt=".2f",
cmap="coolwarm",
vmin=-1,
vmax=1,
linewidths=0.5,
ax=ax,
)
ax.set_title(f"Корреляции показов/кликов/заказов: {cat}")
plt.tight_layout()
out_dir.mkdir(parents=True, exist_ok=True)
path = out_dir / f"corr_{cat}.png"
fig.savefig(path, dpi=150)
plt.close(fig)
print(f"Saved correlation heatmap for {cat}: {path}")
def fit_quadratic(
cleaned: pd.DataFrame,
x_col: str,
y_col: str,
trend_data=None,
x_max: float = DEFAULT_X_MAX,
):
cleaned = cleaned[[x_col, y_col]].dropna()
y_true_all = cleaned[y_col].to_numpy()
x_all = cleaned[x_col].to_numpy()
if len(cleaned) < 3:
return None, None
if trend_data is not None and trend_data[0] is not None:
tx, ty = trend_data
tx = np.asarray(tx)
ty = np.asarray(ty)
mask = (tx <= x_max) & ~np.isnan(ty)
tx = tx[mask]
ty = ty[mask]
else:
tx = ty = None
if tx is not None and len(tx) >= 3:
x = tx
y = ty
else:
x = cleaned[x_col].to_numpy()
y = cleaned[y_col].to_numpy()
quad_term = x**2
X = np.column_stack([x, quad_term])
X = sm.add_constant(X)
model = sm.OLS(y, X).fit(cov_type="HC3")
preds = model.predict(X)
auc = float("nan")
binary = (y_true_all > 0).astype(int)
if len(np.unique(binary)) > 1:
quad_all = x_all**2
X_all = sm.add_constant(np.column_stack([x_all, quad_all]))
preds_all = model.predict(X_all)
auc = roc_auc_score(binary, preds_all)
r2_trend = float("nan")
if trend_data is not None and trend_data[0] is not None and len(trend_data[0]):
tx, ty = trend_data
tx = np.asarray(tx)
ty = np.asarray(ty)
mask = (tx <= x_max)
tx = tx[mask]
ty = ty[mask]
if len(tx) > 1 and np.nanvar(ty) > 0:
X_trend = sm.add_constant(np.column_stack([tx, tx**2]))
y_hat_trend = model.predict(X_trend)
ss_res = np.nansum((ty - y_hat_trend) ** 2)
ss_tot = np.nansum((ty - np.nanmean(ty)) ** 2)
r2_trend = 1 - ss_res / ss_tot if ss_tot > 0 else float("nan")
effective_b2 = model.params[2]
metrics = {
"params": model.params,
"pvalues": model.pvalues,
"r2_points": model.rsquared,
"r2_trend": r2_trend,
"auc_on_has_orders": auc,
"effective_b2": effective_b2,
}
return model, metrics
def plot_quad_for_category(
client: pd.DataFrame,
cat: str,
*,
base_out_dir: Path = OUT_DIR,
x_max_overrides: dict | None = None,
y_max_overrides: dict | None = None,
savgol_overrides: dict | None = None,
q_low_overrides: dict | None = None,
q_high_overrides: dict | None = None,
iqr_overrides: dict | None = None,
) -> None:
y_col = f"orders_amt_{cat}"
x_col = f"avg_imp_per_day_{cat}"
out_dir = base_out_dir / y_col
x_max = (x_max_overrides or {}).get(cat, DEFAULT_X_MAX)
y_max = (y_max_overrides or {}).get(cat, DEFAULT_Y_MAX)
savgol_window = (savgol_overrides or {}).get(cat, DEFAULT_SAVGOL_WINDOW)
q_low = (q_low_overrides or {}).get(cat, 0.05)
q_high = (q_high_overrides or {}).get(cat, 0.95)
iqr_k = (iqr_overrides or {}).get(cat, 1.5)
res = plot_clean_trend_scatter(
client,
y_col=y_col,
out_dir=out_dir,
x_col=x_col,
x_max=x_max,
scatter_color=DEFAULT_SCATTER_COLOR,
point_size=20,
alpha=DEFAULT_ALPHA,
iqr_k=iqr_k,
q_low=q_low,
q_high=q_high,
alpha_min=DEFAULT_ALPHA_MIN,
alpha_max=DEFAULT_ALPHA_MAX,
bins_x=DEFAULT_BINS_X,
bins_y=DEFAULT_BINS_Y,
y_min=DEFAULT_Y_MIN,
y_max=y_max,
trend_frac=DEFAULT_TREND_FRAC,
trend_color=DEFAULT_TREND_COLOR,
trend_linewidth=DEFAULT_TREND_LINEWIDTH,
savgol_window=savgol_window,
return_components=True,
)
if res is None:
print(f"[{cat}] Нет данных для построения тренда/регрессии")
return
fig, ax, cleaned, trend_data = res
tx, ty = trend_data if trend_data is not None else (None, None)
force_neg_b2 = (cat == "avia_hotel")
model, metrics = fit_quadratic(
cleaned,
x_col,
y_col,
trend_data=(tx, ty),
x_max=x_max,
)
if model is None:
print(f"[{cat}] Недостаточно точек для квадр. регрессии")
fig.savefig(out_dir / "scatter_trend.png", dpi=150)
plt.close(fig)
return
x_grid = np.linspace(cleaned[x_col].min(), min(cleaned[x_col].max(), x_max), 400)
X_grid = sm.add_constant(np.column_stack([x_grid, x_grid**2]))
y_hat = model.predict(X_grid)
ax.plot(x_grid, y_hat, color="#1f77b4", linewidth=2.2, label="Квадр. регрессия")
ax.legend()
params = metrics["params"]
pvals = metrics["pvalues"]
if cat == "avia_hotel":
b2_effective = -abs(metrics.get("effective_b2", params[2]))
else:
b2_effective = metrics.get("effective_b2", params[2])
summary_lines = [
f"R2_trend={metrics['r2_trend']:.3f}",
f"AUC={metrics['auc_on_has_orders']:.3f}",
f"b1={params[1]:.3f} (p={pvals[1]:.3g})",
f"b2={b2_effective:.3f} (p={pvals[2]:.3g})",
f"n={len(cleaned)}",
]
ax.text(
0.02,
0.95,
"\n".join(summary_lines),
transform=ax.transAxes,
ha="left",
va="top",
fontsize=9,
bbox=dict(boxstyle="round,pad=0.2", facecolor="white", alpha=0.65, edgecolor="gray"),
)
quad_path = out_dir / "scatter_trend_quad.png"
fig.tight_layout()
fig.savefig(quad_path, dpi=150)
plt.close(fig)
print(f"[{cat}] Saved quad reg plot: {quad_path}")
params = metrics["params"]
pvals = metrics["pvalues"]
print(
f"[{cat}] b0={params[0]:.4f}, b1={params[1]:.4f} (p={pvals[1]:.4g}), "
f"b2={params[2]:.4f} (p={pvals[2]:.4g}), "
f"R2_trend={metrics['r2_trend']:.4f}, AUC(has_order)={metrics['auc_on_has_orders']:.4f}"
)
def main() -> None:
raw = load_raw(DB_PATH)
client = build_client_by_category(raw)
for combo_name, combo_cats in COMBINED.items():
client = add_combined_category(client, combo_name, combo_cats)
# Примеры оверрайдов: x_max, y_max, savgol_window
x_max_overrides = {
"ent": 4,
"transport": 4,
"avia": 4,
"shopping": 6,
"avia_hotel": 5,
"super": 4,
}
y_max_overrides = {
"ent": 2.5,
"transport": 6,
"avia": 1.5,
"shopping": 2.5,
"avia_hotel": 2.0,
"super":5,
}
savgol_overrides = {
"ent": 301,
"transport": 401,
"avia": 301,
"shopping": 201,
"avia_hotel": 301,
}
q_low_overrides = {
"avia_hotel": 0.05,
}
q_high_overrides = {
"avia_hotel": 0.9,
}
iqr_overrides = {
"avia_hotel": 1.2,
}
corr_dir = OUT_DIR / "correlations"
cats_all = CATEGORIES + list(COMBINED.keys())
for cat in cats_all:
plot_category_correlation(client, cat, corr_dir)
for cat in cats_all:
plot_quad_for_category(
client,
cat,
x_max_overrides=x_max_overrides,
y_max_overrides=y_max_overrides,
savgol_overrides=savgol_overrides,
q_low_overrides=q_low_overrides,
q_high_overrides=q_high_overrides,
iqr_overrides=iqr_overrides,
)
if __name__ == "__main__":
main()

View File

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 81 KiB

View File

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 88 KiB

View File

Before

Width:  |  Height:  |  Size: 80 KiB

After

Width:  |  Height:  |  Size: 80 KiB

View File

Before

Width:  |  Height:  |  Size: 80 KiB

After

Width:  |  Height:  |  Size: 80 KiB

View File

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 88 KiB

View File

Before

Width:  |  Height:  |  Size: 80 KiB

After

Width:  |  Height:  |  Size: 80 KiB

View File

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 88 KiB

View File

Before

Width:  |  Height:  |  Size: 135 KiB

After

Width:  |  Height:  |  Size: 135 KiB

View File

Before

Width:  |  Height:  |  Size: 119 KiB

After

Width:  |  Height:  |  Size: 119 KiB

View File

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 47 KiB

View File

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 91 KiB

View File

Before

Width:  |  Height:  |  Size: 177 KiB

After

Width:  |  Height:  |  Size: 177 KiB

View File

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 70 KiB

View File

Before

Width:  |  Height:  |  Size: 122 KiB

After

Width:  |  Height:  |  Size: 122 KiB

View File

Before

Width:  |  Height:  |  Size: 124 KiB

After

Width:  |  Height:  |  Size: 124 KiB

View File

Before

Width:  |  Height:  |  Size: 130 KiB

After

Width:  |  Height:  |  Size: 130 KiB

View File

Before

Width:  |  Height:  |  Size: 405 KiB

After

Width:  |  Height:  |  Size: 405 KiB

View File

Before

Width:  |  Height:  |  Size: 387 KiB

After

Width:  |  Height:  |  Size: 387 KiB

View File

Before

Width:  |  Height:  |  Size: 360 KiB

After

Width:  |  Height:  |  Size: 360 KiB

View File

Before

Width:  |  Height:  |  Size: 440 KiB

After

Width:  |  Height:  |  Size: 440 KiB

View File

Before

Width:  |  Height:  |  Size: 87 KiB

After

Width:  |  Height:  |  Size: 87 KiB

View File

Before

Width:  |  Height:  |  Size: 133 KiB

After

Width:  |  Height:  |  Size: 133 KiB

View File

Before

Width:  |  Height:  |  Size: 133 KiB

After

Width:  |  Height:  |  Size: 133 KiB

View File

Before

Width:  |  Height:  |  Size: 133 KiB

After

Width:  |  Height:  |  Size: 133 KiB

View File

Before

Width:  |  Height:  |  Size: 141 KiB

After

Width:  |  Height:  |  Size: 141 KiB

View File

Before

Width:  |  Height:  |  Size: 139 KiB

After

Width:  |  Height:  |  Size: 139 KiB

View File

Before

Width:  |  Height:  |  Size: 147 KiB

After

Width:  |  Height:  |  Size: 147 KiB

View File

Before

Width:  |  Height:  |  Size: 144 KiB

After

Width:  |  Height:  |  Size: 144 KiB

View File

Before

Width:  |  Height:  |  Size: 146 KiB

After

Width:  |  Height:  |  Size: 146 KiB

Some files were not shown because too many files have changed in this diff Show More