Polars - DataFrame

Un DataFrame és un conjunt de sèries.

Introducció

Encara que un Dataframe i una Tabla d’una base de dades poden semblar el mateix, la seva disposició en memòria és completament diferent perquè tenen proposits d’ús diferent.

Taula

Una taula d’una base de dades és una agrupació de files, on cada fila és un conjunt de dades relacionades.

En una base de dades la unitat de treball són les files, i les tasques habituals són afegir files, actualitzar les files, eliminar les files, localitzar files, etc.

namebirthdateweightheight
Alice Archer1997-1-1057.91.56
Ben Brown1985-2-1072.51.77
Chloe Cooper1983-3-2253.61.65
Daniel Donovan1981-4-3083.11.75

La memòria de l’ordinador no és bidimensional.

Les files es disposen una darrere a l’altre en memòria:

name
birthdate
weight
height
Alice Archer
1997-1-10
57.9
1.56
Ben Brown
1985-2-10
72.5
1.77
Chloe Cooper
1983-3-22
53.6
1.65
Daniel Donovan
1981-4-30
83.1
1.75

Per calcular la mitja dels valors de la columna height de la taula, cal recorrer tota la memòria on està emmagatzemada la taula.

L’operació més habitual és buscar files, i per tal que aquesta operació sigui eficient, s’utilitzen índexs que permeten localitzar les files en un temps constant.

El problema és que els índexs ocupen molta memòria!

DataFrame

Un dataframe és un conjunt de Series (o columnes) encara que es presenti com una taula.

name
Alice Archer
Ben Brown
Chloe Cooper
Daniel Donovan
birthdate
1997-1-10
1985-2-10
1983-3-22
1981-4-30
weight
57.9
72.5
53.6
83.1
height
1.56
1.77
1.65
1.75

I en memòria es disposen en columnes.

name
Alice Archer
Ben Brown
Chloe Cooper
Daniel Donovan
birthdate
1997-1-10
1985-2-10
1983-3-22
1981-4-30
weight
57.9
72.5
53.6
83.1
height
1.56
1.77
1.65
1.75

Per calcular la mitja dels valors de la sèrie height només cal recorrer una part mínima de la memòria on està emmagatzemant el dataframe.

DataFrame

El següent fragment mostra com crear un dataframe a partir d’un diccionari de llistes:

import polars as pl
from datetime import date

df = pl.DataFrame(
    {
        "name": ["Alice Archer", "Ben Brown", "Chloe Cooper", "Daniel Donovan"],
        "birthdate": [
            date(1997, 1, 10),
            date(1985, 2, 15),
            date(1983, 3, 22),
            date(1981, 4, 30),
        ],
        "weight": [57.9, 72.5, 53.6, 83.1],  # (kg)
        "height": [1.56, 1.77, 1.65, 1.75],  # (m)
    }
)

select

A Series vas veure que pots operar amb diferents sèries de dades de diferents maneres.

Amb un dataframe pots fer el mateix, l’únic que has de fer és seleccionar les columnes (sèries) que vols utilitzar amba en el context select:

Per exemple, pots seleccionar la columna birthdate i obtenir l’any:

years = df.select(pl.col("birthdate").dt.year())
print(years)
┌───────────┐
│ birthdate │
│ ---       │
│ i32       │
╞═══════════╡
│ 1997      │
│ 1985      │
│ 1983      │
│ 1981      │
└───────────┘

El resultat és un dataframe en qué només està la columna que has seleccionat amb les operacions que has fet en aquella columna.

Naturalment, pots seleccionar tantes columnes com vulguis, i no cal que facis res amb elles.

Per exemple, pots seleccionar també el nom de les persones per no perdre aquesta informació en el nou dataframe:

df = df.select(pl.col("name"), pl.col("birthdate").dt.year())
print(df.head(1))
shape: (1, 2)
┌──────────────┬───────────┐
│ name         ┆ birthdate │
│ ---          ┆ ---       │
│ str          ┆ i32       │
╞══════════════╪═══════════╡
│ Alice Archer ┆ 1997      │
└──────────────┴───────────┘
Activitat

Enlloc de l’any de naixement volem tenir l’edat:

Activitat

Calcula l’índex de massa corporal (BMI) de cada persona.

El bmi és el valor de la columna weight dividida per la columna height al quadrat:

Activitat

A continuació tens un dataframe amb les notes dels alumnes de l’assignatura de matemàtiques:

df = pl.DataFrame(
    {
        "name": ["David", "Maria", "Sandra", "Jordi"],
        "algebra": [9, 5, 10, 9],
        "calculus": [10, 9, 7, 8],
        "probability": [10, 5, None, 4]
    }
)

Calcula la nota final (tots els parcials tenen el mateix pes):

Esquema

