BI умер, да здравствует BI

от автора

Всех приветствую! Зовут меня Павел, работаю в Datapulse. Создаем различные решения для автоматизации DWH.

Мы живем в переходном периоде, когда на смену традиционным инструментам и подходам в data-engineer приходят новые. То, что еще вчера казалось стандартом де-факто, сегодня все чаще воспринимается как анахронизм. Пример тому — война GUI («гуевых») ETL инструментов со скриптовыми закончилась безоговорочной победой последних (все сказанное здесь является мнением автора и не претендует на истину в последней инстанции). Скрипты взяли верх благодаря своей масштабируемости, возможности применения Git и лучшей интеграции в процессы CI/CD.

А что же с BI? Мы привыкли к мастодонтам: PowerBI, Tableau, Qlik. Либо open-source: Superset, Metabase. Они в свое время стали стандартом, захватив большую часть рынка, а sales manager получали очень неплохие премии за их внедрение. И, казалось, время молочных рек и кисельных берегов не закончится никогда. Компании продолжат тратить кучу денег на красивенькие графики, которыми не пользуются, а в сообществе аналитиков будут лишь рассуждать о том, как доказать упертому менеджеру, что дашборд-вундервафля в PowerBI гораздо удобнее старого доброго Excel (шутка).

А времена эти, если и не прошли, то близятся к закату. Старые короли чахнут и умирают. Приветствуем новых!

Чем плохи стандартные BI-инструменты? Посмею перечислить их недостатки:

  • сложное внедрение и обслуживание

  • минимальные возможности интеграции с Git (или вообще их отсутствие)

  • слабые возможности кастомизации (потребуется неслабая экспертиза)

  • высокая/очень высокая стоимость (если проприетарное ПО)

  • сложно разобраться и поддерживать (если open-source)

— Довольно предисловия! Даже если я соглашусь со сказанным, чем заменить стандартный BI? – спросит читатель.
— У меня есть для вас пара блюд! – отвечу я.

Скриптовые BI или BI as a code. Появились не так давно, но уже набирают бешенную популярность. Сравнивать различные решения в этой статья я не буду. Есть замечательная обзорная статья, где описываются наиболее популярные фреймворки. Все примеры скриптового BI в этой статье будут основаны на фреймворке streamlit, потому что автор более чем хорошо с ним знаком.

Сперва определим явные достоинства скриптового BI в отношении стандартного BI:

  • Элементарная установка в пару команд

  • Простой обмен дашбордами (обмениваетесь лишь файлами)

  • Априори дружит с Git

  • Безграничные возможности кастомизации (можно реализовать полноценный интерфейс!)

  • Бесплатно (условно)

Практическое применение

Перейдем же к непосредственным примерам.

Установка более чем простая.

pip install streamlit

Пишем элементарный код для графика, который читает витрину с заказами из PostgreSQL и верстает график.

from sqlalchemy import create_engine import pandas as pd import streamlit as st  # Создаем движок подключения engine = create_engine('postgresql+psycopg2://postgres:admin@localhost:5432/database')  # Выполняем запрос query = """ select     order_month,     sum(total_orders) as orders_count from dm.mart_order group by     order_month """ df = pd.read_sql_query(query, engine) # График st.bar_chart(df, x="order_month", y="orders_count", x_label="Месяц", y_label="Кол-во заказов")

Запускаем streamlit в браузере

streamlit run app.py
Результат

Результат

Конечно, элементарный bar chart – слишком скучно. Давайте добавим сюда фильтры по периоду и продукту. Обращаю внимание, что из коробки streamlit умеет кэшировать данные, чтобы в нашем случае не выполнять тяжелые SQL по нескольку раз.

Измененный python-скрипт
from sqlalchemy import create_engine import pandas as pd import streamlit as st  # Создаем движок подключения engine = create_engine('postgresql+psycopg2://postgres:admin@localhost:5432/databaase')  # получаем и кэшируем данные spinner_text = "Чтение данных..." @st.cache_data(ttl=300, max_entries=1000, show_spinner=spinner_text) def get_unique_values(column):     """Получаем уникальные значения столбца витрины"""     return  pd.read_sql_query(         f"select distinct {column} from dm.mart_order order by {column}",         engine)  @st.cache_data(ttl=300, max_entries=1000, show_spinner=spinner_text) def get_order_data(         date_from = None,         date_to = None,         city = None,         product = None ):     """Получаем данные из витрины"""     filter = ''     if date_from:         filter += f" and order_month>='{str(date_from)}'"     if date_to:         filter += f" and order_month<='{str(date_to)}'"     if city:         city = "','".join(city)         filter += f" and city_name in ('{city}')"     if product:         product = "','".join(product)         filter += f" and product_category in ('{str(product)}')"     query = f""" select     order_month,     sum(total_orders) as orders_count from dm.mart_order where 1=1     {filter} group by     order_month """     return pd.read_sql_query(query, engine)
Результат

