Streamlit-дашборд за выходные: прототип без BI-системы

Кратко

Есть ситуация, которая повторяется у каждого продуктового аналитика: стейкхолдер просит «показать метрики» к среде, а полноценной BI-системы либо нет, либо она настроена на другие отчёты, либо тянет неделю согласований. Варианты обычно такие: слепить ноутбук с графиками (нельзя показать удалённо), выгрузить Excel (нельзя фильтровать интерактивно) или дождаться BI (поздно). Streamlit закрывает четвёртый вариант: рабочий интерактивный дашборд за выходные, который живёт по ссылке и не требует ничего, кроме Python.

Здесь речь именно о прототипе, а не о замене BI. Streamlit не победит Tableau или Power BI в продакшн-отчётности на сотни пользователей. Но он выигрывает там, где нужно быстро: показать заказчику воронку по синтетическим данным, проверить гипотезу сегментации в интерактиве, провести ревью метрик командой. Ниже — паттерны, которые я использую, чтобы прототип не превратился в технический долг, и ограничения, о которых лучше знать до старта.

Когда нужен прототип, а не BI

BI-система — это инфраструктура: источники, слои агрегаций, права доступа, регламенты обновления. Она хороша, когда метрики устоялись и их нужно доставлять регулярно большой аудитории. Прототип решает другую задачу: проверить, какие метрики вообще нужны, и согласовать их с заказчиком до того, как они пойдут в продакшн-пайплайн.

Типичные сценарии, где прототип на Streamlit уместнее BI:

  • Pre-sales и демо. Заказчик хочет «потрогать» дашборд до подписания контракта. Ссылка на живое приложение убедительнее скриншотов из Figma.
  • Гипотеза о сегментации. RFM-сегментация или когортный анализ требуют нескольких итераций с фильтрами. В BI на каждую итерацию — заявка на изменение вьюхи; в Streamlit меняешь код и перезагружаешь страницу.
  • Согласование дефиниций. «Активный пользователь = ≥1 сессии или ≥1 события?» Спор решается не в Jira, а двумя кнопками в боковой панели прототипа.
  • Внутреннее ревью метрик. Команда аналитиков смотрит на одну и ту же воронку в одном интерфейсе и спорит о цифрах, а не о том, у кого какой Excel открыт.

Главный критерий: если метрика ещё формируется, прототип уместен. Если метрика уже считается в пайплайне и нужна ежедневно сотне людей — это BI, и Streamlit тут лишний.

Минимальное приложение за вечер

Минимальный рабочий дашборд на Streamlit — это один файл, pandas и plotly. Никаких фреймворков, сборщиков, фронтенда. Ниже шаблон, с которого я начинаю: загрузка данных, боковые фильтры, KPI-строка и один график.

# app.py — минимальный прототип дашборда
import pandas as pd
import plotly.express as px
import streamlit as st

st.set_page_config(page_title="RFM-прототип", layout="wide",
                   initial_sidebar_state="expanded")


@st.cache_data
def load_data():
    return pd.read_csv("data/rfm.csv")


df = load_data()

# Фильтры в боковой панели
st.sidebar.title("Фильтры")
segments = st.sidebar.multiselect(
    "Сегменты:", df["segment"].unique(), default=df["segment"].unique()
)
monetary_range = st.sidebar.slider(
    "Доход:", int(df["monetary"].min()),
    int(df["monetary"].max()),
    (int(df["monetary"].min()), int(df["monetary"].max())),
)

df_filt = df[
    df["segment"].isin(segments)
    & df["monetary"].between(*monetary_range)
]

# KPI и график
st.title("RFM-анализ клиентов")
k1, k2, k3 = st.columns(3)
k1.metric("Клиентов", f"{len(df_filt):,}")
k2.metric("Ср. доход", f"{df_filt['monetary'].mean():,.0f} ₽")
k3.metric("Ср. частота", f"{df_filt['frequency'].mean():.1f}")

seg_counts = df_filt["segment"].value_counts().reset_index()
seg_counts.columns = ["segment", "count"]
fig = px.bar(seg_counts, x="segment", y="count", color="segment")
st.plotly_chart(fig, use_container_width=True)

Запуск — одна команда, браузер открывается сам:

pip install streamlit pandas plotly
streamlit run app.py

Это уже можно показать заказчику: фильтры работают, график перестраивается, ссылка localhost:8501 делается скриншотом для презентации. Дальше — прояснение деталей: структура приложения, кэширование, деплой.

Multipage: навигация без боли