L’esquema d’un dataframe està format per l’associació entre els noms de cada sèrie (o columna) i els tipus de dades d’aquestes mateixes sèries (o columnes).

Igual que amb les Polars - Series, Polars infereix l’esquema d’un dataframe quan el crees:

from datetime import date

df = pl.DataFrame(
    {
        "name": ["Alice Archer", "Ben Brown", "Chloe Cooper", "Daniel Donovan"],
        "birthdate": [
            date(1997, 1, 10),
            date(1985, 2, 15),
            date(1983, 3, 22),
            date(1981, 4, 30),
        ],
        "weight": [57.9, 72.5, 53.6, 83.1],  # (kg)
        "height": [1.56, 1.77, 1.65, 1.75],  # (m)
    }
)

print(df.schema)
Schema({'name': String, 'birthdate': Date, 'weight': Float64, 'height': Float64})

Si vols, i en molts casos ho has de fer, pots especificar el tipus de dades de cada columna.

De la mateixa manera que amb una Series pots sobreescriure el tipus que utilitza Polars mitjançant un diccionari que mapi els noms de columna als tipus de dades.

Pots usar el valor None si no vols sobreescriure la inferència per a una columna determinada.

df = pl.DataFrame(
    {
        "name": ["Alice", "Ben", "Chloe", "Daniel"],
        "age": [27, 39, 41, 43],
    },
    schema={"name": None, "age": pl.UInt8},
)

print(df)
shape: (4, 2)
┌────────┬─────┐
│ name   ┆ age │
│ ---    ┆ --- │
│ str    ┆ u8  │
╞════════╪═════╡
│ Alice  ┆ 27  │
│ Ben    ┆ 39  │
│ Chloe  ┆ 41  │
│ Daniel ┆ 43  │
└────────┴─────┘

Si només necessites sobreescriure la inferència d’algunes columnes, el paràmetre schema_overrides sol ser més convenient perquè et permet ometre les columnes per a les quals no vols sobreescriure la inferència:

df = pl.DataFrame(
    {
        "name": ["Alice", "Ben", "Chloe", "Daniel"],
        "age": [27, 39, 41, 43],
    },
    schema_overrides={"age": pl.UInt8},
)

print(df)
shape: (4, 2)
┌────────┬─────┐
│ name   ┆ age │
│ ---    ┆ --- │
│ str    ┆ u8  │
╞════════╪═════╡
│ Alice  ┆ 27  │
│ Ben    ┆ 39  │
│ Chloe  ┆ 41  │
│ Daniel ┆ 43  │
└────────┴─────┘

CSV

Un dels formats de text més populars és csv, que significa valors separats per comes.*

Aquest format pot emmagatzemar dades tabulars: cada fila d’un fitxer representa una fila d’una taula, i els valors corresponents a diferents columnes estan separats per comes.

read_csv

Suposem que tens el fitxer students.csv que emmagatzema les dades d’uns estudiants:

First Name,Family Name,Age
Anna,Smith,21
Bob,Jones,20
Maria,Williams,25
Jack,Brown,22

Per moure les dades dels estudiants a un DataFrame, pots utilitzar la funció read_csv()

df = pl.read_csv('students.csv')
print(df.schema)
Schema({'First Name': String, 'Family Name': String, 'Age': Int64})

La funció read_csv té diversos paràmetres amb valors per defecte.

Un dels més importants és separator, que indica el delimitador que s’utilitza per separar els camps és ,.

Et pots trobar un csv que utilitza un delimitador diferent, per exemple ;.

Activitat

Torna a carregar el fitxer students.csv:

  • Només amb les columnes First Name i Age
  • La columna First Name ha de tenir el nom Name:
  • La columna Age ha de ser de tipus UInt8:
Activitat

Crea el fitxer cars.csv:

Car Name;Price;Condition;Year;Fuel Type
Honda Civic;22000;Used;2021;Gasoline
Ford Mustang;35000;New;2023;Gasoline
Chevrolet Camaro;40000.5;Used;2020;Gasoline
Tesla Model 3;50000;New;2023;Electric
BMW X5;60000;Used;2022;Gasoline
Audi A4;30000;New;2023;Diesel
Toyota Corolla;24999.9;New;2023;Gasoline

Carrega les dades especificant els tipus de les columnes :

Temporal types

It’s important to understand that dates and times can be represented in various formats. Here are a few common string date formats:

  • YYYY-MM-DD (e.g., “2024-09-05”)
  • DD/MM/YYYY (e.g., “05/09/2024”)
  • YYYY-MM-DD HH:MM:SS (e.g., “2024-09-05 14:30:00”)

write_csv

import polars as pl
import datetime as dt

