#!/usr/bin/env python
# coding: utf-8
# # **Polars: um simples mas prático tutorial**
# ## **0. Setup e considerações iniciais**
#
#
# - Este "tutorial" não têm o objetivo de te transformar em um hard-user do Polars e sim mostrar algumas das funções e manuseio de dados mais comuns que existem no Polars
# - Se você quiser estudar um pouco da teoria do Polars, existem algumas referências na [última seção](https://nbviewer.org/github/barbosarafael/polars_python_test/blob/main/01-notebook/01-polars_notebook.ipynb#header20) deste notebook
# - A API/funções do Polars é bem parecido com a do PySpark. Eu optei por fazer esse tutorial parecido com o que já trabalhei anteriormente.
# - Em termos práticos, enquanto em algum tutorial você deva ver `pl.col()`, aqui você verá apenas `col()`
# - Fora que eu escrevo com as identações com as "/"s e alguns Enters a cada comando
# In[1]:
from polars import *
from datetime import date
import json
# ## **1. Como carregar os dados **
#
# Nada muito longe do que já é feito no Pandas ou no próprio R. Inclusive, usa exatamente a mesma síntaxe que a do Pandas :)
# ### **1.1. CSV**
# In[2]:
dados = read_csv(file = '../02-data/titanic.csv')
dados
# ### **1.2. Excel (xlsx)**
# In[3]:
dados = read_excel(file = '../02-data/titanic.xlsx')
dados.head(3)
# ## **2. Informações iniciais do dataframe **
#
# Esse tópico também não foge muito se você já trabalhou com o Pandas. Apenas a inclusão de um novo método do dataframe que é bem parecido com o que têm no Pyspark: o `.limit()`
# In[4]:
dados.head(3)
# In[5]:
dados.limit(3) #---- alô usuários do PySpark
# In[6]:
dados.tail(3)
# In[7]:
dados.shape
# In[8]:
dados.describe()
# ## **3. Selecionando colunas específicas **
#
# Aqui temos algumas particularidades do **Polars**, que também são bem parecidas com o que já existe no Pandas e no PySpark.
# ### **3.1. Por nome**
# In[9]:
dados\
.select(col('age'))\
.head(3)
# In[10]:
dados\
.select(col(['pclass', 'survived', 'name']))\
.head(3)
# ### **3.2. Por index/posição**
# In[11]:
dados[:, 0]\
.head(3)
# In[12]:
dados[:, 0:3]\
.head(3)
# ### **3.3. Bônus (1): Selecionando todas as colunas**
# In[13]:
dados\
.select(\
col('*')
)\
.head(3)
# ### **3.4. Bônus (2): Excluindo colunas**
# In[14]:
dados\
.select(\
exclude('PassengerId')
)\
.head(3)
# In[15]:
dados\
.select(\
exclude(['PassengerId', 'Survived'])
)\
.head(3)
# ## **4. Operações entre colunas **
#
#
# Existem duas funções específicas para este fim: a `with_column` e a `with_columns`.
#
# Qual a melhor usar? Vai na `with_columns`, pois ela permite criar várias colunas de uma vez só, bem parecido com o que `dplyr::mutate()` faz no R.
#
# - **Notem que para adicionar mais de uma coluna, precisa estar dentro de uma lista.**
# - **No método 2:**
# - Tem uma mudança do tipo da variável explicitamente na mesma linha da criação da coluna
# - Preenchimento de valores nulos (NAs) diretamente também
# ### **4.1. Criando apenas 1 coluna**
# In[16]:
dados\
.with_columns(\
(col('age') * col('parch')).alias('nova_coluna'),
)\
.head(3)
# ### **4.2. Criando várias colunas (bem parecido com o dplyr::mutate)**
# In[17]:
#---- Método 1:
dados\
.with_columns(\
nova_coluna = col('age') * col('parch'),
nova_coluna1 = col('age') * col('sibsp'),
nova_coluna2 = col('age') * col('fare')
)\
.head(3)
# In[79]:
#---- Método 2:
dados\
.with_columns([\
(col('age') * col('parch')).alias('nova_coluna'),
(col('age') * col('sibsp')).cast(Int64).alias('nova_coluna1'),
(col('age') * col('fare')).cast(Float32).fill_nan(0).alias('nova_coluna2')
])\
.head(3)
# ### **4.3. Bônus:**
#
# - `nova_coluna_constante`: Coluna com um valor único
# - `nova_coluna_soma`: Coluna com um valor único, que é a soma da variável Age
# - `nova_coluna_42`: Coluna com um valor somado (42) constante para a variável Age
# - `nova_coluna_case_when`: Coluna com uma ideia de case when
# - `nova_coluna_lista`: Coluna que concatena todas as variáveis em uma lista
# In[19]:
dados\
.select(col('age'))\
.with_columns(\
nova_coluna_constante = lit(1),
nova_coluna_soma = col('age').sum(),
nova_coluna_42 = col('age') + 42,
nova_coluna_case_when = when(col('age') > 10).then(lit('novo')).otherwise(lit('idoso')) # ^.^
)\
.with_columns(\
nova_coluna_lista = concat_list(all()) # Problema aqui: tive que adicionar um novo with_columns, pois ele estava criando a lista com um único elemento, que era da coluna PassengerId quando adicionava no primeiro
)\
.head(3)
# ## **5. Renomeando colunas e printando as colunas **
# Segue a mesma ideia do rename do Pandas. Irei trocar age -> Age.
# In[20]:
dados\
.rename(mapping = {'age': 'Age'})\
.columns
# ## **6. Filtros**
#
# Obviamente, alguns filtros podem ser aplicados tanto à variáveis quantitativas quanto qualitativas!
# ### **6.1. Variáveis quantitativas **
# In[21]:
dados\
.filter(col('age') > 10)\
.head(3)
# age maior que 10
# In[22]:
dados\
.filter(col('age') <= 10)\
.head(3)
# age menor ou igual a 10
# In[23]:
dados\
.filter(col('age').is_between(10, 20))\
.head(3)
# age entre 10 e 20
# In[24]:
dados\
.filter((col('age') > 10) & (col('survived') == 1))\
.head(3)
# ge maior que 10 E survided igual a 1
# In[25]:
dados\
.filter((col('age') > 10) | (col('survived') == 1))\
.head(3)
# age maior que 10 OU survided igual a 1
# ### **6.2. Variáveis qualitativas**
# In[26]:
dados\
.filter(col('embarked') == 'S')\
.head(3)
# age maior que 10 OU survided igual a 1
# In[27]:
dados\
.filter(col('embarked') != 'S')\
.head(3)
# Embarked diferente de 'S'
# In[28]:
dados\
.filter(col('embarked').is_in(['C', 'S']))\
.head(3)
# Embarked está na lista C ou S
# In[29]:
#---- Contém uma palavra
dados\
.filter(col('name').str.contains('Mr'))\
.head(3)
# In[30]:
#---- Começa com uma palavra
dados\
.filter(col('name').str.starts_with('Allison'))
# In[31]:
#---- Finaliza com uma palavra
dados\
.filter(col('name').str.ends_with('Walton'))
# ### **6.3. Variáveis de data**
# In[32]:
dates = DataFrame(date_range(date(2022, 1, 1), date(2022, 3, 1), '1d', name = 'drange'))
dates.head()
# In[33]:
dates\
.filter(col('drange') <= datetime(2022, 1, 2))
# drange menor ou igual à 02/01/2022
# In[34]:
dates\
.filter(col('drange').is_between(datetime(2022, 1, 1), datetime(2022, 1, 3)))
# drange entre 01/01/2022 e 03/01/2022
# ## **7. Funções de agregação, sem agupamento **
#
# Aqui temos algumas particularidades do **Polars**. Pelo **Polars** ser relativamente novo, em comparação com um Pandas da vida, por exemplo, algumas das funções não são criadas tão facilmente (e.g. `value_counts()` ou `dplyr::count()`).
# In[35]:
#---- value counts da variável survived
dados\
.select([\
col('survived').value_counts()
])\
.to_series().struct.unnest()
# In[36]:
#---- agregação de uma única variável: máximo da var age
dados\
.select(col('age'))\
.max()
# In[37]:
#---- Para acessar a primeira linha e trazer o valor
dados\
.select(col('age'))\
.max()\
.row(0)[0]
# ## **8. Funções de agregação, COM agupamento **
#
# Lembrem de usar colchete "[]", caso queiram adicionar mais de uma função de agregação. Atualmente, temos as seguintes funções de agregação, segundo a documentação do **Polars**.
#
# - mean
# - median
# - quantile
# - first
# - last
# - min
# - max
# - sum
# - n_unique
# - agg to series
# - return group indexes
# In[38]:
dados\
.groupby('survived')\
.agg([\
col('age').count().alias('n'), # tanto faz a variável, common kids
col('age').mean().alias('mean_age'),
col('age').median().alias('median_age'),
col('age').max().alias('max_age'),
col('age').min().alias('min_age'),
col('fare').sum().cast(Int64).alias('sum_fare'), # trocando o tipo da variável
col('fare').quantile(0.1).alias('q10_fare'),
col('fare').quantile(0.9).alias('q90_fare'),
col('name').first().alias('first_name'),
col('name').last().alias('last_name'),
col('name').n_unique().alias('unique_names')
])
# nunique
# ### **8.1. Bônus: Funções de agregações + agrupamentos + filtros (ao mesmo tempo)**
#
# Vamos considerar que você quer fazer saber somente a média da idade de quem sobreviveu ou não para os passageiros das classes 1 e 2. Podemos fazer o seguinte códiguin:
#
# **PS: Esse tipo de agregação é possível no PySpark, SQL e R, mas no Pandas não :/**
# In[39]:
dados\
.groupby('survived', maintain_order = True)\
.agg([\
col('age').filter(col('pclass').is_in([1, 2])).mean().alias('idade_media_classe_1_2'),
col('age').filter(col('pclass').is_in([2, 3])).mean().alias('idade_media_classe_2_3')
])
# In[40]:
#---- Mesma coisa que o código acima
dados\
.filter(col('pclass').is_in([1, 2]))\
.groupby('survived', maintain_order = True)\
.agg(\
col('age').mean().alias('mean_age')
)
# ## **9. Ordenando as linhas **
#
# Aos mais chegados, é a mesma lógica de:
#
# - **Pandas**: sort_values
# - **R (dplyr)**: arrange
# - **SQL**: Order By
# In[41]:
#---- Ordem crescente da variável age
dados\
.sort(by = 'age', reverse = False)\
.head(3)
# In[42]:
#---- Ordem decrescente da variável age
dados\
.sort(by = 'age', reverse = True)\
.head(3)
# In[43]:
#---- Ordem decrescente da variável age e crescente da variável fare
dados\
.sort(by = ['age', 'fare'], reverse = [True, False])\
.head(3)
# ## **10. Trabalhando com duplicatas **
#
# Funciona bem parecido com o `drop_duplicates` do Pandas.
# In[44]:
dados\
.unique(subset = 'embarked', keep = 'first')
# In[45]:
dados\
.unique(subset = ['embarked', 'pclass'], maintain_order = True, keep = 'last')
# ## **11. Lidando com valores nulos**
# ### **11.1. Identificando as variáveis com valores nulos**
# In[46]:
dados.null_count()
# ### **11.2. Filtrando os nulos**
# In[47]:
#---- Selecionando as linhas nulas da variável age
dados\
.filter(col('age').is_null())\
.head(3)
# In[48]:
#---- Excluindo as linhas nulas da variável age
dados\
.filter(~ col('age').is_null())\
.head(3)
# ### **11.3. Substituindo os valores nulos por algum outro**
# In[49]:
dados\
.with_columns(\
(col('age').fill_null(lit(0))).alias('age_fill_null')
)\
.filter(col('age').is_null())\
.head(3)
# ### **11.4. Substituindo pela mediana**
# In[50]:
dados\
.with_columns(\
(col('age').fill_null(col('age').median())).alias('age_fill_null')
)\
.filter(col('age').is_null())\
.head(3)
# ### **11.5. Substituindo baseado na interpolação**
# In[51]:
dados\
.with_columns(\
(col('age').interpolate()).alias('age_fill_null')
)\
.filter(col('age').is_null())\
.head(3)
# ### **11.6. Substituindo baseado na moda**
# In[52]:
dados\
.with_columns(\
(col('embarked').fill_null(col('embarked').mode())).alias('embarked_fill_null')
)\
.filter(col('embarked').is_null())\
.head(3)
# ### **11.7. Dropando todos os nulos de uma vez**
# In[53]:
#---- A variável body tem basicamente só nulos
dados\
.drop_nulls()
# ### **11.8. Criando uma varaiável/flag que identifica nulos de uma variável**
# In[54]:
dados\
.with_columns(\
col('age').is_null().alias('flag_age_null')
)\
.filter(col('age').is_null())\
.head(3)
# ## **12. Joins **
#
# Atualmente, segundo a documentação, existem os seguintes joins:
#
# - inner
# - left
# - outer
# - cross
# - asof
# - semi
# - anti
#
# **Exemplo copiado descaradamente da documentação, por motivos de não ter um dataset para fazer joins. No fim, basta trocar qual tipo de join você deseja utilizar, dentro da própria função.**
# In[55]:
df_customers = DataFrame(
{
"customer_id": [1, 2, 3],
"name": ["Alice", "Bob", "Charlie"],
}
)
df_customers
# In[56]:
df_orders = DataFrame(
{
"order_id": ["a", "b", "c"],
"customer_id": [1, 2, 2],
"amount": [100, 200, 300],
}
)
df_orders
# In[57]:
df_customers\
.join(df_orders, on = 'customer_id', how = 'inner') # mudar o parâmetro do how para o join desejado
# ## **13. Pivot e melt **
#
# Também temos no Polars essas funções, vou criar um exemplo somente com o pivot, pois os dados estão num formato que faz sentido.
#
# Você pode encontrar um exemplo do melt na [documentação](https://pola-rs.github.io/polars/py-polars/html/reference/dataframe/api/polars.DataFrame.melt.html#polars.DataFrame.melt) ou nesse [exemplo do Stack Overflow](https://stackoverflow.com/questions/71775175/convert-pandas-pivot-table-function-into-polars-pivot-function).
# In[58]:
dados\
.select([col('name'), col('cabin'), col('age')])\
.pivot(values = 'age', index = 'cabin', columns = 'name')\
.head(3)
# ## **14. Operações com datas **
# In[59]:
dates.head(3)
# ### **14.1. Agregações**
# In[60]:
dates\
.select(col('drange'))\
.max()
# ### **14.2. Transformando para string e fazendo o inverso também**
# In[61]:
dates\
.with_columns([\
(col('drange').cast(str)).alias('drange_string')
])\
.with_columns((col('drange_string').str.strptime(Date, fmt = '%Y-%m-%d', strict = False).cast(Date)).alias('drange_new'))\
.head(3)
# ### **14.3. Criando novas colunas de datas**
#
# **Para outras funções/métodos, ver esse [link](https://pola-rs.github.io/polars/py-polars/html/reference/series/timeseries.html).**
# In[62]:
dates\
.with_columns([\
col('drange').dt.day().alias('day'),
col('drange').dt.month().alias('month'),
col('drange').dt.year().alias('year'),
col('drange').dt.quarter().alias('quarter'),
col('drange').dt.week().alias('week'),
col('drange').dt.weekday().alias('weekday')
])\
.tail(3)
# ## **15. Exportando seus dados **
#
# Não foge muito do que temos no Pandas também.
# ### **15.1. CSV**
# In[63]:
dados.write_csv('../02-data/output_csv.csv')
# ### **15.2. XLSX**
#
# **Ainda** não existe uma função nativa para exportar para o formato xlsx. Então, o melhor jeito é transformar o dataframe Polars para Pandas e só aí fazer a exportação.
# In[64]:
dados.to_pandas().to_excel('../02-data/output_xlsx.xlsx')
# ### **15.3. Parquet**
# In[65]:
dados.write_parquet('../02-data/output_parquet.parquet')
# ## **16. Criando e aplicando funções personalizadas **
#
# Vamos criar uma função personalizada que traz **somente a primeira palavra da variável name**. Assim como no `Pandas`, o nosso combo `.apply()` + `lambda(x)` irá nos salvar, só que de um jeito diferente.
# In[66]:
def get_first_word(x):
result = x.split(',', 1)[0]
return result
# In[67]:
dados\
.with_columns([\
(col('name').apply(lambda x: get_first_word(x))).alias('first_name'),
(col('home.dest').apply(lambda x: get_first_word(x))).alias('first_word_home.dest')
])\
.head(5)
# ## **17. Trabalhando com JSONs **
#
# Exemplo retirado **descaradamente** do [Stack Overflow](https://stackoverflow.com/questions/73120642/in-python-polars-convert-a-json-string-column-to-dict-for-filtering). E também ainda não existe uma função parecida com a `json_normalize` do Pandas 😢
# In[68]:
json_list = [
"""{"name": "Maria",
"position": "developer",
"office": "Seattle"}""",
"""{"name": "Josh",
"position": "analyst",
"termination_date": "2020-01-01"}""",
"""{"name": "Jorge",
"position": "architect",
"office": "",
"manager_st_dt": "2020-01-01"}""",
]
df = DataFrame(
{
'tags': json_list,
}
).with_row_count('id', 1)
# In[69]:
#---- Abrindo o JSON em cada uma das chaves
df.with_columns([
col('tags').str.json_path_match(r'$.name').alias('name'),
col('tags').str.json_path_match(r'$.office').alias('location'),
col('tags').str.json_path_match(r'$.manager_st_dt').alias('manager_start_date'),
])
# In[70]:
#---- Abrindo todas as chaves do JSON de uma vez (não testado em JSON dentro de JSON)
df.select(col('tags').apply(json.loads)).unnest('tags')
# ## **18. Window functions **
#
# Conheci essa ideia de Window Function dentro do SQL, com as funções mais básicas de `row_number` e `rank`. Se eu pudesse traduzi-la para quem não conhece, seria um `groupby` com mais funcionalidades. Como teoria, recomendo ler esse [link de como usar window functions em SQL](https://portosql.wordpress.com/2018/10/14/funcoes-de-janela-window-functions/).
#
# Vou utilizar como exemplo o dataset que está na [documentação do Polars](https://pola-rs.github.io/polars-book/user-guide/dsl/window_functions.html). Esse dataset está no contexto do desenho Pokémon e está na granularidade de cada um dos bichinhos (pokémons) junto às suas características, como velocidade, ataque e etc.
# In[71]:
df = read_csv(
"https://gist.githubusercontent.com/ritchie46/cac6b337ea52281aa23c049250a4ff03/raw/89a957ff3919d90e6ef2d34235e6bf22304f3366/pokemon.csv"
)
df.head(5)
# ### **18.1. Row number **
#
# Vamos criar uma coluna que conta a quantidade de cada um dos **Type 1** disponíveis.
#
# Abaixo filtrei somente os pokémons onde o Type 1 é Fire. Notem que a coluna "#", que representa um ID da linha, mostra que a contagem das linhas continuou mesmo após um longo pulo das linhas. Em SQL, é equivalente a:
#
# > ROW_NUMBER() OVER (PARTITION BY Type1)
# In[72]:
df\
.with_columns(\
lit(1).alias('constante')
)\
.select([\
col('#'),
col('Name'),
col('Type 1'),
col('constante').cumsum().over('Type 1').alias('row_number_type1') # Criação efetiva do row_number
])\
.filter(col('Type 1') == 'Fire')
# ### **18.2. Rank **
#
# A ideia agora é **rankear** os tipos de Pokémons (Type 1) por seu ataque (Attack). Para isso, vamos criar uma coluna que faz essa indicação de qual a linha possui qual rank.
#
# > RANK(Attack) OVER (PARTITION BY Type1 ORDER BY Attack ASC)
#
# Notem que foi criado uma nova coluna chamada `rank_attack_by_type1` que mostra qual o rank daquela linha baseado no ataque e "agrupada" para cada um dos `Type 1`.
# In[73]:
df\
.with_columns(\
(col('Attack').rank('dense').over('Type 1')).alias('rank_attack_by_type1')
)\
.filter(col('Type 1') == 'Fire')
# ## **19. Um pouco de teoria **
# ### **19.1. Alguns pontos importantes **
#
# - **Polars** é escrito em Rust + Arrow para otimizar o código. Diferente do Pandas que é escrito em C. Pelos boatos que o pessoal comenta por aí, Rust poderá ser uma alternativa ao Python para Machine Learning no futuro
# - Otimizar o código, utilizar todos cores do seu CPU (não tenho info sobre GPU) e fazer com você consiga trabalhar com aquele dataset que é maior que a sua memória RAM são os objetivos principais do **Polars**
# ### **19.2. Entendendo o Lazy Evaluation **
#
# Usuários de Spark já conhecem.
#
# - **Eager evaluation**: quando você salva na memória os dados e todas suas manipulações/modificações. Algo bem parecido com o que o Pandas faz atualmente
# - **Lazy evaluation**: quando você aplica todas suas manipulações/modificações nos dados mas essa execução só é feita quando você desejar. Ex.: você cria novas colunas e aplica filtros, mas você só executa TUDO isso quando você desejar (no final). É bom e ruim ao mesmo tempo. E bem chatinho de explicar, portanto, sigam esse [link para mais detalhes](https://towardsdatascience.com/3-reasons-why-sparks-lazy-evaluation-is-useful-ed06e27360c4#:~:text=Lazy%20Evaluation%20is%20an%20evaluation,used%20in%20most%20programming%20languages.)
# **Abaixo como podemos deixar o nosso dataframe como um objetivo **lazy** dentro do Polars, adicionando apenas uma linha de comando.**
# In[74]:
iris = read_csv("https://j.mp/iriscsv")
iris.head(3)
# In[75]:
#---- Notem que ele foi para um "Naive Plan"
iris\
.lazy()
# In[76]:
#---- Criei uma coluna nova e mesmo assim ele não me mostrou o output
iris\
.lazy()\
.with_columns(\
(col('sepal_length') * 5).alias('new_sepal_length')
)
# **Para recuperar o seu dataframe após as manipulações/modificações nos dados, basta você utilizar um `.collect()`.**
# In[77]:
#---- Utilizando o collect para recuperar o dataframe
iris\
.lazy()\
.with_columns(\
(col('sepal_length') * 5).alias('new_sepal_length')
)\
.filter(col('new_sepal_length') > 25)\
.collect()\
.head()
# ## **20. Bônus e referências **
#
# Dica da [Cenobita](https://twitter.com/Inferente3) que recentemente precisou testar o Polars para uma tarefa de tratar dados, segundo ela, enormes.
#
# Mesmo que seus dados não caibam na sua memória, você consegue aplicar toda sua manipulação, filtros e tudo mais nesses dados e salvar o resultado em um arquivo `parquet`. E tudo isso é possível graças a combinação do `lazy()` com o `sink_parquet()`.
#
# Para mais detalhes, veja este link sobre ["Sinking larger-than-memory Parquet files"](https://www.rhosignal.com/posts/sink-parquet-files/).
#
# Na prática, como ele funciona:
# In[78]:
read_csv("https://j.mp/iriscsv")\
.lazy()\
.with_columns(\
(col('sepal_length') * 5).alias('new_sepal_length')
)\
.filter(col('new_sepal_length') > 25)\
.sink_parquet('../02-data/sink_parquet_output.parquet')
# ### **Referências**:
#
# - **User guide (referência mais genérica e completa):** https://pola-rs.github.io/polars-book/user-guide/index.html
# - **Data Cleansing in Polars:** https://towardsdatascience.com/data-cleansing-in-polars-f9314ea04a8e
# - **Getting Started with the Polars DataFrame Library:** https://towardsdatascience.com/getting-started-with-the-polars-dataframe-library-6f9e1c014c5c
# - **Using the Polars DataFrame Library:** https://www.codemag.com/Article/2212051/Using-the-Polars-DataFrame-Library
# - **Calmcode - polars: introduction:** https://calmcode.io/polars/introduction.html
# - **Pandas vs. Polars: A Syntax and Speed Comparison:** https://towardsdatascience.com/pandas-vs-polars-a-syntax-and-speed-comparison-5aa54e27497e#:~:text=The%20main%20advantage%20of%20Polars,switch%20from%20Pandas%20to%20Polars.
# - **Sinking larger-than-memory Parquet files:** https://www.rhosignal.com/posts/sink-parquet-files/