Introducción a Polars

Introducción a Polars

Roberto Kramer Pinto
Febrero, 2023

Más sobre ciencia de datos: cienciadedatos.net

¿Qué es Polars?


Polars es una librería diseñada para trabajar con datos tabulares (DataFrames). Tiene como principal característica la capacidad de procesar grandes volúmenes de datos de forma rápida y eficiente, gracias a que maximiza el uso de todos los cores disponibles en un ordenador. Su mayor capacidad para procesar datos frente a otras librerías, por ejemplo Pandas, se debe a que está desarrollada en Rust, lo que le permite disponer de la paralelización de tareas desde su raíz. Además, utiliza Arrow arrays, una estructura de datos especialmente optimizada para realizar operaciones columnares. Actualmente, Polars dispone de APIs en Python y Rust.

Es una librería que ha ganando mucha popularidad a lo largo de los últimos años y puede ser buena alternativa frente a otros frameworks de procesamiento de datos en Python.

En este artículo se utiliza la API de Python y tiene como objetivo hacer una pequeña introducción a la sintaxis de Polars y algunas de sus principales funcionalidades.

Librerías

Estas son las librerías utilizadas en este documento (entorno Python):

In [1]:
# Librerías
# ======================================================================================
import polars as pl
from datetime import datetime

Datos

En este artículo de introducción, vamos a utilizar datos públicos del sistema nacional de salud de Inglaterra (NHS: National Health Service). El archivo de tipo csv contiene una muestra de datos de prescripciones médicas realizadas en Reino Unido (Deciembre 2014) y pueden descargarse desde el siguiente link. En este enlace se puede obtener más información sobre este dataset: link

Polars puede trabajar con distintos formatos de datos (csv, parquet, json, DBs, etc.).

In [2]:
# Lectura de datos (csv)
# ==============================================================================
file_url = (
    "https://raw.githubusercontent.com/JoaquinAmatRodrigo/"
    "Estadistica-machine-learning-python/master/data/sample_epd_201412.csv"
)
nhs_raw_df = pl.read_csv(file_url, sep=";")

print(f"Tipo de objeto: {type(nhs_raw_df)}")
print(f"Dimensiones de los datos: {nhs_raw_df.shape}")
display(nhs_raw_df.head())
Tipo de objeto: <class 'polars.internals.dataframe.frame.DataFrame'>
Dimensiones de los datos: (50000, 26)
shape: (5, 26)
YEAR_MONTH REGIONAL_OFFICE_NAME REGIONAL_OFFICE_CODE AREA_TEAM_NAME AREA_TEAM_CODE PCO_NAME PCO_CODE PRACTICE_NAME PRACTICE_CODE ADDRESS_1 ADDRESS_2 ADDRESS_3 ADDRESS_4 POSTCODE BNF_CHEMICAL_SUBSTANCE CHEMICAL_SUBSTANCE_BNF_DESCR BNF_CODE BNF_DESCRIPTION BNF_CHAPTER_PLUS_CODE QUANTITY ITEMS TOTAL_QUANTITY ADQUSAGE NIC ACTUAL_COST UNIDENTIFIED
i64 str str str str str str str str str str str str str str str str str str f64 i64 f64 f64 f64 f64 str
201412 "NORTH OF ENGLA... "Y54" "GREATER MANCHE... "Q46" "OLDHAM CCG" "00Y00" "ADDY PRACTICE" "P85603" "BARLEY CLOUGH ... "NUGGET STREET" "OLDHAM" null "OL4 1BN" "0902012H0" "Oral rehydrati... "0902012H0BBAGA... "Dioralyte oral... "09: Nutrition ... 12.0 1 12.0 3.0 4.5 4.16564 "N"
201412 "NORTH OF ENGLA... "Y54" "WEST YORKSHIRE... "Q52" "LEEDS NORTH CC... "02V00" "ALLERTON MEDIC... "B86039" "ALLERTON MEDIC... "6 MONTREAL AVE... "LEEDS" "WEST YORKSHIRE... "LS7 4LF" "1103010C0" "Chloramphenico... "1103010C0AAADA... "Chloramphenico... "11: Eye" 4.0 5 20.0 0.0 8.8 8.1839 "N"
201412 "SOUTH OF ENGLA... "Y57" "SURREY AND SUS... "Q68" "CRAWLEY CCG" "09H00" "SAXONBROOK MED... "H82026" "SAXONBROOK MED... "MAIDENBOWER SQ... "CRAWLEY" "WEST SUSSEX" "RH10 7QH" "0703010F0" "Combined ethin... "0703010F0BEABA... "Microgynon 30 ... "07: Obstetrics... 84.0 3 252.0 0.0 8.97 8.3532 "N"
201412 "LONDON" "Y56" "NORTH EAST LON... "Q61" "REDBRIDGE CCG" "08N00" "THE PALMS MEDI... "F86009" "THE PALMS MEDI... "97-101 NETLEY ... "NEWBURY PARK" "ILFORD, ESSEX" "IG2 7NW" "131002030" "Terbinafine hy... "131002030AAAAA... "Terbinafine 1%... "13: Skin" 30.0 4 120.0 0.0 14.0 12.9708 "N"
201412 "SOUTH OF ENGLA... "Y57" "BRISTOL, N SOM... "Q65" "BRISTOL CCG" "11H00" "SOUTHMEAD & HE... "L81067" "SOUTHMEAD HEAL... "ULLSWATER ROAD... "BRISTOL" null "BS10 6DF" "0206030N0" "Nicorandil" "0206030N0AAAAA... "Nicorandil 10m... "02: Cardiovasc... 10.0 71 710.0 177.5 46.86 51.2295 "N"
  • La primera cosa que podemos notar es que, la función de lectura csv, es muy similar a la syntax de Pandas .read_csv() y que también nos permite pasar argumentos como sep, columns, skip_rows, etc (API Doc)
  • El objeto devuelto es de tipo polars.internals.dataframe.frame.DataFrame, contiene 50000 filas y 26 columnas.
  • Al igual que en Pandas, los polars.DataFrame tiene atributos capaces de informarnos del shape, schema, columns y dtypes. (API Doc)
  • Los polars.DataFrame también poseen atributos y métodos que permiten explorar y visualizar los datos de forma rápida. En el código anterior hemos llamado al método .head() que devuelve por defecto las 5 primeras líneas del DataFrame. Otros métodos para una rápida exploración son .tail() y .sample().
  • Es interesante destacar que, cuando hacemos un display del DataFrame, se muestra debajo del nombre de cada columna el tipo de datos.
  • A diferencia de los DataFrame de Pandas,los polars.DataFrame no tiene un index asociado a las filas.

