Skip to content

    Chapter 22, Intermediate

    Reformat: pivot, melt and crosstab

    The same information can be in **long** format (one line per observation) or **wide** format (one column per category). Those who create reports want the wide format, and those who analyze and create graphs want the long format. Learning to switch back and forth is what this chapter teaches.

    The starting point: sales, in a table

    From here until the end of the level, the sales model appears already along: orders, plus products (for the price), plus customers (for the segment). The function combines what chapter 21 showed, and never discards orders:

    intermediario/cap22_reformatar.pylinhas 10 a 30
    import numpy as np
    import pandas as pd
    
    pd.set_option("display.width", 170)
    pd.set_option("display.max_columns", 20)
    
    
    def carregar_vendas() -> pd.DataFrame:
        clientes = pd.read_csv("dados/clientes.csv")
        produtos = pd.read_csv("dados/produtos.csv")
        pedidos = pd.read_csv("dados/pedidos.csv", parse_dates=["data_pedido"])
        v = pedidos.merge(produtos, on="id_produto", how="left", validate="m:1")
        v = v.merge(clientes[["id_cliente", "segmento"]], on="id_cliente", how="left", validate="m:1")
        v["segmento"] = v["segmento"].fillna("Sem cadastro")
        v["receita"] = v["quantidade"] * v["preco_unitario"] * (1 - v["desconto"].fillna(0))
        v["mes"] = v["data_pedido"].dt.month
        return v
    
    
    vendas = carregar_vendas()
    print(vendas.shape, round(float(vendas["receita"].sum()), 2))
    
    Output
    (800, 13) 970851.1
    

    `pivot_table`: aggregate and open in columns

    The pivot_table does what a pivot table in Excel does: groups by one or more keys in the rows, opens another key in columns and aggregates the values. The margins=True adds the totals:

    intermediario/cap22_reformatar.pylinhas 35 a 40
    tabela = vendas.pivot_table(
        index="categoria", columns="canal", values="receita",
        aggfunc="sum", margins=True, margins_name="Total",
    )
    print(tabela.round(0))
    print(bool(np.isclose(tabela.loc["Total", "Total"], vendas["receita"].sum())))
    
    Output
    canal             app      loja      site     Total
    categoria                                          
    Casa          53988.0   77803.0  105830.0  237621.0
    Esporte       51362.0   95554.0  124000.0  270915.0
    Informática   43765.0   79425.0  124154.0  247345.0
    Papelaria     43095.0   69274.0  102601.0  214970.0
    Total        192210.0  322056.0  456585.0  970851.0
    True
    

    The total in the corner closes with the sum of all revenue (the True), which is the usual check. For more than one aggregation, pass a list, and the columns gain an extra level:

    intermediario/cap22_reformatar.pylinha 42
    print(vendas.pivot_table(index="categoria", values="receita", aggfunc=["sum", "count"]).round(0))
    
    Output
                      sum   count
                  receita receita
    categoria                    
    Casa         237621.0     205
    Esporte      270915.0     206
    Informática  247345.0     196
    Papelaria    214970.0     193
    

    `pivot`: reorganize, without aggregating

    The pivot just reorganizes: does not aggregate anything, and therefore requires that each pair (linha, coluna) appears once. Before it, I aggregate with the groupby to ensure this:

    intermediario/cap22_reformatar.pylinhas 47 a 49
    por_mes_e_canal = vendas.groupby(["mes", "canal"])["receita"].sum().reset_index()
    largo = por_mes_e_canal.pivot(index="mes", columns="canal", values="receita")
    print(largo.round(0).head(3))
    
    Output
    canal      app     loja     site
    mes                             
    1      16034.0  38612.0  27511.0
    2      11320.0  23562.0  28582.0
    3      13786.0  22658.0  56152.0
    

    Without the prior aggregation, the pivot fails, because there are many orders for the same pair of category and channel:

    intermediario/cap22_reformatar.pylinhas 51 a 54
    try:
        vendas.pivot(index="categoria", columns="canal", values="quantidade")
    except ValueError as erro:
        print("ValueError:", erro)
    
    Output
    ValueError: Index contains duplicate entries, cannot reshape
    

    The rule for choosing: if there are repetitions to combine, use pivot_table (which aggregates). If each pair is already unique, the pivot is enough.

    `melt`: from wide to long

    The melt does the reverse: transforms columns into rows. It is the format that graph libraries and the groupby prefer:

    intermediario/cap22_reformatar.pylinhas 59 a 61
    longo = largo.reset_index().melt(id_vars="mes", var_name="canal", value_name="receita")
    print(longo.shape, round(float(longo["receita"].sum()), 2) == round(float(largo.sum().sum()), 2))
    print(longo.head(3).round(1))
    
    Output
    (36, 3) True
       mes canal  receita
    0    1   app  16033.6
    1    2   app  11320.5
    2    3   app  13786.3
    

    There are 12 months for 3 channels, 36 rows, and the total revenue has not changed: reformatting does not create or lose information.

    `crosstab`: count combinations

    The crosstab counts how many times each combination occurs, without needing a column of values. The normalize converts the count into proportion, by row, by column, or of the total:

    intermediario/cap22_reformatar.pylinhas 66 a 67
    print(pd.crosstab(vendas["segmento"], vendas["canal"]))
    print(pd.crosstab(vendas["segmento"], vendas["canal"], normalize="index").round(2))
    
    Output
    canal         app  loja  site
    segmento                     
    Atacado        30    43    69
    Online         71   103   137
    Sem cadastro    2     4     6
    Varejo         64   108   163
    canal          app  loja  site
    segmento                      
    Atacado       0.21  0.30  0.49
    Online        0.23  0.33  0.44
    Sem cadastro  0.17  0.33  0.50
    Varejo        0.19  0.32  0.49
    

    In the second table, each row sums to 1: it indicates, for each segment, the proportion of orders by channel. It answers the question "does the channel profile change from one segment to another?", which the gross count hides when the segments have different sizes.

    `unstack` and `stack`

    The unstack takes one level of the index to the columns, and the stack does the opposite. After a groupby with two keys, the unstack is the shortcut to the wide table:

    intermediario/cap22_reformatar.pylinhas 72 a 75
    agrupado = vendas.groupby(["segmento", "canal"])["receita"].sum()
    largo2 = agrupado.unstack("canal")
    print(largo2.round(0))
    print(largo2.stack().equals(agrupado))
    
    Output
    canal             app      loja      site
    segmento                                 
    Atacado       34940.0   49348.0   74926.0
    Online        83440.0  128267.0  170309.0
    Sem cadastro   3771.0    5218.0    8071.0
    Varejo        70059.0  139222.0  203279.0
    True
    

    Percentage of each row

    A wide table of values becomes more useful as participation. The div with axis=0 divides each row by its total, aligning by the index (the / simply dividing would align by the columns, and would be incorrect):

    intermediario/cap22_reformatar.pylinhas 80 a 83
    por_categoria = vendas.pivot_table(index="categoria", columns="canal", values="receita", aggfunc="sum")
    participacao = por_categoria.div(por_categoria.sum(axis=1), axis=0).round(2)
    print(participacao)
    print(participacao.sum(axis=1).round(2).tolist())
    
    Output
    canal         app  loja  site
    categoria                    
    Casa         0.23  0.33  0.45
    Esporte      0.19  0.35  0.46
    Informática  0.18  0.32  0.50
    Papelaria    0.20  0.32  0.48
    [1.01, 1.0, 1.0, 1.0]
    

    Each row sums to 1 You will see, in much material, of rounding. The 1.01 of the first category is just the effect of rounding each cell to two decimal places: there is no error in the calculation.

    I wantUse
    Aggregated summary, like a pivot tablepivot_table
    Just reorganize unique pairspivot
    Wide to longmelt
    Count combinations (and proportions)crosstab
    Take one level of the index to the columnsunstack

    Exercise 1

    The revenue by category and month

    Write receita_por_categoria_e_mes(tabela), which returns a table with categories in the rows and the 12 months in the columns, with 0 where there were no sales, and check that the sum of everything equals the total revenue.