Ibis - Escalonando a Análise de Dados com Expressões Ibis

Escalonando a Análise de Dados com Expressões Ibis

Ibis Framework é uma biblioteca Python que gera expressões de busca em banco de dados. O framework foi idealizado por Wes McKinney, o mesmo criador do Pandas, voltado para integração de diferentes Engines de Bancos de Dados através de Expressões Ibis com Python.

Enquanto os motores de busca e análise de dados crescem e ficam mais robustos com o avanço da era dos Dados, algumas complicações podem ser encontradas em diferentes ambientes de desenvolvimento. Um dos exemplos mais comuns é o crescimento de aplicações que realizam buscas SQL em um banco de dados, com o tempo as buscas se tornam complexas e de difícil leitura.

Atualmente o Ibis possui 12 backends em seu escopo, alguns deles são responsáveis pela manipulação dos dados, outros, como por exemplo o SQLAlchemy, são responsáveis pela tradução das expressões Ibis em buscas SQL, etc. Você pode conferir todos os backends aqui.

Setup e Base de Dados

O Ibis pode ser instalado via pip ou conda. Nos exemplos a seguir utilizaremos apenas a instalação padrão do Ibis, mas você pode conferir como instalar outros Backends como o Google BigQuery, Apache Impala, PostgreSQL dependendo da sua necessidade.

1!mamba install ibis-framework matplotlib sqlalchemy -c conda-forge -y

Estaremos trabalhando com um dataset disponibilizado pelo Ministério da Saúde em formato CSV.

1!wget -c https://data.brasil.io/dataset/covid19/caso_full.csv.gz && gunzip -c caso_full.csv.gz > data/caso_full.csv
1import matplotlib.pyplot as plt
2import pandas as pd
3import sqlite3
4import ibis

Como funciona o Ibis?

O Ibis, a partir da criação de Expressões, se consitui em uma camada de abstração entre as Engines de banco de dados e o usuário final. Em seu backend, o Ibis utiliza Pandas como sua Engine de execução, então podemos ler o arquivo CSV com a função read_csv() do Pandas:

1df = ibis.backends.pandas.pd.read_csv('data/caso_full.csv')
1df.head()

citycity_ibge_codedateepidemiological_weekestimated_populationestimated_population_2019is_lastis_repeatedlast_available_confirmedlast_available_confirmed_per_100k_inhabitantslast_available_datelast_available_death_ratelast_available_deathsorder_for_placeplace_typestatenew_confirmednew_deaths
0Rio Branco1200401.02020-03-17202012413418.0407319.0FalseFalse30.725662020-03-170.001cityAC30
1NaN12.02020-03-17202012894470.0881935.0FalseFalse30.335392020-03-170.001stateAC30
2Rio Branco1200401.02020-03-18202012413418.0407319.0FalseFalse30.725662020-03-180.002cityAC00
3NaN12.02020-03-18202012894470.0881935.0FalseFalse30.335392020-03-180.002stateAC00
4Rio Branco1200401.02020-03-19202012413418.0407319.0FalseFalse40.967542020-03-190.003cityAC10
1df.info()
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 3853648 entries, 0 to 3853647
Data columns (total 18 columns):
 #   Column                                         Dtype  
---  ------                                         -----  
 0   city                                           object 
 1   city_ibge_code                                 float64
 2   date                                           object 
 3   epidemiological_week                           int64  
 4   estimated_population                           float64
 5   estimated_population_2019                      float64
 6   is_last                                        bool   
 7   is_repeated                                    bool   
 8   last_available_confirmed                       int64  
 9   last_available_confirmed_per_100k_inhabitants  float64
 10  last_available_date                            object 
 11  last_available_death_rate                      float64
 12  last_available_deaths                          int64  
 13  order_for_place                                int64  
 14  place_type                                     object 
 15  state                                          object 
 16  new_confirmed                                  int64  
 17  new_deaths                                     int64  
dtypes: bool(2), float64(5), int64(6), object(5)
memory usage: 477.8+ MB

E para demonstrar o verdadeiro poder do Ibis, iremos transformar nosso arquivo CSV em uma Base de Dados SQL. Na instalação padrão do Ibis, o backend SQL é o sqlite3, então nos exemplos a seguir utilizaremos SQLite para realizar buscas na base de dados. Caso queira utilizar outra Engine SQL, como BigQuery ou Postgres, acesse a documentação oficial e siga instruções de instalação.

1df.to_sql('casos_covid19_BR', sqlite3.connect('data/casof.db'))
3853648
1con = ibis.sqlite.connect('data/casof.db')
2casos = con.table('casos_covid19_BR')