Para conocer más detalles sobre los distintos tipos y métodos de ingesta de datos disponibles podemos acceder directamente a su documentación.

Atributos de un DataFrame

En esta sección vamos a explorar algunos de los atributos que tiene un polars.DataFrame

In [3]:
n_chars = 50

# df.columns
# ==============================================================================
print("="*n_chars)
print("df.columns: listado de columnas de un DataFrame")
print(nhs_raw_df.columns)
print("")

# df.types
# ==============================================================================
print("="*n_chars)
print("df.types: tipos de datos de las columnas de un DataFrame")
print(nhs_raw_df.dtypes)
print(f"Tipos de datos únicos: {set(nhs_raw_df.dtypes)}")
print("")

# df.schema
# ==============================================================================
print("="*n_chars)
print("df.schema: Diccionario con las columnas y sus tipos de datos")
print(nhs_raw_df.schema)
print("")

# df.shape
# ==============================================================================
print("="*n_chars)
print("df.shape : filas x columnas de un DataFrame")
print(nhs_raw_df.shape)
print("")

# df.height
# ==============================================================================
print("="*n_chars)
print("df.height : número de filas de un DataFrame")
print(nhs_raw_df.height)
print("")

# df.width
# ==============================================================================
print("="*n_chars)
print("df.width : número de columnas de un DataFrame")
print(nhs_raw_df.width)
print("")
==================================================
df.columns: listado de columnas de un DataFrame
['YEAR_MONTH', 'REGIONAL_OFFICE_NAME', 'REGIONAL_OFFICE_CODE', 'AREA_TEAM_NAME', 'AREA_TEAM_CODE', 'PCO_NAME', 'PCO_CODE', 'PRACTICE_NAME', 'PRACTICE_CODE', 'ADDRESS_1', 'ADDRESS_2', 'ADDRESS_3', 'ADDRESS_4', 'POSTCODE', 'BNF_CHEMICAL_SUBSTANCE', 'CHEMICAL_SUBSTANCE_BNF_DESCR', 'BNF_CODE', 'BNF_DESCRIPTION', 'BNF_CHAPTER_PLUS_CODE', 'QUANTITY', 'ITEMS', 'TOTAL_QUANTITY', 'ADQUSAGE', 'NIC', 'ACTUAL_COST', 'UNIDENTIFIED']

==================================================
df.types: tipos de datos de las columnas de un DataFrame
[Int64, Utf8, Utf8, Utf8, Utf8, Utf8, Utf8, Utf8, Utf8, Utf8, Utf8, Utf8, Utf8, Utf8, Utf8, Utf8, Utf8, Utf8, Utf8, Float64, Int64, Float64, Float64, Float64, Float64, Utf8]
Tipos de datos únicos: {Utf8, Int64, Float64}

==================================================
df.schema: Diccionario con las columnas y sus tipos de datos
{'YEAR_MONTH': Int64, 'REGIONAL_OFFICE_NAME': Utf8, 'REGIONAL_OFFICE_CODE': Utf8, 'AREA_TEAM_NAME': Utf8, 'AREA_TEAM_CODE': Utf8, 'PCO_NAME': Utf8, 'PCO_CODE': Utf8, 'PRACTICE_NAME': Utf8, 'PRACTICE_CODE': Utf8, 'ADDRESS_1': Utf8, 'ADDRESS_2': Utf8, 'ADDRESS_3': Utf8, 'ADDRESS_4': Utf8, 'POSTCODE': Utf8, 'BNF_CHEMICAL_SUBSTANCE': Utf8, 'CHEMICAL_SUBSTANCE_BNF_DESCR': Utf8, 'BNF_CODE': Utf8, 'BNF_DESCRIPTION': Utf8, 'BNF_CHAPTER_PLUS_CODE': Utf8, 'QUANTITY': Float64, 'ITEMS': Int64, 'TOTAL_QUANTITY': Float64, 'ADQUSAGE': Float64, 'NIC': Float64, 'ACTUAL_COST': Float64, 'UNIDENTIFIED': Utf8}

==================================================
df.shape : filas x columnas de un DataFrame
(50000, 26)

==================================================
df.height : número de filas de un DataFrame
50000

==================================================
df.width : número de columnas de un DataFrame
26