Результат

Набор визуализаций в streamlit скудный, но можно использовать сторонние библиотеки. Такие как plotly, altair, vega lite и другие. Мне лично по душе echarts. У него большие возможности, но достаточно сложный интерфейс в виде json-параметров.

pip install streamlit-echarts

Json-параметров огромное количество, разобраться в них с нуля сложно, но можно упростить себе жизнь и использовать ChatGPT.

Пример promt и ответа

Есть pandas.dataframe со столбцами order_month, orders_count и average_order_amount. Сформируй json с параметрами для графика streamlit_echarts , по оси X orders_month, по оси Y orders_count с типом bar, цвет orders_count#1E90FF. По дополнительной оси Y average_order_amount c типом line, цвет — #B0E0E6. У графика должен быть title — Заказы по месяцам.

Ответ ChatGPT

{     "title": {         "text": "Заказы по месяцам",         "left": "center",         "textStyle": {             "color": "#FFFFFF"  # Цвет заголовка         }     },     "tooltip": {         "trigger": "axis"     },     "legend": {         "data": ["Количество заказов", "Средний чек"],          "bottom": "0%",   # Легенда внизу          "left": "center", # Выравнивание по центру по горизонтали          "orient": "horizontal",  # Горизонтальная ориентация         "textStyle": {             "color": "#FFFFFF"  # Цвет заголовка         }     },     "xAxis": {         "type": "category",         "data": df['order_month'].tolist()     },     "yAxis": [         {             "type": "value",             "name": "Количество заказов"         },         {             "type": "value",             "name": "Средний чек",             "position": "right"         }     ],     "series": [         {             "name": "Количество заказов",             "type": "bar",             "data": df['orders_count'].tolist(),             "itemStyle": {                 "color": "#1E90FF"             },             "yAxisIndex": 0         },         {             "name": "Средний чек",             "type": "line",             "data": df['average_order_amount'].tolist(),             "itemStyle": {                 "color": "#B0E0E6"             },             "yAxisIndex": 1         }     ] } 
Оформление графика с echarts

Оформление графика с echarts

SQL-запросы лучше хранить отдельно и вызывать их как jinja-скрипты. И перенести туда всю логику формирования SQL-запроса на основании фильтров. Также большие json с параметрами echarts-графиков тоже в отдельные файлы.

Пример jinja-скрипта
select     order_month,     sum(total_orders) as orders_count,     sum(total_order_amount)/sum(total_orders) as average_order_amount from dm.mart_order where 1=1     {% if date_from %}and order_month>='{{ date_from }}'{% endif %}     {% if date_to %}and order_month<='{{ date_to }}'{% endif %}     {% if city %}and city_name in ({% for item in city %}'{{ item }}'{% if not loop.last %},{% endif %}{% endfor %}){% endif %}     {% if product %}and product_category in ({% for item in product %}'{{ item }}'{% if not loop.last %},{% endif %}{% endfor %}){% endif %} group by     order_month
Переписанный python-скрипт с использованием jinja
@st.cache_data(ttl=300, max_entries=1000, show_spinner=spinner_text) def get_order_data(         date_from = None,         date_to = None,         city = None,         product = None ):     """Получаем данные из витрины"""     with open('get_order_data.sql', "r", encoding="utf-8") as f:         file = f.read()     script = jinja2.Template(file).render(         date_from=str(date_from) if date_from else None,         date_to=str(date_to) if date_to else None,         city=city,         product=product     )     st.write(script)     return pd.read_sql_query(script, engine)

Добавим на дашборд еще графиков для более красочного примера.

Пример дашборда в streamlit

Пример дашборда в streamlit
Python-скрипт
from sqlalchemy import create_engine import pandas as pd import streamlit as st from streamlit_echarts import st_echarts import jinja2 import datetime import json from typing import Literal st.set_page_config(layout="wide")  # Создаем движок подключения engine = create_engine('postgresql+psycopg2://postgres:admin@localhost:5432/database')  # получаем и кэшируем данные spinner_text = "Чтение данных..." @st.cache_data(ttl=300, max_entries=1000, show_spinner=spinner_text) def get_unique_values(column):     """Получаем уникальные значения столбца витрины"""     return  pd.read_sql_query(         f"select distinct {column} from dm.mart_order order by {column}",         engine)  @st.cache_data(ttl=300, max_entries=1000, show_spinner=spinner_text) def get_extreme_value(column, type: Literal["min", "max"]):     """Получаем экстремумы"""     return  pd.read_sql_query(         f"select {type}({column}) from dm.mart_order",         engine).iloc[0, 0]  @st.cache_data(ttl=300, max_entries=1000, show_spinner=spinner_text) def get_order_data(         date_from = None,         date_to = None,         city = None,         product = None,         price = None ):     """Получаем данные из витрины"""     with open('get_order_data.sql', "r", encoding="utf-8") as f:         file = f.read()     script = jinja2.Template(file).render(         date_from=str(date_from) if date_from else None,         date_to=str(date_to) if date_to else None,         city=city,         product=product,         price=price     )     return pd.read_sql_query(script, engine)  @st.cache_data(ttl=300, max_entries=1000, show_spinner=spinner_text) def get_total_data(         date_from = None,         date_to = None,         city = None,         product = None,         price = None ):     """Получаем данные из витрины"""     with open('get_total_data.sql', "r", encoding="utf-8") as f:         file = f.read()     script = jinja2.Template(file).render(         date_from=str(date_from) if date_from else None,         date_to=str(date_to) if date_to else None,         city=city,         product=product,         price=price     )     return pd.read_sql_query(script, engine)  @st.cache_data(ttl=300, max_entries=1000, show_spinner=spinner_text) def get_order_speed(         date_from = None,         date_to = None,         city = None,         product = None,         last = False ):     """Получаем данные из витрины"""     with open('speed.sql', "r", encoding="utf-8") as f:         file = f.read()     script = jinja2.Template(file).render(         date_from=str(date_from) if date_from else None,         date_to=str(date_to) if date_to else None,         city=city,         product=product     )     return pd.read_sql_query(script, engine)   # фильтрация sidebar = st.sidebar col1, col2 = sidebar.columns((1,1)) with col1:     from_date = st.date_input("От", value=None) with col2:     to_date = st.date_input("До", value=None) city = sidebar.multiselect("Город", options=get_unique_values('city_name')) product = sidebar.multiselect("Продукт", options=get_unique_values('product_category')) price = sidebar.slider(     "Цена",     min_value=get_extreme_value("total_order_amount", 'min'),     max_value=get_extreme_value("total_order_amount", 'max'),     step=1.0 )  # метрики st.caption('За последний месяц') total_current_data = get_total_data(     from_date,     to_date,     city,     product,     price=price )  col1, col2, col3, col4 = st.columns((1,1,1,1)) with col1:     total_order = "{:,.0f}".format(total_current_data['total_orders'].tolist()[0])     st.metric("Кол-во заказов", value=total_order, delta=f"{total_current_data['delta_total_orders'].tolist()[0]} %") with col2:     total_order_amount = "{:,.0f}".format(total_current_data['total_order_amount'].tolist()[0])     st.metric("Объем продаж", value=total_order_amount, delta=f"{total_current_data['delta_total_order_amount'].tolist()[0]} %") with col3:     average_order_amount = "{:,.0f}".format(total_current_data['average_order_amount'].tolist()[0])     st.metric("Среднее кол-во позиций в заказе", value=average_order_amount, delta=f"{total_current_data['delta_average_order_amount'].tolist()[0]} %") with col4:     returned_orders = "{:,.0f}".format(total_current_data['returned_orders'].tolist()[0])     st.metric("Кол-во возвратов", value=returned_orders, delta=f"{total_current_data['delta_returned_orders'].tolist()[0]} %", delta_color='inverse')  # График df = get_order_data(     from_date,     to_date,     city,     product,     price=price ) df['order_month'] = df['order_month'].apply(lambda x: x.strftime('%Y-%m'))  last_speed_df = get_order_speed(     from_date,     to_date,     city,     product,     last=True ) speed_df = get_order_speed(     from_date,     to_date,     city,     product ) speed_df['order_month'] = speed_df['order_month'].apply(lambda x: x.strftime('%Y-%m'))  col1, col2 = st.columns((1,1)) with col1:     with open('orders_by_month.json', "r", encoding="utf-8") as f:         orders_by_month_json = f.read()     orders_by_month_options = jinja2.Template(orders_by_month_json).render(         order_month=df['order_month'].tolist(),         orders_count=df['orders_count'].tolist(),         average_order_amount=df['average_order_amount'].tolist()     )      st_echarts(options=json.loads(orders_by_month_options), height="400px")  with col2:     with open('returned_by_month.json', "r", encoding="utf-8") as f:         returned_by_month = f.read()     returned_by_month_options = jinja2.Template(returned_by_month).render(         order_month=df['order_month'].tolist(),         returned_count=df['returned_count'].tolist(),         returned_percent=df['returned_percent'].tolist(),         max_count=df['orders_count'].max()     )      st_echarts(options=json.loads(returned_by_month_options), height="400px")  col1, col2 = st.columns((1,4)) with col1:     with open('order_speed.json', "r", encoding="utf-8") as f:         last_speed_json = f.read()     last_speed_options = jinja2.Template(last_speed_json).render(         speed=last_speed_df['avg_speed'].tolist()[0]     )      st_echarts(options=json.loads(last_speed_options), height="400px") with col2:     with open('order_speed_by_month.json', "r", encoding="utf-8") as f:         speed_by_month_json = f.read()     speed_by_month_options = jinja2.Template(speed_by_month_json).render(         speed=speed_df['avg_speed'].tolist(),         order_month=speed_df['order_month'].tolist()     )      st_echarts(options=json.loads(speed_by_month_options), height="400px") 

Вообще, у echarts огромное количество различных вариантов визуализаций, что на одно лишь знакомство с ними уйдет несколько часов.

Есть возможность верстать не только графики, но и таблицы.

Пример таблицы

Пример таблицы

В streamlit есть возможность создавать многостраничные приложения, если логика вашего дашборда не помещается на одну страницу.

Также при переходе на новую страницу можно передавать дополнительные параметры в url, тем самым воссоздавая подобие drill-down. К примеру, у вас есть приведенная выше таблица, и чтобы посмотреть продажи в деталях по конкретному продукту можно добавить ссылку на новую страницу, в url которой будут передано наименование продукта.

table_data["link"] = table_data.apply(     lambda row: f"http://localhost:8501/product_detail/?name={row['product_category']}",     axis=1 )
Таблица со ссылкой

Таблица со ссылкой

Развертывание

Сценарий внедрения подобного инструмента на множество пользователей мне лично видится следующий – создание под каждый дашборд репозиторий в Git, и пользователи запускают дашборды на своих машинах. У пользователя должно быть доступ в СУБД и установленный python. Тем самым мы нивелируем множество проблем с нагрузкой на сервер BI, а ролевой доступ реализуется через раздачу прав конкретным пользователям к конкретным репозиториям.

Streamlit можно развернуть и на сервере, даже можно реализовать аутентификацию в интерфейсе и реализовать кэширование данных под конкретную сессию, но при большом количестве одновременных пользователей наверняка будут проблемы.

С вариантом развертывания на собственной машине и запуска готового дашборда (по инструкции) в состоянии справиться даже пользователь без каких-либо знаний.

Источники данных

В примерах выше был показан вариант с получением данных из хранилища на PostgreSQL. Конечно же, можно читать данные из всех СУБД, благо драйвера python есть практически под каждую.

Также можно читать данные из CSV, Excel: в Streamlit есть встроенный функционал загрузки файлов.

В том числе можно продвинуться дальше и сделать дашборды над датасетами из DuckDB, который сейчас произвел, на мой скромный взгляд, революцию в data-движках. Но здесь материала хватит на новую статью, поэтому не будем углубляться.

Применение и ограничения

Какое же применение скриптовым BI? Могут ли они на текущий момент являться заменой стандартным решениям?

Варианты применения:

  • нужно быстро для менеджмента реализовать дашборд

  • много data-команд работают над дашбордами, поэтому требуется контроль версий и возможность отката

  • бесплатный инструмент с быстрым внедрением

  • автоматизация развертывания (CI/CD)

  • перенос нагрузки на машины пользователей

  • когда требуются широкие возможности кастомизации

  • прекрасно вписывается в Data mesh

Ограничения, конечно же, тоже есть:

  • можно, но сложно развернуть на сервере (потребуется аутентификация, кэш в рамках сессии и, скорее всего, большого количества пользователей не выдержит)

  • не подходит для Self-service аналитики по причине своей сложности (хотя я лично в Self-service аналитику в принципе не верю)

  • некоторые фичи отсутствуют: drag-n-drop, drill-though или drill-down (хотя, вроде бы, во фреймворке Rill можно сделать последние два)

  • более сложно рассчитать вычисляемые меры (допустим, в DAX весьма просто посчитать дельту к прошлому периоду, в SQL это сделать гораздо сложнее)

  • сложно реализовать row-level доступ

Заключение

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

Но data-аналитики сейчас все чаще имеют экспертизу в python. И все чаще используют pandas или другие фреймворки. Пересесть с громоздкого монолита с небольшими возможностями кастомизации на визуализацию в том же python они смогут. При этом, смогут делать чуть ли не полноценные интерфейсы, превращая дашборды в космолеты.

И тогда встает главный вопрос – зачем компании тратить уйму денег на лицензии проприетарного решения, рискуя при этом получить vendor-lock?

Придем ли мы к тому же, к чему пришли в противостоянии GUI ETL и ETL as a code? Есть все основания полагать, что да.


ссылка на оригинал статьи https://habr.com/ru/articles/905156/