Привет, Хабр. Сегодняшняя статья будет интересна тем, кто хочет собрать простой OLAP‑куб для анализа данных, чтобы понять, как он устроен и работает. Экспериментировать будем с помощью TinyOLAP, одного из немногих OpenSource движков на Python.

Меня зовут Дмитрий Богданов, я старший бэкенд‑разработчик Polymatica в компании SL Soft. Некоторое время назад я изучал возможности различных существующих фреймворков для создания OLAP‑кубов, в том числе, и TinyOLAP.

Терминология

OLAP (On‑Line Analytical Processing, оперативная аналитическая обработка данных) — метод анализа, основанный на многомерных структурах данных и объединении информации из различных источников. Сегодня это один из основных подходов в BI‑системах, поскольку позволяет быстро обрабатывать большие массивы информации.

OLAP‑куб (OLAP cube) — многомерная структура данных, которая помогает их лучше понимать и анализировать. Можно представить себе игрушечный кубик, при вращении которого вы видите его разные стороны и таким образом изучаете, что происходит внутри бизнеса. Работая с OLAP‑кубами можно принимать более осознанные решения на основе накопленных статистических данных.

Измерения (dimension) — специальная структура данных, которая позволяет описать их различные аспекты. Продолжим описание на примере кубика. Каждая из его сторон является измерением = аспектом: продукты, даты, регионы и иные характеристики характеристики. Каждый аспект заранее создан и известен, это как большой справочник, в который уже внесены записи.

Показатели (measures) — количественные или качественные свойства данных. По ним мы можем произвести группировку или агрегацию. Вернемся к нашему образу — в каждое пересечение измерений мы можем внести свойство этих данных, например, количество продаж за дату, сумму продаж по товарам.

Строим свой куб в TinyOLAP

Оговорюсь, что практической пользы от построения именно этого куба вы получите немного, но познакомитесь поближе с TinyOLAP, что поможет вам работать с собственными датасетами.

Нам потребуются библиотеки TinyOLAP и Pandas. Установим их:

pip install tinyolap 
pip install pandas 

Начнем с создания объекта Database, то есть условной базы данных, которая будет хранить все сущности. В нашем случае будем использовать параметр in_memory=True — все данные хранятся в памяти. Если указать параметр False, то будет использована SQLite3 и по умолчанию создается новая БД.

from tinyolap.database import Database 
db = Database("forms", in_memory=True)  
db.caching = False

Далее создадим измерения (dimension). Они включают в себя только уникальные элементы измерений. Тестовые данные и код можно взять у меня на GitHub.

# названия файлов с измерениями 
dimension_names = ["dim_company", "dim_assets", "dim_date"] 
cwd = os.getcwd() 
 
dimensions = [] 
 
for name in dimension_names: 
	# строим путь до файла 
	file_name = os.path.join(cwd, "data", f"{name}.csv") 
     
	# создаем новое измерение в TinyOLAP и открываем его на редактирование 
	dim = db.add_dimension(name).edit() 
	df = pd.read_csv(file_name) 
     
	# делаем список уникальных элементов измерения 
	unique_dim = df[df.columns[0]].unique().astype(str) 
     
	# добавляем в наше созданное измерение 
	dim.add_many(unique_dim) 
     
	# добавляем агрегирующий элемент, который включает все остальные 
	if len(unique_dim) > 1: 
    		dim.add_many("All", tuple(unique_dim)) 

	# "закрепляем" изменения измерения 
	dim.commit() 

Далее создадим показатели. В реализации TinyOLAP показатели создаются как измерения, но со своими особенностями: данные показателей могут быть только числовыми (целочисленные и с плавающей точкой). Создадим общее измерение и поместим названия измерений, которые есть в данных.

data_file_name = os.path.join(cwd, "data", "data.csv") 
 
df = pd.read_csv(data_file_name) 
 
measures_target_names = ["price", "quantity", "amount"] 
 