Когда прототип разрастается до воронки, удержания и выручки в одном файле, код превращается в нечитаемую простыню. Streamlit с версии 1.30 поддерживает нативную multipage-навигацию через st.navigation — без роутинга, без Flask, без папок-хаков. В реальном проекте Product Analytics Dashboard я использую именно этот паттерн: один входной файл и пять страниц-модулей.

# app.py — точка входа multipage-дашборда
import streamlit as st

st.set_page_config(
    page_title="Product Analytics", page_icon="📊",
    layout="wide", initial_sidebar_state="expanded",
)

overview = st.Page("pages/overview.py", title="Overview", icon="🏠")
funnel = st.Page("pages/funnel.py", title="Funnel", icon="🪣")
retention = st.Page("pages/retention.py", title="Retention", icon="🔁")
revenue = st.Page("pages/revenue.py", title="Revenue", icon="💰")

pg = st.navigation(
    [overview, funnel, retention, revenue],
    position="sidebar",
)
st.sidebar.divider()
st.sidebar.caption("SaaS dataset • 8 000 users • 2024–2025")
pg.run()

Каждая страница — отдельный .py в папке pages/, со своим заголовком и виджетами. Плюсы: файлы короткие, каждое изменение перезагружает только текущую страницу, навигация автоматически попадает в боковую панель. Для прототипа это значит, что можно добавлять разделы по мере согласования с заказчиком, не ломая остальное.

@st.cache_data: один раз посчитать, много раз показать

Главная ловушка начинающего: загрузить и пересчитать данные на каждый чих виджета. Фильтр сдвинули — весь пайплайн перезапустился, страница зависла на 10 секунд. @st.cache_data решает это: функция с декоратором выполняется один раз для набора аргументов, результат кэшируется и переиспользуется.

# data.py — генерация и кэширование датасета
import numpy as np
import pandas as pd
import streamlit as st


@st.cache_data(show_spinner="Готовлю данные…")
def get_data() -> dict[str, pd.DataFrame]:
    rng = np.random.default_rng(42)
    n = 8000
    users = pd.DataFrame({
        "user_id": [f"u{i:05d}" for i in range(n)],
        "signup_date": pd.date_range("2024-01-01", periods=n, freq="h"),
        "channel": rng.choice(
            ["organic", "paid_search", "social"], size=n, p=[0.5, 0.3, 0.2]
        ),
    })
    # ... события, подписки
    return {"users": users}  # и т.д.

Правила, которые я держу в голове:

  • Кэшируется загрузка и тяжёлая агрегация. load_data(), cohort_retention_matrix(), funnel_counts() — все под декоратор.
  • Фильтрация не кэшируется. Она лёгкая и зависит от состояния виджетов — кэшировать бессмысленно.
  • Аргументы функции = ключ кэша. Меняешь аргумент — пересчёт. Поэтому тяжёлым функциям передают простые параметры (период, сегмент), а не весь DataFrame.
  • show_spinner — про user experience. Заказчик видит «Готовлю данные…» вместо молчаливого зависания.

Если прототип перешагивает за пару тысяч строк в кэше — имеет смысл переключиться на @st.cache_resource для объектов, которые не нужно сериализовать (соединения с БД, модели).

Фильтры-виджеты и перекладывание

Виджеты в Streamlit — это и фильтры, и способ быстро согласовать дефиниции. Боковая панель с multiselect, slider, date_input превращает статичный отчёт в интерактив, где стейкхолдер сам отвечает на свои вопросы. Но есть граница, которую переступать не стоит: дашборд не должен агрегировать, он должен перекладывать. Подробнее об этом принципе — в «Дашборды, которые не врут».

Плохой паттерн: дашборд сам пересчитывает retention из сырых событий на каждый клик. Хороший: пайплайн посчитал агрегаты один раз, дашборд читает parquet и фильтрует по осям. В прототипе это упрощается: тяжёлая агрегация живёт в кэшированной функции, а виджеты только срезают готовый DataFrame.

# pages/funnel.py — страница воронки с фильтрами
import pandas as pd
import plotly.express as px
import streamlit as st
from data import get_data

d = get_data()
users, events = d["users"], d["events"]

st.sidebar.title("Срезы")
channel = st.sidebar.multiselect(
    "Канал:", users["channel"].unique(), default=users["channel"].unique()
)

uids = users[users["channel"].isin(channel)]["user_id"]
ev = events[events["user_id"].isin(uids)]

steps = ["app_open", "signup", "activate", "start_trial", "subscribe"]
counts = {s: int(ev.loc[ev["event_name"] == s, "user_id"].nunique())
          for s in steps}

