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. Разница не в инструментах, а в фазе зрелости метрики.