Cómo podemos observar, Polars ha inferido tres tipos de datos distintos para nuestro dataset:

  • Float64: Numeric (64-bit floating point type)

  • Int64: Numeric (64-bit signed integer type)

  • Utf8: String (UTF-8 encoded string type)

Más información sobre los tipos de datos disponibles en Polars aquí

Métricas y valores descriptivos

Los polars.DataFrame tienen distintos métodos capaces de generar métricas descriptivas de los datos. Entre ellos destaca .describe() que nos devuelve los principales estadísticos descriptivos de cada columna. Por defecto, las métricas numéricas como mean, std, etc no están disponibles para las columnas de tipo string.

In [4]:
# df.describe()
# ==============================================================================
print("df.describe(): Summary Stats")
display(nhs_raw_df.describe())
df.describe(): Summary Stats
shape: (7, 27)
describe YEAR_MONTH REGIONAL_OFFICE_NAME REGIONAL_OFFICE_CODE AREA_TEAM_NAME AREA_TEAM_CODE PCO_NAME PCO_CODE PRACTICE_NAME PRACTICE_CODE ADDRESS_1 ADDRESS_2 ADDRESS_3 ADDRESS_4 POSTCODE BNF_CHEMICAL_SUBSTANCE CHEMICAL_SUBSTANCE_BNF_DESCR BNF_CODE BNF_DESCRIPTION BNF_CHAPTER_PLUS_CODE QUANTITY ITEMS TOTAL_QUANTITY ADQUSAGE NIC ACTUAL_COST UNIDENTIFIED
str f64 str str str str str str str str str str str str str str str str str str f64 f64 f64 f64 f64 f64 str
"count" 50000.0 "50000" "50000" "50000" "50000" "50000" "50000" "50000" "50000" "50000" "50000" "50000" "50000" "50000" "50000" "50000" "50000" "50000" "50000" 50000.0 50000.0 50000.0 50000.0 50000.0 50000.0 "50000"
"null_count" 0.0 "0" "0" "0" "0" "0" "0" "0" "0" "24" "2585" "1111" "7292" "22" "0" "0" "0" "0" "0" 0.0 0.0 0.0 0.0 0.0 0.0 "0"
"mean" 201412.0 null null null null null null null null null null null null null null null null null null 158.800484 5.0597 411.319542 120.445982 41.311326 38.300054 null
"std" 0.0 null null null null null null null null null null null null null null null null null null 998.722709 16.986033 1836.927548 645.331458 112.181317 103.600127 null
"min" 201412.0 "LONDON" "-" "ARDEN,HEREFORD... "-" "ADDACTION" "-" "(FRACTURE CLIN... "-" "-" "& WELLBEING,EX... "(OFF SOUTH HIL... "-" "-" "0101010G0" "Acamprosate ca... "0101010G0AAABA... "3M Micropore S... "01: Gastro-Int... 0.5 1.0 0.5 0.0 0.01 0.06461 "N"
"max" 201412.0 "UNIDENTIFIED" "Y57" "WEST YORKSHIRE... "Q70" "YORK TEACHING ... "RYY00" "ZETLAND MEDICA... "Y04837" "YPRES ROAD" "ZACHARY MERTON... "YORKLEY,LYDNEY... "YORKSHIRE" "YO8 9BX" "2396" "Zuclopenthixol... "23965609646" "palmdoc iCare ... "23: Stoma Appl... 45000.0 1152.0 115200.0 43007.99999 7256.56 6698.14673 "Y"
"median" 201412.0 null null null null null null null null null null null null null null null null null null 56.0 2.0 84.0 9.33333 12.65 11.86299 null

Valores Nulos

El método .null_count() devuelve un DataFrame con el total de valores nulos por columna. Más adelante en este artículo, también vamos a ver cómo encontrar y manipular estos valores.

In [5]:
# df.null_count()
# ==============================================================================
print("df.null_count(): Devuelve un DataFrame con el total de valores nulos por columna")
display(nhs_raw_df.null_count())
df.null_count(): Devuelve un DataFrame con el total de valores nulos por columna
shape: (1, 26)
YEAR_MONTH REGIONAL_OFFICE_NAME REGIONAL_OFFICE_CODE AREA_TEAM_NAME AREA_TEAM_CODE PCO_NAME PCO_CODE PRACTICE_NAME PRACTICE_CODE ADDRESS_1 ADDRESS_2 ADDRESS_3 ADDRESS_4 POSTCODE BNF_CHEMICAL_SUBSTANCE CHEMICAL_SUBSTANCE_BNF_DESCR BNF_CODE BNF_DESCRIPTION BNF_CHAPTER_PLUS_CODE QUANTITY ITEMS TOTAL_QUANTITY ADQUSAGE NIC ACTUAL_COST UNIDENTIFIED
u32 u32 u32 u32 u32 u32 u32 u32 u32 u32 u32 u32 u32 u32 u32 u32 u32 u32 u32 u32 u32 u32 u32 u32 u32 u32
0 0 0 0 0 0 0 0 0 24 2585 1111 7292 22 0 0 0 0 0 0 0 0 0 0 0 0

Polars expressions

Las Expressions son funciones/métodos utilizados a la hora de realizar operaciones con datos en Polars (e.g., selección, creación y manipulación de columnas, aplicación de filtros, entre otros). Tienen como entrada una serie y como salida otra serie, y són, por definición, un mapeo entre séries, lo que nos permite encadenarlas. Además, Polars es capaz de automatizar la ejecución de las expressions en paralelo siempre que sea posible (cuando se trabaja con múltiples columnas, por ejemplo) lo que hace de las Expressions algo muy potente.

Selección de columnas

Vamos ahora ver un ejemplo de cómo seleccionar columna(s) utilizando a Polars Expressions:

In [6]:
# .select(): Método utilizado para seleccionar una o más columnas
# ==============================================================================
nhs_raw_df.select(pl.col("REGIONAL_OFFICE_NAME"))
Out[6]:
shape: (50000, 1)
REGIONAL_OFFICE_NAME
str
"NORTH OF ENGLA...
"NORTH OF ENGLA...
"SOUTH OF ENGLA...
"LONDON"
"SOUTH OF ENGLA...
"SOUTH OF ENGLA...
"SOUTH OF ENGLA...
"NORTH OF ENGLA...
"MIDLANDS AND E...
"MIDLANDS AND E...
"SOUTH OF ENGLA...
"MIDLANDS AND E...
...
"SOUTH OF ENGLA...
"MIDLANDS AND E...
"SOUTH OF ENGLA...
"NORTH OF ENGLA...
"SOUTH OF ENGLA...
"LONDON"
"MIDLANDS AND E...
"MIDLANDS AND E...
"LONDON"
"SOUTH OF ENGLA...
"NORTH OF ENGLA...
"SOUTH OF ENGLA...
In [7]:
type(nhs_raw_df.select(pl.col("REGIONAL_OFFICE_NAME")))
Out[7]:
polars.internals.dataframe.frame.DataFrame

Cómo podemos observar en este ejemplo, hemos utilizado el método .select() para seleccionar una columna específica de nuestro DataFrame ("REGIONAL_OFFICE_NAME"). Por defecto, este método nos retorna un otro DataFrame (polars.internals.dataframe.frame.DataFrame)

Para seleccionar columnas podemos pasar una lista con las columnas deseadas:

In [8]:
nhs_raw_df.select(
    [
        pl.col("REGIONAL_OFFICE_NAME"),
        pl.col("REGIONAL_OFFICE_CODE"),
    ]
)
Out[8]:
shape: (50000, 2)
REGIONAL_OFFICE_NAME REGIONAL_OFFICE_CODE
str str
"NORTH OF ENGLA... "Y54"
"NORTH OF ENGLA... "Y54"
"SOUTH OF ENGLA... "Y57"
"LONDON" "Y56"
"SOUTH OF ENGLA... "Y57"
"SOUTH OF ENGLA... "Y57"
"SOUTH OF ENGLA... "Y57"
"NORTH OF ENGLA... "Y54"
"MIDLANDS AND E... "Y55"
"MIDLANDS AND E... "Y55"
"SOUTH OF ENGLA... "Y57"
"MIDLANDS AND E... "Y55"
... ...
"SOUTH OF ENGLA... "Y57"
"MIDLANDS AND E... "Y55"
"SOUTH OF ENGLA... "Y57"
"NORTH OF ENGLA... "Y54"
"SOUTH OF ENGLA... "Y57"
"LONDON" "Y56"
"MIDLANDS AND E... "Y55"
"MIDLANDS AND E... "Y55"
"LONDON" "Y56"
"SOUTH OF ENGLA... "Y57"
"NORTH OF ENGLA... "Y54"
"SOUTH OF ENGLA... "Y57"

Para la selección de columnas, también se puede pasar un string o una lista de strings directamente al método .select() (ejemplo abajo). Sin embargo, en general, la utilización de la expression pl.col() para seleccionar columnas es más indicada ya que tiene la ventaja de permitir encadenar y paralelizar la ejecución de otras tareas.

In [9]:
# Selección de una única columna
# ==============================================================================
display(nhs_raw_df.select("REGIONAL_OFFICE_NAME").head())

# Selección de múltiples columnas
# ==============================================================================
display(nhs_raw_df.select(["REGIONAL_OFFICE_NAME","REGIONAL_OFFICE_CODE"]).head())
shape: (5, 1)
REGIONAL_OFFICE_NAME
str
"NORTH OF ENGLA...
"NORTH OF ENGLA...
"SOUTH OF ENGLA...
"LONDON"
"SOUTH OF ENGLA...
shape: (5, 2)
REGIONAL_OFFICE_NAME REGIONAL_OFFICE_CODE
str str
"NORTH OF ENGLA... "Y54"
"NORTH OF ENGLA... "Y54"
"SOUTH OF ENGLA... "Y57"
"LONDON" "Y56"
"SOUTH OF ENGLA... "Y57"

Creando una secuencia de tareas

En esta sección vamos a entender de manera muy sencilla cómo podemos crear una secuencia de pasos para manipular nuestros datos.

Estos serán los pasos que vamos a implementar:

  • Seleccionar y renombrar columnas

  • Crear una nueva columna

  • Aplicar un filtro a una determinada columna

  • Realizar una ordenación

In [10]:
# 1. Seleccionar y renombrar columnas: "REGIONAL_OFFICE_NAME", "AREA_TEAM_NAME", "BNF_DESCRIPTION", "TOTAL_QUANTITY", "NIC"
# ==============================================================================
processed_df = (
    nhs_raw_df
    .select(
        [
            pl.col("REGIONAL_OFFICE_NAME").alias("region_name"),
            pl.col("AREA_TEAM_NAME").alias("area_name"),
            pl.col("BNF_DESCRIPTION").alias("drug_name"),
            pl.col("TOTAL_QUANTITY").alias("total_qt"),
            pl.col("NIC").alias("net_ingredient_cost"),
        ]
    )
)

processed_df.head()
Out[10]:
shape: (5, 5)
region_name area_name drug_name total_qt net_ingredient_cost
str str str f64 f64
"NORTH OF ENGLA... "GREATER MANCHE... "Dioralyte oral... 12.0 4.5
"NORTH OF ENGLA... "WEST YORKSHIRE... "Chloramphenico... 20.0 8.8
"SOUTH OF ENGLA... "SURREY AND SUS... "Microgynon 30 ... 252.0 8.97
"LONDON" "NORTH EAST LON... "Terbinafine 1%... 120.0 14.0
"SOUTH OF ENGLA... "BRISTOL, N SOM... "Nicorandil 10m... 710.0 46.86

Cómo podemos ver, una manera de renombrar las columnas es a través de la expresión alias() que puede ser encadenada a cada una de las columnas dentro del select().

In [11]:
# 2. Creación de nuevas columnas: unit_cost, execution_date
# ==============================================================================
processed_df = ( 
    processed_df
    .with_columns([
        (pl.col("net_ingredient_cost")/pl.col("total_qt")).alias("unit_cost"),
        (pl.lit(datetime.now().date()).alias("execution_date")),
    ])
)

processed_df.head()
Out[11]:
shape: (5, 7)
region_name area_name drug_name total_qt net_ingredient_cost unit_cost execution_date
str str str f64 f64 f64 date
"NORTH OF ENGLA... "GREATER MANCHE... "Dioralyte oral... 12.0 4.5 0.375 2023-02-18
"NORTH OF ENGLA... "WEST YORKSHIRE... "Chloramphenico... 20.0 8.8 0.44 2023-02-18
"SOUTH OF ENGLA... "SURREY AND SUS... "Microgynon 30 ... 252.0 8.97 0.035595 2023-02-18
"LONDON" "NORTH EAST LON... "Terbinafine 1%... 120.0 14.0 0.116667 2023-02-18
"SOUTH OF ENGLA... "BRISTOL, N SOM... "Nicorandil 10m... 710.0 46.86 0.066 2023-02-18

Para crear columnas utilizamos la expresión with_columns() donde podemos crear varias columnas a la vez. pl.lit() se utiliza cuando queremos propagar una constante para todo el DataFrame. En este ejemplo hemos creado una fecha de ejecución que ha sido propagada en todo el DataFrame en la nueva columna execution_date que fue automáticamente inferida cómo tipo date por Polars.

In [12]:
# 3. Filtrar filas: region_name = 'LONDON' or  'SOUTH OF ENGLAND'
# ==============================================================================
processed_df = ( 
    processed_df
    .filter(
        (pl.col("region_name") == "LONDON") | (pl.col("region_name") == "SOUTH OF ENGLAND")
    )
)

processed_df.head()
Out[12]:
shape: (5, 7)
region_name area_name drug_name total_qt net_ingredient_cost unit_cost execution_date
str str str f64 f64 f64 date
"SOUTH OF ENGLA... "SURREY AND SUS... "Microgynon 30 ... 252.0 8.97 0.035595 2023-02-18
"LONDON" "NORTH EAST LON... "Terbinafine 1%... 120.0 14.0 0.116667 2023-02-18
"SOUTH OF ENGLA... "BRISTOL, N SOM... "Nicorandil 10m... 710.0 46.86 0.066 2023-02-18
"SOUTH OF ENGLA... "DEVON,CORNWALL... "Bisoprolol 5mg... 1568.0 55.44 0.035357 2023-02-18
"SOUTH OF ENGLA... "WESSEX AREA" "Enalapril 20mg... 168.0 6.96 0.041429 2023-02-18
In [13]:
# 4. Ordenar resultados: net_ingredient_cost desc
# ==============================================================================
processed_df = ( 
    processed_df
    .sort("net_ingredient_cost", reverse=True)
)

processed_df.head()
Out[13]:
shape: (5, 7)
region_name area_name drug_name total_qt net_ingredient_cost unit_cost execution_date
str str str f64 f64 f64 date
"SOUTH OF ENGLA... "THAMES VALLEY ... "Prevenar 13 va... 77.0 3780.7 49.1 2023-02-18
"LONDON" "NORTH WEST LON... "Influenza vacc... 492.0 3242.28 6.59 2023-02-18
"SOUTH OF ENGLA... "THAMES VALLEY ... "Tiotropium bro... 2340.0 2613.0 1.116667 2023-02-18
"SOUTH OF ENGLA... "BATH,GLOS,SWIN... "Seretide 500 A... 62.0 2537.04 40.92 2023-02-18
"SOUTH OF ENGLA... "WESSEX AREA" "Fluticasone 25... 42.0 2498.16 59.48 2023-02-18

Para ordenar todo un DataFrame se utiliza la expresión .sort() que permite ordenar datos a partir de varias columnas de manera ascendente o descendente.

La principal ventaja de trabajar con las Polars Expressions es poder concatenarlas dentro de un solo bloque de código. Abajo, vamos a crear una función de preprocesado para ejemplificar esta secuencia de tareas:

In [14]:
# Función de preprocesado
# ==============================================================================
def preprocess_raw_data(raw_data):
    
    df = (
        raw_data
        .select(
            [
                pl.col("REGIONAL_OFFICE_NAME").alias("region_name"),
                pl.col("AREA_TEAM_NAME").alias("area_name"),
                pl.col("BNF_DESCRIPTION").alias("drug_name"),
                pl.col("TOTAL_QUANTITY").alias("total_qt"),
                pl.col("NIC").alias("net_ingredient_cost"),
            ]
        )
        .with_columns([
            (pl.col("net_ingredient_cost")/pl.col("total_qt")).alias("unit_cost"),
            (pl.lit(datetime.now().date()).alias("execution_date")),
        ])
        .filter(
            (pl.col("region_name") == "LONDON") | (pl.col("region_name") == "SOUTH OF ENGLAND")
        )
        .sort("net_ingredient_cost", reverse=True)
    )
    
    return df

nhs_processed_df = preprocess_raw_data(raw_data=nhs_raw_df)

print(nhs_processed_df.shape)
display(nhs_processed_df.head())
(19181, 7)
shape: (5, 7)
region_name area_name drug_name total_qt net_ingredient_cost unit_cost execution_date
str str str f64 f64 f64 date
"SOUTH OF ENGLA... "THAMES VALLEY ... "Prevenar 13 va... 77.0 3780.7 49.1 2023-02-18
"LONDON" "NORTH WEST LON... "Influenza vacc... 492.0 3242.28 6.59 2023-02-18
"SOUTH OF ENGLA... "THAMES VALLEY ... "Tiotropium bro... 2340.0 2613.0 1.116667 2023-02-18
"SOUTH OF ENGLA... "BATH,GLOS,SWIN... "Seretide 500 A... 62.0 2537.04 40.92 2023-02-18
"SOUTH OF ENGLA... "WESSEX AREA" "Fluticasone 25... 42.0 2498.16 59.48 2023-02-18

Lazy evaluation

Hasta el momento, todo lo que hemos ejecutado en nuestro DataFrame (nhs_raw_df:polars.DataFrame) se ejecutó en modo Eager, es decir, de manera instantánea de acuerdo con lo que hemos definido en las celdas, tal cómo ocurriría si fuera un DataFrame de Pandas.

Sin embargo, Polars también tiene una forma Lazy para evaluar/ejecutar las instrucciones de código. Esta forma Lazy permite a Polars evaluar la sintaxis de nuestro código/expresión, optimizarla y finalmente ejecutarla dentro de su engine. Esto permite, en general, mejorar aún más el rendimiento y optimizar el uso de la memoria. En modo Lazy, Polars crea y realiza un seguimiento de nuestro código en un plan lógico donde es capaz de optimizar y reordenar cada tarea antes de ejecutarlo. Vamos ahora a explorar un poco el universo de las Lazy Evaluations.

In [15]:
lazy_nhs_raw_df = nhs_raw_df.lazy()
print(f"Object type: {type(lazy_nhs_raw_df)}")
Object type: <class 'polars.internals.lazyframe.frame.LazyFrame'>
In [16]:
print(lazy_nhs_raw_df.shape)
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
<ipython-input-16-902516b4b370> in <module>
----> 1 print(lazy_nhs_raw_df.shape)

AttributeError: 'LazyFrame' object has no attribute 'shape'
In [17]:
print(lazy_nhs_raw_df)
naive plan: (run LazyFrame.describe_optimized_plan() to see the optimized plan)

  DF ["YEAR_MONTH", "REGIONAL_OFFICE_NAME", "REGIONAL_OFFICE_CODE", "AREA_TEAM_NAME"]; PROJECT */26 COLUMNS; SELECTION: "None"

En primer lugar hemos creado un polars.LazyFrame a partir de nuestro polars.DataFrame inicial a través del método .lazy().

  • Un LazyFrame es un objeto sobre el cual Polars es capaz de crear un plan de ejecución y optimizar queries, volviendo así más eficiente el proceso de transformación y manipulación de los datos.

  • Existen también maneras de realizar una lectura de datos en modo Lazy directamente, como por ejemplo: pl.scan_csv, pl.scan_parquet, etc.

Al intentar mostrar el shape de nuestro lazy dataframe se obtiene un error porque no todos los métodos disponibles para los polars.DataFrame están disponibles también para polars.LazyFrame, entre ellos el .shape()

Al llamar a nuestro polars.LazyFrame directamente, lo que nos devuelve es el plan de ejecución de datos. En este caso, no está haciendo nada en especial ya que solamente estamos cargando los datos. Pero es importante entender que, a medida que vamos trabajando con este DataFrame, el plan de ejecución también va cambiando y cuando necesitemos ejecutarlo, Polars es capaz de optimizarlo y paralelizar la ejecución de las tareas siempre que sea posible.

Vamos ahora llamar a la misma función de preprocesado pero utilizando nuestro polars.LazyFrame:

In [18]:
lazy_nhs_processed_df = preprocess_raw_data(raw_data=lazy_nhs_raw_df)
lazy_nhs_processed_df
Out[18]:

NAIVE QUERY PLAN

run LazyFrame.show_graph() to see the optimized version

polars_query SORT BY [col("net_ingredient_cost")] [(0, 0)] SORT BY [col("net_ingredient_cost")] FILTER BY ((col("region_name")) == ... [(0, 1)] FILTER BY ((col("region_name")) == ... SORT BY [col("net_ingredient_cost")] [(0, 0)]--FILTER BY ((col("region_name")) == ... [(0, 1)] WITH COLUMNS ["unit_cost","execution_date"] [(0, 2)] WITH COLUMNS ["unit_cost","execution_date"] FILTER BY ((col("region_name")) == ... [(0, 1)]--WITH COLUMNS ["unit_cost","execution_date"] [(0, 2)] π 5/26 [(0, 3)] π 5/26 WITH COLUMNS ["unit_cost","execution_date"] [(0, 2)]--π 5/26 [(0, 3)] TABLE π */26; σ -; [(0, 4)] TABLE π */26; σ -; π 5/26 [(0, 3)]--TABLE π */26; σ -; [(0, 4)]
In [19]:
lazy_nhs_processed_df.show_graph(optimized=True)
polars_query SORT BY [col("net_ingredient_cost")] [(0, 0)] SORT BY [col("net_ingredient_cost")] WITH COLUMNS ["unit_cost","execution_date"] [(0, 1)] WITH COLUMNS ["unit_cost","execution_date"] SORT BY [col("net_ingredient_cost")] [(0, 0)]--WITH COLUMNS ["unit_cost","execution_date"] [(0, 1)] FILTER BY ((col("region_name")) == ... [(0, 2)] FILTER BY ((col("region_name")) == ... WITH COLUMNS ["unit_cost","execution_date"] [(0, 1)]--FILTER BY ((col("region_name")) == ... [(0, 2)] π 5/26 [(0, 3)] π 5/26 FILTER BY ((col("region_name")) == ... [(0, 2)]--π 5/26 [(0, 3)] TABLE π 5/26; σ -; [(0, 4)] TABLE π 5/26; σ -; π 5/26 [(0, 3)]--TABLE π 5/26; σ -; [(0, 4)]

Nuestra función se ha ejecutado sin problemas y nos ha devuelto un otro objeto LazyFrame. Al intentar visualizar este objeto, cómo ya sabemos, nos salta su plan de ejecución no optimizado (NAIVE QUERY PLAN). Podemos visualizar los planes de ejecución (NAIVE QUERY PLAN/OPTIMIZED QUERY PLAN) de nuestro DataFrame a través del método: lazy_nhs_processed_df.show_graph(optimized_graph:bool)

Si comparamos el plan optimizado con el naive podemos notar que la optimizador de queries de Polars ha sido capaz de cambiar el orden de las transformaciones, poniendo la operación de filtrado de la columna "region_name" (FILTER BY) antes de la creación de las nuevas columnas (WITH COLUMNS).

Nota: Para poder visualizar los planos de ejecución puede ser necesario tener instalado graphviz en el sistema (link).

Plans

collect() y fetch()

Si en algún momento de nuestro +pipeline+ necesitamos realmente visualizar el pl.LazyDataFrame con sus datos, podemos utilizar métodos como collect() y fetch() que nos van a retornar un pl.DataFrame. El método fetch() es muy indicado para hacer debugging ya que es una operación rápida y que limita el número final de filas devueltas.

In [20]:
# .fetch(): devuelve un número limitado de líneas para hacer debugging
# ==============================================================================
print(type(lazy_nhs_processed_df.fetch(20)))
lazy_nhs_processed_df.fetch(20)
<class 'polars.internals.dataframe.frame.DataFrame'>
Out[20]:
shape: (8, 7)
region_name area_name drug_name total_qt net_ingredient_cost unit_cost execution_date
str str str f64 f64 f64 date
"SOUTH OF ENGLA... "DEVON,CORNWALL... "Bisoprolol 5mg... 1568.0 55.44 0.035357 2023-02-18
"SOUTH OF ENGLA... "THAMES VALLEY ... "Co-codamol 15m... 672.0 48.72 0.0725 2023-02-18
"SOUTH OF ENGLA... "BRISTOL, N SOM... "Nicorandil 10m... 710.0 46.86 0.066 2023-02-18
"LONDON" "NORTH EAST LON... "Terbinafine 1%... 120.0 14.0 0.116667 2023-02-18
"SOUTH OF ENGLA... "BATH,GLOS,SWIN... "Moxonidine 200... 112.0 9.68 0.086429 2023-02-18
"SOUTH OF ENGLA... "SURREY AND SUS... "Microgynon 30 ... 252.0 8.97 0.035595 2023-02-18
"SOUTH OF ENGLA... "WESSEX AREA" "Enalapril 20mg... 168.0 6.96 0.041429 2023-02-18
"SOUTH OF ENGLA... "KENT AND MEDWA... "Verapamil 40mg... 56.0 1.38 0.024643 2023-02-18
In [21]:
# .collect(): devuelve todo el DataFrame
# ==============================================================================
print(type(lazy_nhs_processed_df.collect()))
lazy_nhs_processed_df.collect()
<class 'polars.internals.dataframe.frame.DataFrame'>
Out[21]:
shape: (19181, 7)
region_name area_name drug_name total_qt net_ingredient_cost unit_cost execution_date
str str str f64 f64 f64 date
"SOUTH OF ENGLA... "THAMES VALLEY ... "Prevenar 13 va... 77.0 3780.7 49.1 2023-02-18
"LONDON" "NORTH WEST LON... "Influenza vacc... 492.0 3242.28 6.59 2023-02-18
"SOUTH OF ENGLA... "THAMES VALLEY ... "Tiotropium bro... 2340.0 2613.0 1.116667 2023-02-18
"SOUTH OF ENGLA... "BATH,GLOS,SWIN... "Seretide 500 A... 62.0 2537.04 40.92 2023-02-18
"SOUTH OF ENGLA... "WESSEX AREA" "Fluticasone 25... 42.0 2498.16 59.48 2023-02-18
"SOUTH OF ENGLA... "BRISTOL, N SOM... "Rivaroxaban 20... 1092.0 2293.2 2.1 2023-02-18
"SOUTH OF ENGLA... "WESSEX AREA" "Budesonide 400... 52.0 1976.0 38.0 2023-02-18
"LONDON" "NORTH WEST LON... "Influenza vacc... 274.0 1805.66 6.59 2023-02-18
"SOUTH OF ENGLA... "THAMES VALLEY ... "Ethinylestradi... 189.0 1800.0 9.52381 2023-02-18
"SOUTH OF ENGLA... "BRISTOL, N SOM... "Symbicort 200/... 46.0 1748.0 38.0 2023-02-18
"SOUTH OF ENGLA... "BRISTOL, N SOM... "Prostap 3 DCS ... 7.0 1580.04 225.72 2023-02-18
"SOUTH OF ENGLA... "KENT AND MEDWA... "Symbicort 200/... 40.0 1520.0 38.0 2023-02-18
... ... ... ... ... ... ...
"SOUTH OF ENGLA... "DEVON,CORNWALL... "Tegretol Prolo... 1.0 0.09 0.09 2023-02-18
"SOUTH OF ENGLA... "BATH,GLOS,SWIN... "Warfarin 1mg t... 2.0 0.08 0.04 2023-02-18
"SOUTH OF ENGLA... "DEVON,CORNWALL... "Warfarin 5mg t... 2.0 0.08 0.04 2023-02-18
"SOUTH OF ENGLA... "KENT AND MEDWA... "Zetuvit E non-... 1.0 0.07 0.07 2023-02-18
"SOUTH OF ENGLA... "THAMES VALLEY ... "Diazepam 5mg t... 2.0 0.07 0.035 2023-02-18
"SOUTH OF ENGLA... "WESSEX AREA" "Zopiclone 3.75... 1.0 0.05 0.05 2023-02-18
"LONDON" "NORTH WEST LON... "Senna 7.5mg/5m... 10.0 0.05 0.005 2023-02-18
"SOUTH OF ENGLA... "BATH,GLOS,SWIN... "Amlodipine 10m... 1.0 0.04 0.04 2023-02-18
"SOUTH OF ENGLA... "SURREY AND SUS... "Folic acid 5mg... 1.0 0.04 0.04 2023-02-18
"SOUTH OF ENGLA... "DEVON,CORNWALL... "Lisinopril 20m... 1.0 0.04 0.04 2023-02-18
"SOUTH OF ENGLA... "THAMES VALLEY ... "Metformin 850m... 1.0 0.03 0.03 2023-02-18
"SOUTH OF ENGLA... "THAMES VALLEY ... "Metoclopramide... 1.0 0.03 0.03 2023-02-18

Agrupación de Datos - GroupBy

En esta sección, vamos a explorar un ejemplo sencillo de cómo hacer un GroupBy en Polars. Básicamente, vamos a agrupar nuestros datos preprocesados a nivel del "region_name" y calcular la cantidad total de prescripciones médicas y la media de unidades solicitadas (total_sales y avg_qt).

In [22]:
# Group by Operation
# ==============================================================================
agg_df = (
    lazy_nhs_processed_df
    .groupby(["region_name"])
    .agg(
        [
            pl.col("net_ingredient_cost").sum().alias("total_sales"),
            pl.col("total_qt").mean().alias("avg_qt"),
        ]
    )
    .sort("total_sales", reverse=True)
    
).collect()

agg_df
Out[22]:
shape: (2, 3)
region_name total_sales avg_qt
str f64 f64
"SOUTH OF ENGLA... 523946.91 427.324624
"LONDON" 250269.3 351.671161

En este ejemplo hemos utilizado las funciones .sum() y .mean() para agrupar los datos. En este enlace se puede obtener más información sobre otras funciones disponibles: link

Conclusiones

En conclusión, como hemos visto en este artículo de introducción, Polars es una librería capaz de procesar datos tabulares de forma rápida y eficiente, a través de sus características avanzadas como la capacidad de paralelizar tareas y su modo de Lazy Evaluation. También por tener una API amigable, una syntax muy intuitiva y una robusta documentación, se convierte en una excelente opción para procesar datos en Python.

Información de sesión

In [23]:
import session_info
session_info.show(html=False)
-----
polars              0.16.2
session_info        1.0.0
-----
IPython             7.20.0
jupyter_client      6.1.11
jupyter_core        4.7.1
notebook            6.2.0
-----
Python 3.8.9 (default, Oct 26 2021, 07:25:54) [Clang 13.0.0 (clang-1300.0.29.30)]
macOS-11.6.3-x86_64-i386-64bit
-----
Session information updated at 2023-02-18 17:28

¿Cómo citar este documento?

Introducción a Polars by Roberto Kramer Pinto, available under a CC BY-NC-SA 4.0 at https://www.cienciadedatos.net/documentos/pyml01-intro_polars_es.html DOI


¿Te ha gustado el artículo? Tu ayuda es importante

Mantener un sitio web tiene unos costes elevados, tu contribución me ayudará a seguir generando contenido divulgativo gratuito. ¡Muchísimas gracias! 😊


Creative Commons Licence
This work by Roberto Kramer Pinto is licensed under a Creative Commons Attribution 4.0 International License.