st.title("Воронка активации")
c1, c2, c3 = st.columns(3)
c1.metric("Регистрации", f"{counts['signup']:,}")
c2.metric("Активации", f"{counts['activate']:,}")
c3.metric("Оплаты", f"{counts['subscribe']:,}")

fig = px.bar(
    x=list(counts.keys()), y=list(counts.values()),
    labels={"x": "Шаг", "y": "Пользователей"},
)
st.plotly_chart(fig, use_container_width=True)

Здесь get_data() из data.py кэширован, фильтрация по каналу — лёгкая операция над уже загруженным DataFrame, агрегация воронки — простой groupby. Дашборд не пересчитывает метрики заново, он перекладывает готовые данные по срезам. Это держит прототип быстрым и честным: одни и те же цифры на любой странице.

Деплой за 5 минут

Прототип, который живёт только на localhost, — это половина дела. Streamlit Community Cloud делает деплой бесплатным и быстрым: репозиторий на GitHub → share.streamlit.io → выбор app.py → живой URL через минуту.

Чек-лист деплоя, который я использую:

  • requirements.txt с зафиксированными версиями: streamlit==1.45.*, pandas==2.*, plotly==5.*. Без пина версии Streamlit иногда ломается API между минорами.
  • app.py в корне или путь указан явно в интерфейсе деплоя.
  • Данные либо в репозитории (для синтетики), либо подключаются через st.secrets (для реальных источников). Никаких хардкод-токенов в коде.
  • .streamlit/config.toml для темы и заголовка, если хочется брендинга.

Альтернатива для приватных прототипов — Docker на внутреннем сервере или Render. Streamlit Community Cloud публичный, поэтому реальные данные клиентов туда нельзя; для внутреннего демо подходит. Для чувствительных данных лучше поднять контейнер внутри корпоративной сети.

Где Streamlit ломается

Прототип хорош, пока он прототип. Вот границы, после которых Streamlit превращается из ускорителя в тормоз:

  • Производительность на больших данных. Всё, что попадает в @st.cache_data, живёт в памяти процесса. Датасет в сотни мегабайт — уже проблема, гигабайты — точно нет. Решение: агрегировать на уровне SQL/parquet до Streamlit, не тащить сырые события.
  • Сложный UI. Кастомные компоненты, drag-and-drop, сложные таблицы с условным форматированием — всё это либо через сторонние библиотеки (streamlit-aggrid), либо через st.components.v1.html с самописным JS. Если UI начинает доминировать над данными, это уже не прототип.
  • Многопользовательность и права. Streamlit не имеет встроенной авторизации и ролевой модели. Для внутреннего прототипа это нормально; для дашборда на 50 пользователей с разным доступом — нет.
  • Real-time обновления. Авто-рефреш есть (st_autorefresh), но это не стриминг. Если нужны секунды-свежие данные, лучше смотреть на специализированные инструменты.
  • Версионность и откаты. Нет встроенной истории изменений дашборда. Каждый деплой — git-коммит, но откатывать вручную.

Главный сигнал, что прототип пора заменять: он стал ежедневным отчётом для команды. Как только дашборд открывают по привычке, а не чтобы проверить гипотезу, — это сигнал уносить агрегаты в пайплайн, а UI — в BI.

Выводы

Streamlit — это инструмент для узкого, но частого сценария: показать заказчику или команде интерактивные метрики за выходные, пока BI-система не настроена или не уместна. Он не заменяет пайплайн и не заменяет BI — он заполняет разрыв между «ноутбуком, который никто не откроет» и «BI-дашбордом, который согласуют через месяц».

Паттерны, которые держат прототип здоровым:

  • Multipage через st.navigation — короткие файлы, независимые страницы.
  • @st.cache_data на загрузку и тяжёлые агрегации — никаких пересчётов на каждый клик.
  • Перекладывание, а не агрегация — дашборд фильтрует готовые данные, не считает метрики заново. Подробнее в «Дашборды, которые не врут».
  • Деплой через Streamlit Community Cloud для публичных прототипов, Docker — для внутренних.
  • Честная граница с BI — прототип становится проблемой, когда превращается в ежедневный отчёт.

Рабочий пример multipage-дашборда с AARRR-воронкой, когортами и MRR — в Product Analytics Dashboard. А воспроизводимый пайплайн данных под капотом прототипа — отдельная тема в «Воспроизводимые пайплайны данных».

Если метрика ещё формируется и заказчик хочет её «потрогать» — Streamlit. Если метрика устоялась и её читают ежедневно — BI. Разница не в инструментах, а в фазе зрелости метрики.

Смотрите также

← К статьям