Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b52fc42eb8 | |||
| cc9aae7425 | |||
| 192117f40e | |||
| 67c326c618 | |||
| 82de71ec56 | |||
| c963b1e5ac |
120
README.md
@@ -1,9 +1,115 @@
|
|||||||
# dano2025
|
# DANO 2025/2026 — исследование FinalTry.exe
|
||||||
|
|
||||||
dano 2025/2026 solve by FinalTry.exe team
|
Аналитический проект команды FinalTry.exe: исследуем связь пользовательских коммуникаций и заказов, проверяем форму зависимости (тренд, квадратичная регрессия) по общим и категорийным метрикам. Данные лежат в `dataset/ds.csv`, итог — собранная SQLite, статичные PNG и интерактивные HTML-графики.
|
||||||
|
|
||||||
## Dataset migrations
|
## Table of Contents
|
||||||
- Запуск всех миграций: `python migrate.py`
|
- [Quickstart](#quickstart)
|
||||||
- Посмотреть список и статус: `python migrate.py --list`
|
- [Repository Tree](#repository-tree)
|
||||||
- Принудительно переисполнить уже примененные миграции: `python migrate.py --force`
|
- [Repository Structure Explained](#repository-structure-explained)
|
||||||
- По умолчанию миграции работают с `dataset/ds.csv` и создают SQLite базу `dataset/ds.sqlite` (таблица `communications`).
|
- [Reproduce the Analysis](#reproduce-the-analysis)
|
||||||
|
- [Methodology](#methodology)
|
||||||
|
- [Results and Takeaways](#results-and-takeaways)
|
||||||
|
- [FAQ (for the jury)](#faq-for-the-jury)
|
||||||
|
- [Notes](#notes)
|
||||||
|
|
||||||
|
## Quickstart
|
||||||
|
```bash
|
||||||
|
# 1) создать окружение
|
||||||
|
python -m venv .venv
|
||||||
|
source .venv/bin/activate # win: .venv\Scripts\activate
|
||||||
|
|
||||||
|
# 2) установить зависимости
|
||||||
|
pip install pandas numpy scipy statsmodels scikit-learn matplotlib seaborn altair vl-convert-python
|
||||||
|
# альтернатива для Altair: pip install altair_saver && установка node
|
||||||
|
|
||||||
|
# 3) подготовить данные
|
||||||
|
cp <your-ds.csv> dataset/ds.csv
|
||||||
|
python migrate.py # соберёт dataset/ds.sqlite
|
||||||
|
|
||||||
|
# 4) запустить ключевые скрипты
|
||||||
|
python main_hypot/best_model_and_plots.py # базовые облака/тренды (PNG)
|
||||||
|
python main_hypot/quadreg.py # общая квадратика (PNG)
|
||||||
|
python main_hypot/category_quadreg.py # категории: корреляции + квадратика (PNG)
|
||||||
|
python main_hypot/new_plots.py # интерактивные HTML-графики
|
||||||
|
python new_divided_scatters.py # интерактив: активные/пассивные/общие
|
||||||
|
```
|
||||||
|
|
||||||
|
## Repository Tree
|
||||||
|
```
|
||||||
|
.
|
||||||
|
├── dataset/ # вход: ds.csv; выход: ds.sqlite
|
||||||
|
├── migrations/ # шаги подготовки данных (0001, 0002)
|
||||||
|
├── migrate.py # исполнитель миграций
|
||||||
|
├── preanalysis/ # EDA-утилиты (нормализация, агрегаты)
|
||||||
|
├── main_hypot/ # основной пайплайн графиков/регрессий
|
||||||
|
│ ├── best_model_and_plots.py
|
||||||
|
│ ├── category_quadreg.py
|
||||||
|
│ ├── quadreg.py
|
||||||
|
│ └── new_plots.py
|
||||||
|
├── new_divided_scatters.py # интерактивные облака по типам показов
|
||||||
|
├── old_data/ # архив ранних скриптов/данных (не в основном пайплайне)
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Repository Structure Explained (folder + file guide)
|
||||||
|
- `dataset/`
|
||||||
|
- `ds.csv` — входной CSV (кладёте сами).
|
||||||
|
- `ds.sqlite` — результат миграций, таблица `communications`, источник для всех скриптов.
|
||||||
|
- `migrations/`
|
||||||
|
- `0001_csv_to_sqlite.py` — читает CSV чанками, нормализует дату, пишет таблицу, создаёт индексы.
|
||||||
|
- `0002_cap_orders_to_one.py` — каппинг заказов: все значения >1 приводятся к 1 по дневным категориям.
|
||||||
|
- `migrate.py` — менеджер миграций (`--list`, `--force`, настраиваемые пути); обновляет `migrations_state.json`.
|
||||||
|
- `preanalysis/`
|
||||||
|
- `eda_utils.py` — нормализация пола/платформы, возрастные группы, суммарные метрики, флаги, агрегации по дням и клиентам.
|
||||||
|
- `main_hypot/`
|
||||||
|
- `best_model_and_plots.py` — загрузка клиентов из SQLite, фильтр выбросов, тренды LOWESS/Savitzky–Golay, плотностные облака; PNG в `main_hypot/orders_amt_total/`.
|
||||||
|
- `quadreg.py` — накладывает квадратичную регрессию на общий график заказов; вывод в ту же папку.
|
||||||
|
- `category_quadreg.py` — агрегации по категориям/комбо, корреляции и квадратичные регрессии; PNG в `main_hypot/category_analysis/`.
|
||||||
|
- `new_plots.py` — интерактивные Altair-графики (total + категории); HTML в `main_hypot/new_plots/`.
|
||||||
|
- `new_divided_scatters.py` — интерактивные облака активных/пассивных/общих показов; HTML в `new_plots/final_result/`.
|
||||||
|
- `old_data/` — архив ранних скриптов/данных, не участвует в текущем пайплайне.
|
||||||
|
|
||||||
|
## Reproduce the Analysis
|
||||||
|
1) **Подготовить данные:** положить `dataset/ds.csv`.
|
||||||
|
2) **Собрать SQLite:** `python migrate.py` (или вручную 0001 → 0002). Итог: `dataset/ds.sqlite`.
|
||||||
|
3) **Базовые графики:** `python main_hypot/best_model_and_plots.py` → PNG в `main_hypot/orders_amt_total/`.
|
||||||
|
4) **Общая квадратика:** `python main_hypot/quadreg.py` → PNG в `main_hypot/orders_amt_total/`.
|
||||||
|
5) **Категории:** `python main_hypot/category_quadreg.py` → PNG/корреляции в `main_hypot/category_analysis/`.
|
||||||
|
6) **Интерактив:**
|
||||||
|
- `python main_hypot/new_plots.py` → HTML в `main_hypot/new_plots/`;
|
||||||
|
- `python new_divided_scatters.py` → HTML в `new_plots/final_result/`.
|
||||||
|
Все скрипты читают `dataset/ds.sqlite`; без него упадут.
|
||||||
|
|
||||||
|
## Methodology
|
||||||
|
- **Модели/метрики:** AUC для вероятности заказа, R² по тренду, p-value и коэффициенты b1/b2 квадратичной регрессии `y ~ 1 + x + x²`.
|
||||||
|
- **Тренды:** сглаживание LOWESS или Savitzky–Golay для выявления формы зависимости.
|
||||||
|
- **Квадратичная форма:** базовый способ поймать нелинейность и насыщение/спад заказов при росте показов.
|
||||||
|
- **Очистка:** фильтрация выбросов по IQR и ограничение диапазонов осей.
|
||||||
|
|
||||||
|
|
||||||
|
## Results and Takeaways
|
||||||
|
- Пайплайн выдаёт: очищенные облака, трендовые линии, квадратичные кривые и метрики AUC/R²/p-values.
|
||||||
|
- Для total и категорий видны зоны насыщения/редкого спроса, что помогает подобрать лимиты показов.
|
||||||
|
- Комбинации категорий (например, avia+hotel) позволяют увидеть перекрёстный спрос.
|
||||||
|
- Плотностные альфы показывают концентрации клиентов и помогают читать облака без шума.
|
||||||
|
- Корреляционные матрицы подсвечивают связки «показы → клики → заказы» внутри категорий.
|
||||||
|
Бизнес-применение:
|
||||||
|
1) Тестировать потолки показов в сегментах с убывающей отдачей.
|
||||||
|
2) Приоритизировать категории с устойчивым положительным b1/b2.
|
||||||
|
3) Для насыщенных категорий использовать частоту/каппинг или смену креативов.
|
||||||
|
4) Отдельно мониторить avia/hotel-комбинации для выявления сезонности/пересечений спроса.
|
||||||
|
5) Использовать AUC для оценки пригодности скорингов/сегментов на заказ.
|
||||||
|
|
||||||
|
## FAQ (for the jury)
|
||||||
|
- **Что такое корреляция?** Мера линейной связи (-1…1) между двумя величинами.
|
||||||
|
- **Почему корреляция ≠ причинность?** Связь может быть опосредована или вызвана третьим фактором; нужны эксперименты/каузальные методы.
|
||||||
|
- **Что означает AUC?** Площадь под ROC-кривой: шанс, что модель верно ранжирует случайную пару (позитив/негатив).
|
||||||
|
- **Что означает R²?** Доля объяснённой вариации целевой величины моделью.
|
||||||
|
- **Почему binarize orders (0/1)?** Для расчёта AUC по факту наличия заказа, независимо от суммы.
|
||||||
|
- **Ограничения исследования?** Зависимость от структуры `ds.csv`, чувствительность Savitzky–Golay к размеру выборки, отсутствуют продакшн-оптимизации.
|
||||||
|
- **Чем отличается avia+hotel?** Комбинированная категория для ловли перекрёстного спроса; может показывать насыщение или иной профиль b2.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- Altair сохранение требует `vl-convert-python` или `altair_saver` + node.
|
||||||
|
- Savitzky–Golay окно по умолчанию большое (~501); на малых выборках уменьшайте, иначе будет ошибка.
|
||||||
|
- `old_data/` — архив, не трогает основной пайплайн.
|
||||||
|
|||||||
@@ -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 Обратная связь клиента
|
|
||||||
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
Т-Банк: коммуникации в городе
|
|
||||||
|
|
||||||
Датасет содержит данные о коммуникациях с клиентами внутри экосистемы Город Т-Банка, включая активные и пассивные каналы и реакцию пользователей на них. Для каждого клиента по дням указано количество показов, кликов и совершенных заказов по разным категориям сервисов: развлечения, транспорт, шопинг, отели, супермаркеты и авиабилеты. Клиент мог совершить покупку как в день коммуникации, так и позже, что особенно важно для сложных сервисов вроде путешествий. Дополнительно доступны демографические признаки клиентов и информация об устройстве.
|
|
||||||
|
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
"""Базовый набор расчётов и графиков: загрузка клиентов, фильтрация выбросов и построение трендов/квадратики."""
|
||||||
|
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import sys
|
import sys
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
"""Категорийный анализ: собирает агрегаты по категориям и строит корреляции/квадратичную регрессию по заказам."""
|
||||||
|
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import sys
|
import sys
|
||||||
@@ -47,6 +49,7 @@ COMBINED = {
|
|||||||
|
|
||||||
|
|
||||||
def load_raw(db_path: Path) -> pd.DataFrame:
|
def load_raw(db_path: Path) -> pd.DataFrame:
|
||||||
|
# Загружаем полную таблицу коммуникаций из SQLite
|
||||||
conn = sqlite3.connect(db_path)
|
conn = sqlite3.connect(db_path)
|
||||||
df = pd.read_sql_query("select * from communications", conn, parse_dates=["business_dt"])
|
df = pd.read_sql_query("select * from communications", conn, parse_dates=["business_dt"])
|
||||||
conn.close()
|
conn.close()
|
||||||
@@ -54,6 +57,7 @@ def load_raw(db_path: Path) -> pd.DataFrame:
|
|||||||
|
|
||||||
|
|
||||||
def build_client_by_category(df: pd.DataFrame) -> 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}
|
agg_spec = {f"{col}_{cat}": "sum" for col in BASE_COLUMNS for cat in CATEGORIES}
|
||||||
client = (
|
client = (
|
||||||
df.groupby("id")
|
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:
|
def plot_category_correlation(client: pd.DataFrame, cat: str, out_dir: Path) -> None:
|
||||||
|
# Быстрая тепловая карта корреляций для одной категории
|
||||||
cols = [f"{base}_{cat}" for base in BASE_COLUMNS]
|
cols = [f"{base}_{cat}" for base in BASE_COLUMNS]
|
||||||
corr = client[cols].corr()
|
corr = client[cols].corr()
|
||||||
|
|
||||||
@@ -190,6 +195,7 @@ def plot_quad_for_category(
|
|||||||
q_high_overrides: dict | None = None,
|
q_high_overrides: dict | None = None,
|
||||||
iqr_overrides: dict | None = None,
|
iqr_overrides: dict | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
# Строим облако, тренд и квадратичную регрессию для конкретной категории с опциональными настройками
|
||||||
y_col = f"orders_amt_{cat}"
|
y_col = f"orders_amt_{cat}"
|
||||||
x_col = f"avg_imp_per_day_{cat}"
|
x_col = f"avg_imp_per_day_{cat}"
|
||||||
out_dir = base_out_dir / y_col
|
out_dir = base_out_dir / y_col
|
||||||
|
|||||||
477
main_hypot/new_plots.py
Normal 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()
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
"""Обёртка для построения общей квадратичной регрессии заказов от среднего числа показов."""
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional, Tuple
|
from typing import Optional, Tuple
|
||||||
|
|
||||||
@@ -69,6 +71,7 @@ def plot_overall_quad(
|
|||||||
y_max: float = Y_MAX,
|
y_max: float = Y_MAX,
|
||||||
savgol_window: int = bmp.DEFAULT_SAVGOL_WINDOW,
|
savgol_window: int = bmp.DEFAULT_SAVGOL_WINDOW,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
# Рисуем три облака (из best_model_and_plots) и добавляем поверх квадратичную кривую
|
||||||
out_dir = bmp.BASE_OUT_DIR / Y_COL
|
out_dir = bmp.BASE_OUT_DIR / Y_COL
|
||||||
|
|
||||||
res = bmp.plot_clean_trend_scatter(
|
res = bmp.plot_clean_trend_scatter(
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
"""
|
||||||
|
Утилита для запуска файлов миграций из папки migrations и фиксации состояния применённых шагов.
|
||||||
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import importlib.util
|
import importlib.util
|
||||||
import json
|
import json
|
||||||
@@ -36,6 +40,7 @@ class Migration:
|
|||||||
|
|
||||||
|
|
||||||
def load_state(path: Path) -> Dict:
|
def load_state(path: Path) -> Dict:
|
||||||
|
# Достаём список уже применённых миграций, чтобы не выполнять их повторно
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
return {"applied": []}
|
return {"applied": []}
|
||||||
with path.open("r", encoding="utf-8") as f:
|
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:
|
def save_state(path: Path, state: Dict) -> None:
|
||||||
|
# Создаём файл состояния на диске после успешной миграции
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
with path.open("w", encoding="utf-8") as f:
|
with path.open("w", encoding="utf-8") as f:
|
||||||
json.dump(state, f, ensure_ascii=True, indent=2)
|
json.dump(state, f, ensure_ascii=True, indent=2)
|
||||||
|
|
||||||
|
|
||||||
def discover_migrations(root: Path) -> List[Migration]:
|
def discover_migrations(root: Path) -> List[Migration]:
|
||||||
|
# Подгружаем все *.py в каталоге миграций и ищем в них функцию run/apply
|
||||||
migrations: List[Migration] = []
|
migrations: List[Migration] = []
|
||||||
for module_path in sorted(root.glob("*.py")):
|
for module_path in sorted(root.glob("*.py")):
|
||||||
if module_path.name.startswith("_") or module_path.name == "__init__.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:
|
def record_applied(state: Dict, migration: Migration) -> None:
|
||||||
|
# Обновляем состояние, фиксируя идентификатор и имя файла миграции
|
||||||
applied = [entry for entry in state.get("applied", []) if entry.get("id") != migration.migration_id]
|
applied = [entry for entry in state.get("applied", []) if entry.get("id") != migration.migration_id]
|
||||||
applied.append(
|
applied.append(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
"""Первая миграция: переносит сырое CSV в SQLite и создаёт индексы."""
|
||||||
|
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -13,6 +15,7 @@ TABLE_NAME = "communications"
|
|||||||
|
|
||||||
|
|
||||||
def run(context) -> None:
|
def run(context) -> None:
|
||||||
|
# Определяем пути и режим выполнения (force позволяет пересоздать БД)
|
||||||
dataset_dir = Path(getattr(context, "dataset_dir", Path.cwd()))
|
dataset_dir = Path(getattr(context, "dataset_dir", Path.cwd()))
|
||||||
csv_path = getattr(context, "csv_path", dataset_dir / "ds.csv")
|
csv_path = getattr(context, "csv_path", dataset_dir / "ds.csv")
|
||||||
sqlite_path = getattr(context, "sqlite_path", dataset_dir / "ds.sqlite")
|
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)
|
sqlite_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
conn = sqlite3.connect(sqlite_path)
|
conn = sqlite3.connect(sqlite_path)
|
||||||
try:
|
try:
|
||||||
|
# Читаем CSV чанками, нормализуем дату и пишем в таблицу communications
|
||||||
first_chunk = True
|
first_chunk = True
|
||||||
for chunk in pd.read_csv(csv_path, chunksize=CHUNK_SIZE):
|
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")
|
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:
|
if first_chunk:
|
||||||
raise RuntimeError("Source CSV is empty, no rows were written to SQLite")
|
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}_id ON {TABLE_NAME}(id)")
|
||||||
conn.execute(f"CREATE INDEX IF NOT EXISTS idx_{TABLE_NAME}_business_dt ON {TABLE_NAME}(business_dt)")
|
conn.execute(f"CREATE INDEX IF NOT EXISTS idx_{TABLE_NAME}_business_dt ON {TABLE_NAME}(business_dt)")
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
"""Вторая миграция: ограничивает значения заказов 1 в день по каждой категории."""
|
||||||
|
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -17,6 +19,7 @@ ORDER_COLS = [
|
|||||||
|
|
||||||
|
|
||||||
def run(context) -> None:
|
def run(context) -> None:
|
||||||
|
# Работаем с уже собранной SQLite, путь берём из контекста мигратора
|
||||||
dataset_dir = Path(getattr(context, "dataset_dir", Path.cwd()))
|
dataset_dir = Path(getattr(context, "dataset_dir", Path.cwd()))
|
||||||
sqlite_path = getattr(context, "sqlite_path", dataset_dir / "ds.sqlite")
|
sqlite_path = getattr(context, "sqlite_path", dataset_dir / "ds.sqlite")
|
||||||
|
|
||||||
@@ -25,6 +28,7 @@ def run(context) -> None:
|
|||||||
|
|
||||||
conn = sqlite3.connect(sqlite_path)
|
conn = sqlite3.connect(sqlite_path)
|
||||||
try:
|
try:
|
||||||
|
# Каждую колонку приводим к максимуму 1, чтобы убрать аномальные значения
|
||||||
for col in ORDER_COLS:
|
for col in ORDER_COLS:
|
||||||
sql = f"""
|
sql = f"""
|
||||||
UPDATE communications
|
UPDATE communications
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
"""Генерирует раздельные Altair-облака для активных/пассивных/всех показов по категориям."""
|
||||||
|
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -30,6 +32,7 @@ COMBINED_WINDOW_FACTOR = 1.0
|
|||||||
|
|
||||||
|
|
||||||
def load_raw() -> pd.DataFrame:
|
def load_raw() -> pd.DataFrame:
|
||||||
|
# Читаем полные коммуникации из SQLite для дальнейших агрегаций
|
||||||
conn = sqlite3.connect(bmp.DB_PATH)
|
conn = sqlite3.connect(bmp.DB_PATH)
|
||||||
df = pd.read_sql_query("select * from communications", conn, parse_dates=["business_dt"])
|
df = pd.read_sql_query("select * from communications", conn, parse_dates=["business_dt"])
|
||||||
conn.close()
|
conn.close()
|
||||||
@@ -37,6 +40,7 @@ def load_raw() -> pd.DataFrame:
|
|||||||
|
|
||||||
|
|
||||||
def build_client(df: pd.DataFrame) -> pd.DataFrame:
|
def build_client(df: pd.DataFrame) -> pd.DataFrame:
|
||||||
|
# Агрегируем активные/пассивные показы и заказы по клиенту и считаем средние в день
|
||||||
agg_spec = {
|
agg_spec = {
|
||||||
**{f"active_imp_{c}": "sum" for c in CATEGORIES},
|
**{f"active_imp_{c}": "sum" for c in CATEGORIES},
|
||||||
**{f"passive_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]:
|
def compute_limits(df: pd.DataFrame, x_col: str, y_col: str) -> Tuple[float, float]:
|
||||||
|
# Автоматический подбор разумных лимитов осей по 99 перцентилю
|
||||||
x_q = df[x_col].quantile(0.99)
|
x_q = df[x_col].quantile(0.99)
|
||||||
y_q = df[y_col].quantile(0.99)
|
y_q = df[y_col].quantile(0.99)
|
||||||
x_max = float(max(0.1, x_q + 2.0))
|
x_max = float(max(0.1, x_q + 2.0))
|
||||||
@@ -81,6 +86,7 @@ def fit_quadratic(
|
|||||||
y_col: str,
|
y_col: str,
|
||||||
x_max: float,
|
x_max: float,
|
||||||
) -> Tuple[Optional[sm.regression.linear_model.RegressionResultsWrapper], dict]:
|
) -> Tuple[Optional[sm.regression.linear_model.RegressionResultsWrapper], dict]:
|
||||||
|
# Строим квадратичную регрессию и считаем AUC/R2 по тренду
|
||||||
if len(df) < 3:
|
if len(df) < 3:
|
||||||
return None, {}
|
return None, {}
|
||||||
x = df[x_col].to_numpy()
|
x = df[x_col].to_numpy()
|
||||||
@@ -119,6 +125,7 @@ def scatter_trend_quad(
|
|||||||
y_override: float | None = None,
|
y_override: float | None = None,
|
||||||
x_scale_factor: float | None = None,
|
x_scale_factor: float | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
# Пайплайн для одной комбинации x/y: фильтр, тренд, регрессия и сохранение HTML
|
||||||
# Авто-лимиты
|
# Авто-лимиты
|
||||||
x_max, y_max = compute_limits(df, x_col, y_col)
|
x_max, y_max = compute_limits(df, x_col, y_col)
|
||||||
if x_override is not None:
|
if x_override is not None:
|
||||||
|
|||||||
582
old data/best_model_and_plots.py
Normal 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()
|
||||||
|
Before Width: | Height: | Size: 75 KiB After Width: | Height: | Size: 75 KiB |
|
Before Width: | Height: | Size: 100 KiB After Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 83 KiB After Width: | Height: | Size: 83 KiB |
|
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 83 KiB After Width: | Height: | Size: 83 KiB |
|
Before Width: | Height: | Size: 111 KiB After Width: | Height: | Size: 111 KiB |
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 82 KiB After Width: | Height: | Size: 82 KiB |
|
Before Width: | Height: | Size: 101 KiB After Width: | Height: | Size: 101 KiB |
|
Before Width: | Height: | Size: 87 KiB After Width: | Height: | Size: 87 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 143 KiB After Width: | Height: | Size: 143 KiB |
353
old data/category_quadreg.py
Normal 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()
|
||||||
|
Before Width: | Height: | Size: 81 KiB After Width: | Height: | Size: 81 KiB |
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 80 KiB After Width: | Height: | Size: 80 KiB |
|
Before Width: | Height: | Size: 80 KiB After Width: | Height: | Size: 80 KiB |
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 80 KiB After Width: | Height: | Size: 80 KiB |
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 135 KiB After Width: | Height: | Size: 135 KiB |
|
Before Width: | Height: | Size: 119 KiB After Width: | Height: | Size: 119 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 91 KiB After Width: | Height: | Size: 91 KiB |
|
Before Width: | Height: | Size: 422 KiB After Width: | Height: | Size: 422 KiB |
|
Before Width: | Height: | Size: 177 KiB After Width: | Height: | Size: 177 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 122 KiB After Width: | Height: | Size: 122 KiB |
|
Before Width: | Height: | Size: 124 KiB After Width: | Height: | Size: 124 KiB |
|
Before Width: | Height: | Size: 130 KiB After Width: | Height: | Size: 130 KiB |
|
Before Width: | Height: | Size: 405 KiB After Width: | Height: | Size: 405 KiB |
|
Before Width: | Height: | Size: 387 KiB After Width: | Height: | Size: 387 KiB |
|
Before Width: | Height: | Size: 360 KiB After Width: | Height: | Size: 360 KiB |
|
Before Width: | Height: | Size: 256 KiB After Width: | Height: | Size: 256 KiB |
|
Before Width: | Height: | Size: 440 KiB After Width: | Height: | Size: 440 KiB |
|
Before Width: | Height: | Size: 87 KiB After Width: | Height: | Size: 87 KiB |
|
Before Width: | Height: | Size: 133 KiB After Width: | Height: | Size: 133 KiB |
|
Before Width: | Height: | Size: 133 KiB After Width: | Height: | Size: 133 KiB |
|
Before Width: | Height: | Size: 133 KiB After Width: | Height: | Size: 133 KiB |
|
Before Width: | Height: | Size: 141 KiB After Width: | Height: | Size: 141 KiB |
|
Before Width: | Height: | Size: 139 KiB After Width: | Height: | Size: 139 KiB |
|
Before Width: | Height: | Size: 147 KiB After Width: | Height: | Size: 147 KiB |
|
Before Width: | Height: | Size: 144 KiB After Width: | Height: | Size: 144 KiB |
|
Before Width: | Height: | Size: 146 KiB After Width: | Height: | Size: 146 KiB |