Как стать автором
Обновить

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

Уровень сложностиПростой
Время на прочтение11 мин
Количество просмотров5.5K

Всех приветствую! Зовут меня Павел, работаю в 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? Есть все основания полагать, что да.

Теги:
Хабы:
+10
Комментарии44

Публикации

Работа

Data Scientist
42 вакансии

Ближайшие события