Basics

Getting started

From CSV to Cube

In this part of the tutorial, you will create your first cube from a CSV file and learn multidimensionnal concepts such as cube, dimension, hierarchy, measure.

Let’s start by creating a session:

[1]:
import atoti as tt

session = tt.create_session()

We can now load the data from a CSV file into an in-memory table called a store:

[2]:
sales_store = session.read_csv("data/sales.csv", keys=["Sale ID"])

We can have a look at the loaded data. They are sales from a company:

[3]:
sales_store.head()
[3]:
Date Shop Product Quantity Unit price Amount
Sale ID
S000000000 2020-06-12 shop_0 TAB_0 1.0 210.0 210.0
S000000001 2020-06-11 shop_1 TAB_1 1.0 300.0 300.0
S000000002 2020-06-10 shop_2 CHA_2 2.0 60.0 120.0
S000000003 2020-06-09 shop_3 BED_3 1.0 150.0 150.0
S000000004 2020-06-08 shop_4 BED_4 3.0 300.0 900.0

We will come back to stores in details later, for now we will use the one we have to build a cube:

[4]:
cube = session.create_cube(sales_store)

That’s it, you have created your first cube! But what’s a cube exactly and how to use it?

Multidimensional concepts

A cube is a multidimensional view of some data, making it easy to explore, aggregate, filter and compare. It’s called a cube because each attribute of the data can be represented as a dimension of the cube:

Multidimensional cube concept

The axes of the cube are called hierarchies. The purpose of multidimensionnal analysis is to visualize some numeric indicators at specific coordinates of the cube. These indicators are called measures. An example of measure would be the amount of products sold.

We can list the hierarchies in our cube:

[5]:
# Aliasing the hierarchies property to a shorter variable name because we will use it a lot.
h = cube.hierarchies
h
[5]:
  • Dimensions
    • Hierarchies
      • Date
        1. Date
      • Product
        1. Product
      • Sale ID
        1. Sale ID
      • Shop
        1. Shop

The cube has automatically created a hierarchy for each non numeric field: “Date”, “Product”, “Sale ID” and “Shop”.

You can see that the hierarchy are grouped into dimensions. Here we have a single dimension, the default one, called “Hierarchies” but we will later group the hierarchies about the same concept in the same dimension.

Hierarchies are also made of levels. Levels of the same hierarchy are attributes with a parent-child relationship. For instance, a city belongs to a country so “Country” and “City” could be the two levels of a “Geography” hierarchy.

At the moment, to keep it simple, we only have single-level hierarchies.

[6]:
lvl = cube.levels

Let’s have a look at the measures of the cube that have been inferred from the data:

[7]:
m = cube.measures
m
[7]:
  • Measures
    • Amount.MEAN
      • formatter: DOUBLE[#,###.00]
      • visible: True
    • Amount.SUM
      • formatter: DOUBLE[#,###.00]
      • visible: True
    • Quantity.MEAN
      • formatter: DOUBLE[#,###.00]
      • visible: True
    • Quantity.SUM
      • formatter: DOUBLE[#,###.00]
      • visible: True
    • Unit price.MEAN
      • formatter: DOUBLE[#,###.00]
      • visible: True
    • Unit price.SUM
      • formatter: DOUBLE[#,###.00]
      • visible: True
    • contributors.COUNT
      • formatter: None
      • visible: True

The cube has automatically created the sum and mean aggregations for all the numeric fields of the dataset.

Note that a measure isn’t a single result number, it’s more a formula that can be evaluated for any coordinates of the cube.

For instance, we can see the grand total of “Quantity.SUM”, which means summing the sold quantities over the whole dataset:

Grand total

[8]:
cube.query(m["Quantity.SUM"])
[8]:
Quantity.SUM
0 8077.0

But we can also dice the cube to get the quantity for each shop, which means taking one slice of the cube for each shop:

Dicing the cube

[9]:
cube.query(m["Quantity.SUM"], levels=lvl["Shop"])
[9]:
Quantity.SUM
Shop
shop_0 202.0
shop_1 202.0
shop_10 203.0
shop_11 203.0
shop_12 201.0
shop_13 202.0
shop_14 202.0
shop_15 202.0
shop_16 201.0
shop_17 204.0
shop_18 202.0
shop_19 202.0
shop_2 202.0
shop_20 201.0
shop_21 201.0
shop_22 201.0
shop_23 203.0
shop_24 203.0
shop_25 201.0
shop_26 202.0
shop_27 202.0
shop_28 202.0
shop_29 201.0
shop_3 201.0
shop_30 204.0
shop_31 202.0
shop_32 202.0
shop_33 201.0
shop_34 201.0
shop_35 201.0
shop_36 203.0
shop_37 203.0
shop_38 201.0
shop_39 202.0
shop_4 204.0
shop_5 202.0
shop_6 202.0
shop_7 201.0
shop_8 201.0
shop_9 201.0

We can slice on a single shop:

Slicing the cube

[10]:
cube.query(
    m["Quantity.SUM"], condition=lvl["Shop"] == "shop_0",
)
[10]:
Quantity.SUM
0 202.0

We can dice along 2 different axes and take the quantity per product and date.

Pivot table

[11]:
cube.query(m["Quantity.SUM"], levels=[lvl["Date"], lvl["Product"]])
[11]:
Quantity.SUM
Date Product
2020-05-14 BED_24 8.0
BED_25 4.0
BED_26 6.0
BED_27 4.0
BED_3 2.0
... ... ...
2020-06-12 TSH_52 6.0
TSH_53 4.0
TSH_7 3.0
TSH_8 5.0
TSH_9 3.0

1830 rows × 1 columns

We can even combine these operations to slice on one hierarchy and dice on the two others:

Slice and dice

[12]:
cube.query(
    m["Quantity.SUM"],
    levels=[lvl["Date"], lvl["Product"]],
    condition=lvl["Shop"] == "shop_0",
)
[12]:
Quantity.SUM
Date Product
2020-05-23 BED_24 1.0
BED_26 1.0
BED_3 1.0
BED_4 1.0
BED_46 1.0
... ... ...
2020-06-12 TSH_51 2.0
TSH_52 1.0
TSH_53 1.0
TSH_7 1.0
TSH_9 1.0

125 rows × 1 columns

First visualization

So far we have used cube.query which returns a table as a pandas DataFrame but a better way to visualize multidimensional data is a pivot table. With atoti’s JupyterLab extension, you can do advanced and interactive visualizations such as pivot tables and charts directly into your notebook by calling cube.visualize().

This will create a widget and open the atoti tab on the left with tools to manipulate the widget.

Let’s start by creating a pivot table:

  • Run cube.visualize().

  • Select “Pivot table”.

  • In the left pannel, click on a measure such as “Amount.SUM” to add it.

  • Click on a hierarchy such as “Date” to get the Amount per date.

  • Drag and drop another hierarchy such as “Product” to the “Columns” section to get the amount sold per day and per product.

First pivot table GIF

[13]:
cube.visualize()
Install and enable the atoti JupyterLab extension to see this widget.

We can also create a first chart representing the evolution of the amount sold per day:

  • Run cube.visualize().

  • Select “Chart”.

  • Click on the “Amount.SUM” measure to add it to the chart.

  • Click on the “Date” hierachy to add it to the chart.

First atoti chart GIF

[15]:
cube.visualize()
Install and enable the atoti JupyterLab extension to see this widget.

Drilldown and filters

Multidimensional analysis is meant to be done from top to bottom: start by visualizing the indicators at the top level then drilldown to explain the top figures with more details.

For instance, we can visualize some measures per date then drilldown on the shops for a specific date, then see the products sold by a specific shop on this date.

Using the previous cube representation, this is like zooming more and more on a part of the cube.

Drilldown the cube

Drilldown

[16]:
cube.visualize()
Install and enable the atoti JupyterLab extension to see this widget.

It’s also very easy to filter on a hierarchy when building widgets. Let’s apply a filter on a chart:

Chart filter

[17]:
cube.visualize()
Install and enable the atoti JupyterLab extension to see this widget.

Dashboarding application

Being able to quickly build widgets inside a notebook without coding is nice to rapidly explore the data, iterate on your model and share some results. However, to provide richer insights, dashboards are even better. That’s why atoti comes with a web application that can be accessed outside of the notebook and where widgets can be laid out to form dashboards.

The URL of this application can be accessed like that:

[18]:
session.url
[18]:
'http://localhost:45199'

It’s possible to publish widgets built in the notebook to the application by right clicking on them and selecting “Publish widget in app”. They will then be available in the “Saved widgets” section.

Publish widget in application

Enriching the cube

In the previous section, you have learned how to create a basic cube and manipulate it. We will now enrich this cube with additional attributes and more interesting measures.

Join

Currently, we have very limited information about our products: only the ID. We can load a CSV containing more details into a new store:

[19]:
products_store = session.read_csv("data/products.csv", keys=["Product"])

Note that a store can have a set of keys. These keys are the columns which make each line unique. Here, it’s the product ID.

If you try to insert a new row with the same keys as an existing row, it will override the existing one.

[20]:
products_store.head()
[20]:
Category Sub category Size Purchase price Color Brand
Product
TAB_0 Furniture Table 1m80 190.0 black Basic
TAB_1 Furniture Table 2m40 280.0 white Mega
CHA_2 Furniture Chair N/A 48.0 blue Basic
BED_3 Furniture Bed Single 127.0 red Mega
BED_4 Furniture Bed Double 252.0 brown Basic

This store contains the category, subcategory, size, color, purchase price and brand of the product. Both stores have a “Product” field we can use to join them.

Note that this is a database-like join and not a pandas-like join. All the details from products_store won’t be inlined into sales_store. Instead, this just declares a reference between these two stores that the cube can use to provide more analytical axes.

[21]:
sales_store.join(products_store, mapping={"Product": "Product"})

You can visualize the structure of the whole datastore:

[22]:
session.stores.schema
[22]:
../_images/tutorial_01-Basics_44_0.svg

The new columns have been automatically added to the cube as hierarchies:

[23]:
h
[23]:
  • Dimensions
    • Hierarchies
      • Brand
        1. Brand
      • Category
        1. Category
      • Color
        1. Color
      • Date
        1. Date
      • Product
        1. Product
      • Sale ID
        1. Sale ID
      • Shop
        1. Shop
      • Size
        1. Size
      • Sub category
        1. Sub category

You can use them directly in a new widget. For instance, let’s create a bar chart to visualize the mean price per subcategory of product:

Price per category

[24]:
cube.visualize()
Install and enable the atoti JupyterLab extension to see this widget.

We can also make a donut chart to see how all the sales are distributed between brands:

Donut chart brands

[25]:
cube.visualize()
Install and enable the atoti JupyterLab extension to see this widget.

In a similar way, we can enrich the data about the shops:

[26]:
shops_store = session.read_csv("data/shops.csv", keys=["Shop ID"])
shops_store.head()
[26]:
City State or region Country Shop size
Shop ID
shop_0 New York New York USA big
shop_1 Los Angeles California USA medium
shop_2 San Diego California USA medium
shop_3 San Jose California USA medium
shop_4 San Francisco California USA small
[27]:
sales_store.join(shops_store, mapping={"Shop": "Shop ID"})
session.stores.schema
[27]:
../_images/tutorial_01-Basics_53_0.svg

We can now plot the evolution of the sales per country over time:

Amount per country over time

[28]:
cube.visualize()
Install and enable the atoti JupyterLab extension to see this widget.

New measures

So far we have only used the default measures which are basic aggregations of the numeric columns. We can add new custom measures to our cube.

Max

We’ll start with a simple aggregation taking the maximum price of the sales store:

[29]:
m["Max price"] = tt.agg.max(sales_store["Unit price"])

This new measure is directly available:

[30]:
cube.query(m["Max price"])
[30]:
Max price
0 440.0
[31]:
cube.query(m["Max price"], levels=lvl["Category"])
[31]:
Max price
Category
Cloth 60.0
Furniture 440.0

Margin

Now that the price of each product is available from the products store, we can compute the margin.

To do that, we start by computing the cost which is the quantity sold multiplied by the purchase price, summed over all the products.

Note the use of the origin scope instructing to perform the multiplication for each product and then do the sum.

[32]:
cost = tt.agg.sum(
    m["Quantity.SUM"] * products_store["Purchase price"],
    scope=tt.scope.origin(lvl["Product"]),
)
[33]:
m["Margin"] = m["Amount.SUM"] - cost

We can also define the margin rate which is the ratio of the margin by the the sold amount:

[34]:
m["Margin rate"] = m["Margin"] / m["Amount.SUM"]
[35]:
cube.query(m["Margin"], m["Margin rate"], levels=lvl["Product"])
[35]:
Margin Margin rate
Product
BED_24 3082.0 0.153333
BED_25 6336.0 0.160000
BED_26 8060.0 0.156962
BED_27 8580.0 0.147727
BED_3 3036.0 0.153333
... ... ...
TSH_52 520.0 0.166667
TSH_53 396.0 0.125000
TSH_7 393.0 0.150000
TSH_8 264.0 0.100000
TSH_9 390.0 0.136364

61 rows × 2 columns

Let’s use this margin rate to do a “Top 10” filter to see the products with the best rate.

Note that you don’t need to put the rate measure and the product level in the pivot table to apply the filter.

top10 filter on the margin rate

[36]:
cube.visualize("10 most profitable products")
Install and enable the atoti JupyterLab extension to see this widget.

Cumulative sum over time

A cumulative sum is the partial sum of the data up to the current value. For instance, a cumulative sum over time can be used to show how some measure changes over time.

[37]:
m["Cumulative amount"] = tt.agg.sum(
    m["Amount.SUM"], scope=tt.scope.cumulative(lvl["Date"])
)

Cumulative amount

[38]:
cube.visualize()
Install and enable the atoti JupyterLab extension to see this widget.

Average per store

Aggregations can also be combined. For instance, we can sum inside a shop then take the average of this to see how much a store sales on average:

[39]:
m["Average amount per shop"] = tt.agg.mean(
    m["Amount.SUM"], scope=tt.scope.origin(lvl["Shop"])
)
[40]:
cube.query(m["Average amount per shop"])
[40]:
Average amount per shop
0 24036.575
[41]:
cube.query(m["Average amount per shop"], levels=lvl["Sub category"])
[41]:
Average amount per shop
Sub category
Bed 12728.875
Chair 601.500
Hoodie 1403.200
Shoes 3184.500
Table 5023.500
Tshirt 1095.000

Fact-level operations

[42]:
sales_store.head()
[42]:
Date Shop Product Quantity Unit price Amount
Sale ID
S000000030 2020-06-12 shop_30 TSH_30 3.0 22.0 66.0
S000000070 2020-06-02 shop_30 TSH_9 1.0 22.0 22.0
S000000074 2020-05-29 shop_34 HOO_13 1.0 48.0 48.0
S000000079 2020-05-24 shop_39 SHO_18 1.0 60.0 60.0
S000000209 2020-05-14 shop_9 BED_26 1.0 395.0 395.0

As you can see, our data already contains an “Amount” column which is equal to “Quantity” * “Unit price”. It’s a good practice to do this kind of fact-level preprocessing outside atoti. Indeed, it avoids redoing these computations on every row over and over, slowing down every query. Like that, atoti only has to do what it’s the best at: aggregation.

Multilevel hierarchies

So far, all our hierarchies only had one level but it’s best to regroup attributes with a parent-child relationship in the same hierarchy.

For example, we can group the “Category”, “SubCategory” and “Product ID” levels into a “Product” hierarchy:

[43]:
h["Product"] = [lvl["Category"], lvl["Sub category"], lvl["Product"]]

And let’s remove the old hierarchies:

[44]:
del h["Category"]
del h["Sub category"]
[45]:
h
[45]:
  • Dimensions
    • Hierarchies
      • Brand
        1. Brand
      • City
        1. City
      • Color
        1. Color
      • Country
        1. Country
      • Date
        1. Date
      • Product
        1. Category
        2. Sub category
        3. Product
      • Sale ID
        1. Sale ID
      • Shop
        1. Shop
      • Shop size
        1. Shop size
      • Size
        1. Size
      • State or region
        1. State or region

We can also do it with “City”, “Region” and “Country” to build a “Geography” hierarchy.

Note that instead of using existing levels you can also define a hierarchy with the fields of the store the levels will be based on:

[46]:
h["Geography"] = [
    shops_store["Country"],
    shops_store["State or region"],
    shops_store["City"],
]
del h["Country"]
del h["State or region"]
del h["City"]

As we are restructuring the hierarchies, let’s use this opportunity to also change the dimensions.

A dimension regroups hierarchies of the same concept.

To keep it simple here, we will simply move the new “Geography” hierarchy to its own dimension:

[47]:
h["Geography"].dimension = "Location"
h
[47]:
  • Dimensions
    • Hierarchies
      • Brand
        1. Brand
      • Color
        1. Color
      • Date
        1. Date
      • Product
        1. Category
        2. Sub category
        3. Product
      • Sale ID
        1. Sale ID
      • Shop
        1. Shop
      • Shop size
        1. Shop size
      • Size
        1. Size
    • Location
      • Geography
        1. Country
        2. State or region
        3. City

With that, we can define new measures taking advantage of the multilevel structure. For instance, we can create a measure indicating how much a product contributes to its subcategory:

[48]:
m["Parent category amount"] = tt.parent_value(m["Amount.SUM"], on=h["Product"])
[49]:
m["Percent of parent amount"] = m["Amount.SUM"] / m["Parent category amount"]

Percent of parent

[50]:
cube.visualize()
Install and enable the atoti JupyterLab extension to see this widget.

Polishing the cube

Deleting or hiding measures

Some measures have been automatically created from numeric fields but are not useful. For instance, “Unit Price.SUM” does not really make sense as we never want to sum the unit prices. We can delete it:

[51]:
del m["Unit price.SUM"]

Other measures have been used while building the project only as intermediary steps but are not useful to the end users in the application. We can hide them from the UI (they will remain accessible in Python):

[52]:
m["Parent category amount"].visible = False

Measure folders

Measures can be rearranged into folders.

Measure folder

[53]:
for measure in [
    m["Amount.MEAN"],
    m["Amount.SUM"],
    m["Average amount per shop"],
    m["Cumulative amount"],
    m["Percent of parent amount"],
]:
    measure.folder = "Amount"

Measure formatters

Some measures can be formatted for a nicer display. Classic examples of this is changing the number of decimals or adding a percent or a currency symbol.

Let’s do this for our percent of parent amount and margin rate:

Before

Before measure formatting

[54]:
m["Percent of parent amount"].formatter = "DOUBLE[0.00%]"
m["Margin rate"].formatter = "DOUBLE[0.00%]"

After

After measure formatting

Simulations

Simulations are a way to compare several scenarios and do what-if analysis. This is very powerful as it helps understanding how changing the source data or a piece of the model impact the key indicators.

In atoti, the data model is made of measures chained together. A simulation can be seen as changing one part of the model, either its source data or one of its measure definitions, and then evaluating how it impacts the following measures.

Source simulation

Let’s start by changing the source. With pandas or Spark, if you want to compare two results for a different versions of the entry dataset you have to reapply all the transformations to your dataset. With atoti, you only have to provide the new data and all the measures will be automatically available for both versions of the data.

We will create a new scenario using pandas to modify the original dataset.

[55]:
import pandas as pd

For instance, we can simulate what would happen if we had managed to purchase some products at a cheaper price.

[56]:
products_df = pd.read_csv("data/products.csv")
products_df.head()
[56]:
Product Category Sub category Size Purchase price Color Brand
0 TAB_0 Furniture Table 1m80 190.0 black Basic
1 TAB_1 Furniture Table 2m40 280.0 white Mega
2 CHA_2 Furniture Chair NaN 48.0 blue Basic
3 BED_3 Furniture Bed Single 127.0 red Mega
4 BED_4 Furniture Bed Double 252.0 brown Basic
[57]:
better_prices = {
    "TAB_0": 180.0,
    "TAB_1": 250.0,
    "CHA_2": 40.0,
    "BED_3": 110.0,
    "BED_4": 210.0,
}
[58]:
for product, purchase_price in better_prices.items():
    products_df.loc[
        products_df["Product"] == product, "Purchase price"
    ] = purchase_price
products_df.head()
[58]:
Product Category Sub category Size Purchase price Color Brand
0 TAB_0 Furniture Table 1m80 180.0 black Basic
1 TAB_1 Furniture Table 2m40 250.0 white Mega
2 CHA_2 Furniture Chair NaN 40.0 blue Basic
3 BED_3 Furniture Bed Single 110.0 red Mega
4 BED_4 Furniture Bed Double 210.0 brown Basic

We can now load this new dataframe into a new scenarios of the product store.

[59]:
products_store.scenarios["Cheaper purchase prices"].load_pandas(products_df)

Using the “Source Simulation” hierarchy we can display the margin of the scenario and compare it to the base case.

Source simulation comparison

[60]:
cube.visualize()
Install and enable the atoti JupyterLab extension to see this widget.

Note that all the existing measures are immediately available on the new data. For instance, the margin rate still exists, and we can see that in this scenario we would have a better margin for the Furniture products.

Margin rate per product category and scenario

[61]:
cube.visualize()
Install and enable the atoti JupyterLab extension to see this widget.

Measures simulations

The other simulation technique is to change the value of a measure for some coordinates. The value of the simulated measure can be multiplied by some factor, added to a fixed amount, or completely replaced.

When creating the simulation, you can choose at which granularity the modification applies. For instance, we can create a simulation of the quantity and amount measures per country. Doing that, we can answer questions such as “What happens if there is a crisis in France and we sell 20% less ?”

[62]:
quantity_simu = cube.setup_simulation(
    "Country Simulation",
    levels=[lvl["Country"]],
    multiply=[m["Quantity.SUM"], m["Amount.SUM"]],
)

Let’s add a first scenario to this simulation:

[63]:
france_crisis = quantity_simu.scenarios["France crisis"]

A scenario is like a store where you can add new parameters to configure the scenario. In this scenario, we decided that France sell 20% less so let’s multiply the quantity and the amount by 80% only for France.

[64]:
france_crisis += ("France", 0.80, 0.80)
france_crisis.head()
[64]:
Country Simulation_Quantity.SUM_multiply Country Simulation_Amount.SUM_multiply Priority
Country
France 0.8 0.8 0.0

Let’s query the cube using the new “Country Simulation” hierarchy to compare the quantity and amount between the base case and our new scenario:

[65]:
cube.query(
    m["Quantity.SUM"],
    m["Amount.SUM"],
    levels=[lvl["Country Simulation"], lvl["Country"]],
)
[65]:
Quantity.SUM Amount.SUM
Country Simulation Country
Base France 3027.0 358042.0
USA 5050.0 603421.0
France crisis France 2421.6 286433.6
USA 5050.0 603421.0

Note that you are not limited to a single scenario:

[66]:
quantity_simu.scenarios["US boost"] += ("USA", 1.15, 1.15)
[67]:
cube.query(m["Quantity.SUM"], levels=[lvl["Country Simulation"], lvl["Country"]])
[67]:
Quantity.SUM
Country Simulation Country
Base France 3027.0
USA 5050.0
France crisis France 2421.6
USA 5050.0
US boost France 3027.0
USA 5807.5

As the amount has been modified, the measures depending on it such as the cumulative amount are also impacted.

Cumulative amount per scenario

[68]:
cube.visualize()
Install and enable the atoti JupyterLab extension to see this widget.

Finally, we can even combine the different simulations (the source one and the measure one) to create a matrix of scenarios:

Matrix of scenarios

[69]:
cube.visualize()
Install and enable the atoti JupyterLab extension to see this widget.

Summary

In this tutorial, you have learned all the basics to build a project with atoti, from the concept of multidimensional to powerful simulations. We now encourage you to try the library with your own data!