# создаем общий показатель "measures" 
measures = db.add_dimension("measures").edit() 
 
# добавляем в него элементы измерений из исходных данных  
measures.add_many(measures_target_names) 
 
# "закрепляем" изменения измерения 
measures.commit()  
dimension_names.append('measures') 

Переходим к созданию самого куба и заполнения его данными.

# создаем OLAP куб. В качестве измерений передадим все созданные измерения и показатели 
cube = db.add_cube(name="data", dimensions=dimension_names) 
     
dim_count = 3 
 
for i in range(dim_count): 
	df[df.columns[i]] = df[df.columns[i]].astype(str) 

# заполняем данные из файлы. Путь для каждого значения имеет вид 
# (измерение1,...,измерениеn,показатель1) 
# (измерение1,...,измерениеn,показательn) 
for row in df.itertuples(index=False): 
	for cur_measure_index, cur_measure in enumerate(measures_target_names): 
    	address = tuple(row[:dim_count]) + (cur_measure, ) 
    	value = row.__getattribute__(cur_measure) 
    	cube.set(address, value) 

У нас создан куб, но пока он бесполезен и является просто табличкой с данными.

Далее будем разбираться с «магией» OLAP кубов. Создадим срез данных в нужном нам ракурсе:

view = View(cube, definition={ 
    	"filters": {"dimensions": ["dim_assets", "dim_date"]}, 
    	"columns": {"dimensions": ["measures"]}, 
    	"rows": {"dimensions": ["dim_company"]} 
	}) 
 
print(view.to_console_output()) 

Проблемы и особенности TinyOLAP

Явные – неявные фильтры

Видим, что значений нет. Так произошло, поскольку мы не указали в фильтре явные значения — TinyOLAP взял значения по умолчанию, то есть первые внесенные. Пофиксим это! Для этого используем ранее созданные агрегирующие элементы All.

view = View(cube, definition={ 
    	"filters": {"dimensions": [{"dimension": "dim_assets", "members": "All"}, {"dimension": "dim_date", "members": "All"}]}, 
    	"columns": {"dimensions": ["measures"]}, 
    	"rows": {"dimensions": ["dim_company"]} 
	}) 
 
print(view.to_console_output()) 

Ура, мы получили первые наши результаты. Но… данные не соответствуют реальности. Мы столкнулись со следующей особенностью TinyOLAP: по умолчанию функция агрегации — суммирование, но для цены данный метод не подходит. Для таких случаев есть правила (rules).

Правила

Правила (rules) — понятие, относящееся к TinyOLAP. Они нужны для определения пользовательских вычислений или бизнес‑логики, которая должна быть назначена кубу. По умолчанию, как мы уже выяснили ранее, существует только агрегация для вложенных сущностей. Поэтому нам нужно создать свои правила для цены и суммы.

@rule("data", trigger=["measures:amount"], scope=RuleScope.BASE_LEVEL) 
def rule_cost(c: Cell): 
	quantity = c["quantity"] 
	price = c["price", c.BYPASS_RULES] 
	if quantity and price: 
    	return float(price) * float(quantity) 
	return c.CONTINUE 

@rule("data", trigger=["measures:price"], scope=RuleScope.AGGREGATION_LEVEL) 
def rule_price(c: Cell): 
	quantity = c["quantity"] 
	amount = c["amount"] 
	if quantity != 0: 
    	return float(amount / quantity) 
	return c.CONTINUE 

cube.register_rule(rule_cost) 
cube.register_rule(rule_price) 

Видим, что сумма стала верной, но цена по‑прежнему некорректна. Тут проблема в форматировании вывода в консоль.

Варианты вывода данных

Ранее для визуализации данных использовали метод to_console_log. Однако в TinyOLAP существуют и другие варианты представления отчетов: to_dict, to_json, to_html.

Воспользуемся функцией to_dict к объекту view.