O Ibis, junto com o SQLAlchemy, utiliza grafos para representar suas Expressões. Portanto é possível visualizar todas as etapas ocorridas no Backend com a configuração de representação de grafos ativa. Da mesma forma, podemos representar o Schema da nossa tabela através da função display():

1ibis.options.graphviz_repr = True
2display(casos)

png

Assim como no Pandas, as colunas podem ser chamadas diretamente. Entretanto, como o Ibis funciona por padrão em Lazy mode, o resultado da query não fica armazenado em memória e ela só será executada utilizando o comando execute(). O lazy mode busca diminuir a utilização da memória, ao invés de executar a busca quando o objeto é instanciado, o Ibis retorna uma expressão contendo os parâmetros de busca, executando a busca somente quando necessário:

1# Semelhante ao pandas, podemos realizar uma busca SQL que retorna os valores max. e min. da coluna
2data_min = casos.date.min().name('first_entry')
3data_max = casos.date.max().name('last_entry')
1print(data_min.execute()) # Dia da primeira entrada registrada na base de dados
2print(data_max.execute()) # Dia da última entrada registrada na base de dados
2020-02-25
2022-03-27

E por que usar Ibis ao invés das ferramentas SQL diretamente?

  • Ibis, por ser uma ferramenta escrita em Python, é atraente para quem não tem tanta familiaridade com SQL. Ao automatizar as buscas, novos desenvolvedores poderão entender como as buscas são feitas na Engine SQL.
  • Com o crescimento da aplicação, os parâmetros de busca em SQL podem se tornar confusos e de difícil replicação. Com o Ibis é possível portabilizar as Expressões para serem reutilizadas em outras buscas sem a necessidade de reescrever código.
  • Caso você esteja trabalhando com diferentes Engines SQL, seja por causa do tamanho da base de dados, por exemplo. Com o Ibis é possível rapidamente conectar à uma Engine que consiga lidar diferentes volumes de dados (ClickHouse, HeavyAI, etc) e continuar com o mesmo fluxo de trabalho.

Por exemplo: já vimos que é possível criar buscas SQL através de expressões Ibis, entretanto, o oposto também pode ser visualizado. Com a função compile() podemos retornar os parâmetros da busca realizada pela Expressão Ibis na base de dados. Ou seja, é possível realizar análises das buscas na base de dados com facilidade, pois o Ibis te mostra como fez para retornar o resultado desejado:

1print(data_min.compile())
SELECT min(t0.date) AS first_entry 
FROM main."casos_covid19_BR" AS t0

Um dos pontos chave do Ibis, é a possibilidade de criar Expressões com o resultado de interesse, renomeá-las, e utilizá-las para outras buscas sem precisar repetir código:

1total_new_cases = casos['new_confirmed'].sum().name('total_new_cases')
2total_new_deaths = casos['new_deaths'].sum().name('total_new_deaths')
1total_new_cases

png

1epiweek_covid = casos.group_by('epidemiological_week').aggregate((
2                                                                    total_new_cases, 
3                                                                    total_new_deaths,
4                                                                    ))
5
6print(epiweek_covid.compile())
SELECT t0.epidemiological_week, sum(t0.new_confirmed) AS total_new_cases, sum(t0.new_deaths) AS total_new_deaths 
FROM main."casos_covid19_BR" AS t0 GROUP BY t0.epidemiological_week

Lembra que o Ibis utiliza o Pandas como Backend de execução? Podemos agora salvar o Pandas DataFrame gerado na execução em uma variável para termos acesso às funções do Pandas:

1df = epiweek_covid.execute()
2df.head()

epidemiological_weektotal_new_casestotal_new_deaths
020200940
1202010330
22020112310
3202012193530
42020135476183
1df['week'] = df['epidemiological_week'].astype(str).str[4:6]
2df['year'] = df['epidemiological_week'].astype(str).str[0:4]
3
4df.pop('epidemiological_week')
5df.insert(0, 'year', df.pop('year'))
6df.insert(1, 'week', df.pop('week'))
7df.head()

yearweektotal_new_casestotal_new_deaths
020200940
1202010330
22020112310
3202012193530
42020135476183
1df = df.head(15)
2plt.bar(df.week, df.total_new_cases)
3plt.bar(df.week, df.total_new_deaths)
4plt.xlabel('Epidemiological week')
5plt.ylabel('Population')
6plt.legend(['New cases', 'New deaths'])
7plt.show()

png

Manipulando os dados e inserindo novas colunas:

Os DataFrames gerados pelo Pandas Engine são estáticos. Isso quer dizer que ao manipular os dados, as expressões não alteram o DataFrame em si, mas preparam os parâmetros de busca para gerar um novo DataFrame a partir do comando execute(), que pode ser visualizado, armazenado e exportado.

No exemplo abaixo, estaremos parametrizando a busca a ser realizada na base de dados. Com uma sintaxe "Pandas-like", pode-se imaginar que estaremos manipulando o DataFrame em si. Entretanto, o Ibis armazena o resultado desejado para que a busca seja realizada somente quando o resultado deve ser exibido, executando-o. Ao extrair as colunas como em percentage_cases, por exemplo, uma query SQL é armazenada

Operações matemáticas entre as colunas podem ser escritas de maneira Pythonica com o Ibis e facilmente adicionadas em outros critérios de busca:

1percentage_cases = (casos['new_confirmed'] / casos['estimated_population'] * 100).name('porc_cases')

Assim como em SQL, when clauses podem ser usadas para extrair apenas certas partes dos dados. Neste exemplo estaremos extraindo as informações dos estados do Sul do país, para que no final seja retornado um DataFrame com os parâmetros definidos. Com o modo de gráficos ativo, podemos visualizar os passos armazenados nos parâmetros de busca:

1south_br = (casos.state.case()
2          .when('SC', 'Santa Catarina')
3          .when('RS', 'Rio Grande do Sul')
4          .when('PR', 'Parana')
5          .end()
6          .name('Regiao Sul'))
7
8south_br

png

Compilando as buscas com os parâmetros, podemos visualizar a query que acabamos de criar e que será executada com a função execute():

1# Query SQL referente a `percentage_cases`:
2print(percentage_cases.compile().compile(
3                                compile_kwargs={"literal_binds": True}))
SELECT (t0.new_confirmed / t0.estimated_population) * 100 AS porc_cases 
FROM main."casos_covid19_BR" AS t0
1# Query SQL referente a `south_br`
2print(south_br.compile().compile(
3                                compile_kwargs={"literal_binds": True}))
SELECT CASE WHEN (t0.state = 'SC') THEN 'Santa Catarina' WHEN (t0.state = 'RS') THEN 'Rio Grande do Sul' WHEN (t0.state = 'PR') THEN 'Parana' ELSE CAST(NULL AS TEXT) END AS "Regiao Sul" 
FROM main."casos_covid19_BR" AS t0

Agora que temos a porcentagem de casos e a região separadas em duas variáveis, podemos agregar as buscas e encontrar as porcentagem de casos nos estados em questão e retorná-lo em Dataframe:

1# Agregando as duas queries SQL como se fosse um Pandas DataFrame:
2sul = casos.group_by(south_br).aggregate(percentage_cases.mean().name('Media Casos')).dropna()
3print(sul.compile().compile(compile_kwargs={"literal_binds": True}))
SELECT t0."Regiao Sul", t0."Media Casos" 
FROM (SELECT CASE WHEN (t1.state = 'SC') THEN 'Santa Catarina' WHEN (t1.state = 'RS') THEN 'Rio Grande do Sul' WHEN (t1.state = 'PR') THEN 'Parana' ELSE CAST(NULL AS TEXT) END AS "Regiao Sul", avg((t1.new_confirmed / t1.estimated_population) * 100) AS "Media Casos" 
FROM main."casos_covid19_BR" AS t1 GROUP BY CASE WHEN (t1.state = 'SC') THEN 'Santa Catarina' WHEN (t1.state = 'RS') THEN 'Rio Grande do Sul' WHEN (t1.state = 'PR') THEN 'Parana' ELSE CAST(NULL AS TEXT) END) AS t0 
WHERE t0."Regiao Sul" IS NOT NULL AND t0."Media Casos" IS NOT NULL
1# O resultado da query com o DataFrame desejado:
2sul.execute()

Regiao SulMedia Casos
0Parana0.018670
1Rio Grande do Sul0.028637
2Santa Catarina0.029332

Conclusão

Como pudemos ver, o Ibis é uma ferramenta poderosa para acelerar sua análise de dados, capaz de integrar diferentes engines SQL com o Pandas, o Framework traz melhorias de performance e legibilidade ao código Python. Crie e realize buscas SQL como se estivesse trabalhando com um Pandas DataFrame, mas com uma economia de memória e alta portabilidade! Com o Ibis é possível utilizar o mesmo padrão de trabalho para desenvolvimento e produção, acelerar seus testes unitários, escalonar a aplicação para diferentes bases de dados, e muito mais!

Referências