df = pl.DataFrame(
    {
        "name": ["Alice Archer", "Ben Brown", "Chloe Cooper", "Daniel Donovan"],
        "birthdate": [
            dt.date(1997, 1, 10),
            dt.date(1985, 2, 15),
            dt.date(1983, 3, 22),
            dt.date(1981, 4, 30),
        ],
        "weight": [57.9, 72.5, 53.6, 83.1],  # (kg)
        "height": [1.56, 1.77, 1.65, 1.75],  # (m)
    }
)

En l’exemple següent escrivim el dataframe a un fitxer csv anomenat output.csv.

Després, el tornem a llegir utilitzant read_csv i tot seguit n’imprimim el resultat per a la seva inspecció.

df.write_csv("data/output.csv")
df_csv = pl.read_csv("data/output.csv", try_parse_dates=True)
print(df_csv)

Polars et permet escanejar una entrada CSV. L’escaneig retarda l’anàlisi real del fitxer i, en lloc d’això, retorna un contenidor de càlcul “lazy” anomenat LazyFrame.

df = pl.scan_csv("data/output.csv")

Si vols saber per què això és desitjable, pots llegir més sobre aquestes optimitzacions de Polars aquí: [Lazy-API](https://docs.pola.rs/user-guide/concepts/lazy-api/](https://docs.pola.rs/user-guide/concepts/lazy-api/).

Inspeccionar

PENDENT amb un DF gran

A continuació veurem alguns mètodes útils per inspeccionar ràpidament un dataframe.

La funció head mostra les primeres files d’un dataframe.

Per defecte, obtens les primeres 5 files, però també pots especificar el nombre de files que vols:

print(df.head(2))
shape: (2, 4)
┌───────┬─────────┬──────────┬─────────────┐
│ name  ┆ algebra ┆ calculus ┆ probability │
│ ---   ┆ ---     ┆ ---      ┆ ---         │
│ str   ┆ i64     ┆ i64      ┆ i64         │
╞═══════╪═════════╪══════════╪═════════════╡
│ David ┆ 9       ┆ 10       ┆ 10          │
│ Maria ┆ 5       ┆ 9        ┆ 5           │
└───────┴─────────┴──────────┴─────────────┘

glimpse

La funció glimpse és una altra funció que mostra els valors de les primeres files d’un dataframe, però formata la sortida de manera diferent que head.

Aquí, cada línia de la sortida correspon a una única columna, fent que sigui més fàcil inspeccionar dataframes més amples:

print(df.glimpse(return_as_string=True))
Rows: 4
Columns: 4
$ name        <str> 'David', 'Maria', 'Sandra', 'Jordi'
$ algebra     <i64> 9, 5, 10, 9
$ calculus    <i64> 10, 9, 7, 8
$ probability <i64> 10, 5, None, 4

tail

La funció tail mostra les últimes files d’un dataframe.

Per defecte, obtens les últimes 5 files, però també pots especificar el nombre de files que vols, de manera similar a com funciona head:

print(df.tail(3))
shape: (2, 4)
┌────────┬─────────┬──────────┬─────────────┐
│ name   ┆ algebra ┆ calculus ┆ probability │
│ ---    ┆ ---     ┆ ---      ┆ ---         │
│ str    ┆ i64     ┆ i64      ┆ i64         │
╞════════╪═════════╪══════════╪═════════════╡
│ Sandra ┆ 10      ┆ 7        ┆ null        │
│ Jordi  ┆ 9       ┆ 8        ┆ 4           │
└────────┴─────────┴──────────┴─────────────┘

sample

Si creus que les primeres o últimes files del teu dataframe no són representatives de les teves dades, pots utilitzar sample per obtenir un nombre arbitrari de files seleccionades aleatòriament del dataframe.

Tingues en compte que les files no necessàriament es retornen en el mateix ordre en què apareixen al dataframe:

import random

random.seed(42)  # For reproducibility.

print(df.sample(2))
shape: (2, 4)
┌──────────────┬────────────┬────────┬────────┐
│ name         ┆ birthdate  ┆ weight ┆ height │
│ ---          ┆ ---        ┆ ---    ┆ ---    │
│ str          ┆ date       ┆ f64    ┆ f64    │
╞══════════════╪════════════╪════════╪════════╡
│ Alice Archer ┆ 1997-01-10 ┆ 57.9   ┆ 1.56   │
│ Ben Brown    ┆ 1985-02-15 ┆ 72.5   ┆ 1.77   │
└──────────────┴────────────┴────────┴────────┘

describe

També pots utilitzar describe per calcular estadístiques resum per a totes les columnes del teu DataFrame:

print(df.describe())
shape: (9, 5)
┌────────────┬────────────────┬─────────────────────┬───────────┬──────────┐
│ statistic  ┆ name           ┆ birthdate           ┆ weight    ┆ height   │
│ ---        ┆ ---            ┆ ---                 ┆ ---       ┆ ---      │
│ str        ┆ str            ┆ str                 ┆ f64       ┆ f64      │
╞════════════╪════════════════╪═════════════════════╪═══════════╪══════════╡
│ count      ┆ 4              ┆ 4                   ┆ 4.0       ┆ 4.0      │
│ null_count ┆ 0              ┆ 0                   ┆ 0.0       ┆ 0.0      │
│ mean       ┆ null           ┆ 1986-09-04 00:00:00 ┆ 66.775    ┆ 1.6825   │
│ std        ┆ null           ┆ null                ┆ 13.560082 ┆ 0.097082 │
│ min        ┆ Alice Archer   ┆ 1981-04-30          ┆ 53.6      ┆ 1.56     │
│ 25%        ┆ null           ┆ 1983-03-22          ┆ 57.9      ┆ 1.65     │
│ 50%        ┆ null           ┆ 1985-02-15          ┆ 72.5      ┆ 1.75     │
│ 75%        ┆ null           ┆ 1985-02-15          ┆ 72.5      ┆ 1.75     │
│ max        ┆ Daniel Donovan ┆ 1997-01-10          ┆ 83.1      ┆ 1.77     │
└────────────┴────────────────┴─────────────────────┴───────────┴──────────┘

Parquet

Apache Parquet és un format de fitxer de dades de codi obert, orientat a columnes, dissenyat per a l’emmagatzematge i la recuperació eficients de dades. Proporciona esquemes de compressió i codificació d’alt rendiment per gestionar dades complexes a gran escala i és compatible amb molts llenguatges de programació i eines analítiques.

Carregar o escriure fitxers Parquet és molt ràpid, ja que la disposició de les dades en un DataFrame de Polars a memòria reflecteix en molts aspectes la disposició d’un fitxer Parquet en disc.

A diferència del CSV, Parquet és un format columnar. Això vol dir que les dades s’emmagatzemen per columnes en lloc de per files. És una manera més eficient d’emmagatzemar dades perquè permet una millor compressió i un accés més ràpid a les dades.

Pots llegir un fitxer Parquet en un DataFrame amb la funció read_parquet:

df = pl.read_parquet("docs/assets/data/path.parquet")

write_parquet s’utilitza per escriure un DataFrame en un fitxer Parquet:

df = pl.DataFrame({"foo": [1, 2, 3], "bar": [None, "bak", "baz"]})
df.write_parquet("docs/assets/data/path.parquet")

Polars et permet escanejar una entrada Parquet.

L’escaneig retarda l’anàlisi real del fitxer i, en lloc d’això, retorna un contenidor de càlcul “lazy” anomenat LazyFrame.

df = pl.scan_parquet("docs/assets/data/path.parquet")

Quan escanegem un fitxer Parquet emmagatzemat al núvol, també podem aplicar predicate i projection pushdowns. Això pot reduir significativament la quantitat de dades que cal descarregar.

Per a l’escaneig d’un fitxer Parquet al núvol, consulta Cloud storage.

JSON

Polars pot llegir i escriure tant JSON estàndard com JSON delimitat per noves línies (NDJSON).

La lectura d’un fitxer JSON hauria de ser familiar:

df = pl.read_json("docs/assets/data/path.json")

Els objectes JSON delimitats per noves línies es poden llegir a Polars d’una manera molt més eficient que el JSON estàndard.

Polars pot llegir un fitxer NDJSON en un DataFrame utilitzant la funció read_ndjson:

df = pl.read_ndjson("docs/assets/data/path.json")
df = pl.DataFrame({"foo": [1, 2, 3], "bar": [None, "bak", "baz"]})
df.write_json("docs/assets/data/path.json")
df.write_ndjson("docs/assets/data/path.json")

Polars et permet escanejar una entrada JSON només per a JSON delimitat per noves línies.

L’escaneig retarda l’anàlisi real del fitxer i, en lloc d’això, retorna un contenidor de càlcul mandrós “lazy” LazyFrame.

df = pl.scan_ndjson("docs/assets/data/path.json")

Arrow

Apache Arrow és un format columnar universal i una caixa d’eines multillenguatge per a l’intercanvi ràpid de dades i l’analítica en memòria.

El projecte especifica un format de memòria orientat a columnes, independent del llenguatge, per a dades planes i jeràrquiques, organitzat per a operacions analítiques eficients en maquinari modern. El projecte conté una col·lecció de biblioteques activament desenvolupades en molts llenguatges per resoldre problemes relacionats amb la transferència de dades i el processament analític en memòria. Això inclou aspectes com:

  • Moviment de dades amb memòria compartida sense còpies (zero-copy) i RPC
  • Lectura i escriptura de formats de fitxer (com CSV, Apache ORC i Apache Parquet)
  • Analítica en memòria i processament de consultes