print(view.to_dict()) 
 
{'contentType': 'TinyOlap.View', 'version': '0.8.16', 'uid': '0bab13b6-b8ef-49c8-9585-0e4aaadbc085', 'database': 'forms', 'cube': 'data', 'name': '7f4b46c5', 'title': '', 'description': '', 'zeroSuppressionOnRows': False, 'zeroSuppressionOnColumns': False, 'statistics': {'lastRefresh': '2024-03-06T00:55:19.211856', 'refreshDuration': 0.001097, 'cellsCount': 3, 'rowsCount': 1, 'columnsCount': 3, 'rowDimensionsCount': 1, 'columnDimensionsCount': 1, 'executedRules': 22, 'executedCellRequests': 49, 'executedCellAggregations': 88}, 'axes': {'filters': {'dimensions': [{'dimension': 'dim_assets', 'ordinal': 0, 'members': ['All']}, {'dimension': 'dim_date', 'ordinal': 1, 'members': ['All']}], 'positions': [{'row': 0, 'members': [{'name': 'All', 'level': 1, 'root': True}, {'name': 'All', 'level': 1, 'root': True}]}]}, 'rows': {'dimensions': [{'dimension': 'dim_company', 'ordinal': 0, 'members': ['2000']}], 'positions': [{'row': 0, 'members': [{'name': '2000', 'level': 0, 'root': True}]}]}, 'columns': {'dimensions': [{'dimension': 'measures', 'ordinal': 0, 'members': ['price', 'quantity', 'amount']}], 'positions': [{'row': 0, 'members': [{'name': 'price', 'level': 0, 'root': True}]}, {'row': 1, 'members': [{'name': 'quantity', 'level': 0, 'root': True}]}, {'row': 2, 'members': [{'name': 'amount', 'level': 0, 'root': True}]}]}}, 'cells': [{'row': 0, 'cells': [{'row': 0, 'col': 0, 'value': 160.24096385542168, 'caption': '160.24'}, {'row': 0, 'col': 1, 'value': 83.0, 'caption': '83.00'}, {'row': 0, 'col': 2, 'value': 13300.0, 'caption': '13,300.00'}]}]} 

Как видим, все значения верны.

Используем OLAP по истинному предназначению

Основное предназначение OLAP — вывод данных не просто в виде таблички, а в разрезе, необходимом для анализа. Построим срез, в котором будет отображено количество продаж каждого товара по датам.

 view = View(cube, definition={
        "filters": {"dimensions": [{"dimension": "dim_company", "members": "2000"}]},
        "columns": {"dimensions": [{"dimension": "measures", "members": "quantity"}, "dim_date"]},
        "rows": {"dimensions": ["dim_assets"]}
    })

print(view.to_console_output())

Немного о TinyOLAP

Библиотека TinyOLAP позволяет создать все структуры данных и сам кубик, загрузить в него данные и построить различные срезы. По типу хранения данных относится к MOLAP (многомерная оперативная аналитическая обработка).

У TinyOLAP есть как свои плюсы, так и минусы.

Плюсы:

  • простота освоения,

  • OpenSourse.

Минусы:

  • нет обновлений и поддержки,

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

  • ограниченность типов данных для измерений и показателей,

  • скорость работы и ресурсы для тяжелых датасетов.

Мы работали с небольшим демонстрационным датасетом на OpenSourse‑версии, описаны лишь некоторые проблемы. В текущей версии TinyOLAP часть функциональных возможностей заявлена, но не имплементирована, поэтому при попытке их использования возникают ошибки. При внесении модификаций большинство проблем «лечится».

