#!/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/