Комментарии (9)


  1. Johan_Palych
    15.04.2024 09:04

    Для начала:
    Cubes - OLAP Framework
    https://pythonhosted.org/cubes/


    1. pro100alf Автор
      15.04.2024 09:04

      Спасибо, видел это либу. Но, к сожалению, оч давно заброшена более 5 лет назад


  1. economist75
    15.04.2024 09:04
    +1

    Спасибо за статью. На мой взгляд золотые времена OLAP прошли, теперь это не воспринимается как чудо-технология. Дaже в pandas/numpy не стали развивать и таки-выпилили многослойные таблицы (по сути тот же OLAP), логично предположив что многоуровневая индексация (pd.Multiindex) нагляднее и проще в восприятии (она легко визуализируется в плоскую таблицу, просто купите монитор пошире).

    TinyOLAP действительно заброшен, в свежей среде он не запустится (нужна env) и это сильно ограничивает энтузиазм. В то же время новый движок DuckDB - очень перспективен, поражает скоростью работы, примиряет адептов Pandas и SQL за счет равнозначно удобного использования лучшего из двух миров. К.м.к. это и есть замена OLAP. поскольку поколоночное, а не хранение "измерений", дает наибольшую скорость в i/o и вычислениях, в т.ч. в RAM.


    1. pro100alf Автор
      15.04.2024 09:04

      Спасибо, посмотрю, что за «зверь»


    1. alex0306
      15.04.2024 09:04
      +1

      Вы знаете, совсем не соглашусь, я пока за всю свою практику гибче и удобнее инструменты не находил.
      Естественно можно накидать сложные дашборды и т.д, но с точки зрения гибкости и скорости, то найти сложно ( + само понятие self service)
      Пока вижу, как наилучшее решение прикручивать CH и Утку как ядро аналитическое.
      Есть очень интересное решение kylin (на текущий момент часть этого продукта - динозавр от яндека), и его старший брат - уже корпоративный продукт.
      Но так и не хватило терпения его запустить полноценно.


      1. economist75
        15.04.2024 09:04

        Верно пишете, симбиоз ClickHouse для "болота" и DuckDB для "аналитической" БД - об этом говорят аналитики на каждом углу, семинаре и даже там, у них. Зрелые, ультимативно быстрые инструменты.

        Добавлю что все это нужно обязательно "обмазать" оркестратором из тех что попроще (Dagster) или престижнее (AirFlow).

        Но при этом нельзя не уходить с максимально отзывчивых инструментов (JupyterLab + Pandas), поскольку возвращаться к ним приходится буквально еженедельно даже в хорошо отлаженных конвейерах (мир меняется слишком быстро).

        Обмен py-скриптами между Pandas и оркестратором (и многим другим) - легко организуем в обе стороны парой простых UDF, все никак не допишу статью об этом сюда. Можно сказать что озвученный программный стек наконец-то ознаменовал собой завершение "поиска идеала" (шучу, это неизлечимо в отрасли). Но чувство надежности выросло.


  1. titan_pc
    15.04.2024 09:04

    Вроде в clickhouse можно в OLAP


    1. pro100alf Автор
      15.04.2024 09:04

      Да, ClickHouse специализируется на аналитической обработке. Возможно, ее можно использовать в связке с OLAP.


    1. economist75
      15.04.2024 09:04

      Если воспринимать природу OLAP как способ хранения (измерений) и способ организации данных - то он реализуем в любой БД и ее суррогатах (встраиваемые БД, Excel, TXT-файлы).

      Колоночные хранилища перевернули страницу OLAP как самостоятельной технологии: прирост скорости колоночных БД полностью нивелировал все плюсы OLAP. Теперь OLAP это заурядный, один из видов анализа по срезам.

      Для максимизации скорости отборов (напр. для дэшборда) - нужно десять раз подумать и проверить что "быстрее": хранить аналитическую БД строками, колонками, измерениями OLAP, с крутыми индексами итд. Готовых рецептов нет, потому что действия юзеров хаотичны и непостижимы.

      На практике IN MEMORY данные с булевыми готовыми колонками (после one hot encoding) в той же Pandas часто оказываются самыми быстрыми, но нужно много RAM.