Ubuntu TechHive
rust-and-data-processing-with-polars.md
Rust and Data Processing with Polars
article.détail

Rust and Data Processing with Polars

reading.progression 14 min de lecture

Une introduction rapide aux bases de Rust ainsi qu'au traitement de données avec Polars

Traitement de données avec Rust et Polars

Ce qui rend Rust différent

  • Compilé et rapide — compilé en code machine natif, pas de runtime/GC
  • Sûr pour la mémoire — le compilateur empêche des classes entières de bugs (erreurs de pointeur nul, courses aux données) avant que votre programme ne s'exécute
  • Fortement et statiquement typé — chaque valeur a un type connu à la compilation ; le compilateur détecte les erreurs de correspondance très tôt

Variables et mutabilité

Les variables sont immuables par défaut. Vous optez pour la mutabilité avec mut.

let x = 5;          // immuable -- ne peut pas être réassigné
let mut y = 10;     // mutable
y = 20;             // OK grâce à `mut`
// x = 6;           // ERREUR DE COMPILATION : impossible d'assigner deux fois à `x`

const MAX: u32 = 100_000;  // constante : toujours immuable, type requis

Ce comportement par défaut inverse l'attente habituelle : vous déclarez explicitement ce qui peut changer, ce qui rend le code plus facile à raisonner.

Types de données de base

Types scalaires

  • Entiers : i32, i64, u32, u64 … (i = signé, u = non signé ; nombre = bits). i32 est le défaut.
  • Flottants : f64 (défaut), f32
  • Booléen : bool -> true / false
  • Caractère : char -> un seul caractère Unicode, entre guillemets simples
let count: i64 = 42;
let price: f64 = 19.99;
let is_ready: bool = true;
let letter: char = 'A';

Types composés

  • Tuple : groupe de taille fixe de types mixtes
  • Tableau : taille fixe, tous du même type
let person: (i32, f64, char) = (30, 5.9, 'M');
let height = person.1;          // accès par index -> 5.9

let nums: [i32; 3] = [1, 2, 3]; // tableau de 3 i32
let first = nums[0];            // -> 1

Chaînes de caractères : deux types

  • &str — une "tranche de chaîne" (string slice), généralement un littéral de chaîne fixe/emprunté
  • String — une chaîne possédée (owned), extensible, que vous pouvez modifier
let literal: &str = "hello";          // texte fixe
let mut owned: String = String::from("hello");
owned.push_str(", world");            // peut grandir car elle est possédée

Fonctions

  • Déclarées avec fn
  • Les types des paramètres sont requis ; le type de retour vient après ->
  • La dernière expression (sans point-virgule) est la valeur de retour
fn add(a: i32, b: i32) -> i32 {
    a + b          // pas de point-virgule = ceci est la valeur de retour
}

fn greet(name: &str) {   // pas de `->` signifie qu'elle ne retourne rien
    println!("Hello, {name}!");
}

fn main() {
    let sum = add(2, 3);     // chaque programme commence par main()
    println!("Sum: {sum}");
    greet("Aziz");
}

Note : println! est une macro (le ! le trahit), pas une fonction.

Flux de contrôle

if / else (c'est une expression !)

let n = 7;
if n % 2 == 0 {
    println!("even");
} else {
    println!("odd");
}

// Comme `if` retourne une valeur, vous pouvez l'utiliser pour assigner :
let label = if n > 5 { "big" } else { "small" };

Boucles

// loop : tourne indéfiniment jusqu'à un `break`
let mut i = 0;
loop {
    if i >= 3 { break; }
    i += 1;
}

// while
let mut c = 3;
while c > 0 {
    println!("{c}");
    c -= 1;
}

// for : le plus courant -- itérer sur une plage ou une collection
for k in 0..3 {            // 0, 1, 2 (fin exclue)
    println!("k = {k}");
}

Propriété (Ownership) : Le concept clé

La fonctionnalité phare de Rust. Trois règles :

  1. Chaque valeur a un propriétaire
  2. Il n'y a qu'un seul propriétaire à la fois
  3. Quand le propriétaire sort de portée, la valeur est nettoyée
let s1 = String::from("hi");
let s2 = s1;              // la propriété est DÉPLACÉE vers s2
// println!("{s1}");      // ERREUR : s1 n'est plus valide

// Pour laisser une autre fonction utiliser une valeur SANS prendre la propriété,
// vous l'empruntez avec & (une référence) :
fn length(s: &String) -> usize {
    s.len()              // lit s, ne le possède pas
}
let word = String::from("rust");
let n = length(&word);   // prêt ; `word` est toujours utilisable après

C'est ce qui permet à Rust de garantir la sécurité mémoire sans ramasse-miettes (garbage collector). C'est la partie qui demande le plus d'habitude.

Structs : Types de données personnalisés

struct Order  {
    id: i64,
    amount: f64,
    shipped: bool,
}

let o = Order { id: 1, amount: 42.5, shipped: true };
println!("Order {} costs {}", o.id, o.amount);

Enums et filtrage par motif (Pattern Matching)

Les enums permettent à une valeur d'être l'une des plusieurs variantes ; match gère chacune d'elles.

enum Status {
    Pending,
    Shipped,
    Cancelled,
}

let s = Status::Shipped;
match s {
    Status::Pending   => println!("waiting"),
    Status::Shipped   => println!("on the way"),
    Status::Cancelled => println!("nope"),
}

match doit être exhaustif — gérez chaque cas ou le code ne compilera pas. Une autre façon dont le compilateur vous empêche d'oublier des choses.

Option et Result : Pas de nuls, pas d'erreurs silencieuses

Rust n'a pas de null. À la place :

  • Option — une valeur qui est soit Some(x) soit None
  • Result — soit Ok(x) soit Err(e) (c'est la base de toute la gestion d'erreurs dans les exemples Polars)
fn divide(a: f64, b: f64) -> Option<f64> {
    if b == 0.0 { None } else { Some(a / b) }
}

match divide(10.0, 2.0) {
    Some(result) => println!("Got {result}"),
    None         => println!("Can't divide by zero"),
}

L'opérateur ? : Raccourci pour la gestion d'erreurs

Sur un Result, ? signifie "donne-moi la valeur, ou retourne l'erreur depuis cette fonction."

use std::num::ParseIntError;

fn parse_and_double(text: &str) -> Result<i32, ParseIntError> {
    let n = text.parse::<i32>()?;  // si le parsing échoue, retourne Err
    Ok(n * 2)                      // sinon continue
}

C'est pourquoi read_orders(...)? se lit proprement : le ? propage discrètement toute défaillance au lieu de forcer un gros bloc match.

Collections courantes

  • Vec — liste extensible (comme une liste Python)
  • HashMap — table de correspondance clé/valeur (comme un dictionnaire Python)
let mut v: Vec<i32> = Vec::new();
v.push(1);
v.push(2);
for item in &v { println!("{item}"); }

use std::collections::HashMap;
let mut scores = HashMap::new();
scores.insert("alice", 10);
scores.insert("bob", 7);

Cargo : Outil de build et gestionnaire de paquets de Rust

L'essentiel :

cargo new my_project   # créer un nouveau projet
cargo build            # compiler
cargo run              # compiler + exécuter
cargo test             # exécuter les tests
cargo add polars       # ajouter une dépendance au Cargo.toml

Les dépendances (appelées "crates") sont déclarées dans Cargo.toml et récupérées depuis crates.io.

À surveiller :

  • Propriété / emprunt — la danse des & et mut. Attendez-vous à lutter au début ; cela devient naturel avec la pratique.
  • Deux types de chaînes (String vs &str) — convertissez avec .to_string() ou String::from(...).
  • Immuable par défaut — oublier mut est l'erreur initiale la plus courante.
  • Le compilateur est votre ami — les messages d'erreur de Rust sont exceptionnellement bons. Lisez-les ; ils indiquent souvent la correction exacte.
  • Macros vs fonctionsprintln!, vec!, df! se terminent par ! et se comportent un peu différemment des fonctions normales.

Ce qu'est Polars

  • Une bibliothèque DataFrame pour travailler avec des données tabulaires (lignes et colonnes) — pensez aux feuilles de calcul ou aux tables de base de données, dans le code
  • Écrite en Rust, construite sur Apache Arrow (un format de mémoire en colonnes)
  • En colonnes : stocke les données par colonne, pas par ligne — c'est pourquoi les opérations sur les colonnes et l'analytique sont rapides
  • Multithreadé par défaut : utilise tous vos cœurs CPU sans que vous ayez à le demander
  • Disponible directement depuis Rust, et depuis Python via des bindings

Les deux types principaux

  • Series — une seule colonne de données, toutes du même type
  • DataFrame — une collection de Series ; la table elle-même
use polars::prelude::*;

// Une Series est une colonne nommée.
let s = Series::new("amount".into(), &[42.5, 17.0, 9.99]);

// Un DataFrame est construit à partir de colonnes. La macro df! est le moyen facile.
let df = df!(
    "order_id" => &[1, 2, 3],
    "amount"   => &[42.5, 17.0, 9.99],
)?;
println!("{df}");

Notez que df! se termine par ! — c'est une macro, comme println! et vec!.

Tout retourne un Result

Presque chaque opération Polars peut échouer (mauvais types, colonnes manquantes, mauvais fichiers), donc elle retourne PolarsResult. C'est pourquoi vous voyez ? partout dans l'atelier — cela propage les erreurs au lieu de les laisser passer silencieusement.

fn build() -> PolarsResult<DataFrame> {
    let df = df!("a" => &[1, 2, 3])?;   // ? déballe ou retourne l'erreur
    Ok(df)
}

Cela se lie directement au Result et au ? de Rust : les mauvaises données deviennent une erreur que vous devez gérer, pas un NaN silencieux.

Lecture et écriture de données

Les quatre formats de l'agenda :

// CSV en entrée
let df = CsvReadOptions::default()
    .with_has_header(true)
    .try_into_reader_with_file_path(Some("orders.csv".into()))?
    .finish()?;

// Parquet en sortie
let mut file = std::fs::File::create("orders.parquet")?;
ParquetWriter::new(&mut file).finish(&mut df)?;

// Parquet en entrée
let mut f = std::fs::File::open("orders.parquet")?;
let df = ParquetReader::new(&mut f).finish()?;

Idée clé : Parquet stocke le schéma et les types dans le fichier, donc la lecture ne nécessite aucune supposition. Le CSV est du texte et doit être déduit ou recevoir un schéma explicite.

Schémas : Le contrat

Un Schema déclare le nom et le type de chaque colonne à l'avance. Donnez-en un à un lecteur et les mauvaises données échoueront bruyamment au lieu de corrompre une colonne.

let mut schema = Schema::default();
schema.with_column("order_id".into(), DataType::Int64);
schema.with_column("amount".into(), DataType::Float64);

DataType=s courants : =Int64, Float64, String, Boolean, Date.

Sélection et filtrage

Vous décrivez les opérations avec des expressionscol(...) fait référence à une colonne, et vous enchaînez les transformations.

let result = df
    .clone()
    .lazy()
    .filter(col("status").eq(lit("shipped")))   // garder les lignes correspondantes
    .select([col("order_id"), col("amount")])    // choisir les colonnes
    .collect()?;                                  // exécuter
  • col("x") — faire référence à la colonne x
  • lit("shipped") — une valeur littérale à comparer
  • .eq, .gt, .lt — opérateurs de comparaison sur les expressions

Jointures : Combiner des tables

Faire correspondre des lignes de deux DataFrames sur une clé partagée.

let joined = orders.join(
    &customers,
    ["customer_id"],                 // clé dans la table de gauche
    ["customer_id"],                 // clé dans la table de droite
    JoinArgs::new(JoinType::Inner),  // Inner / Left / Anti / ...
    None,
)?;

Types de jointure à connaître :

  • Inner — seulement les lignes qui correspondent dans les deux
  • Left — toutes les lignes de gauche, nuls là où il n'y a pas de correspondance
  • Anti — lignes de gauche sans correspondance (excellent comme vérification de la qualité des données)

Eager vs Lazy : La grande distinction

  • Eager — chaque opération s'exécute immédiatement (DataFrame). Simple, bon pour les petites données et l'exploration.
  • Lazy — vous construisez un plan de requête, et rien ne s'exécute avant .collect(). Polars optimise alors tout le plan (pousse les filtres vers le bas, ne lit que les colonnes nécessaires).
// Lazy : scan_* et .lazy() retournent un LazyFrame -- un plan, pas encore des données.
let plan = LazyCsvReader::new(PlPath::new("orders.csv"))
    .with_has_header(true)
    .finish()?
    .filter(col("status").eq(lit("shipped")))
    .select([col("order_id"), col("amount")]);

println!("{}", plan.clone().explain(true)?);  // inspecter le plan
let df = plan.collect()?;                       // MAINTENANT il s'exécute

explain(true) affiche le plan optimisé — vous pouvez voir ce que le moteur a décidé de faire avant de dépenser du calcul.

Aide-mémoire des opérations courantes

df.height();                 // nombre de lignes
df.width();                  // nombre de colonnes
df.column("amount")?;        // obtenir une colonne (Series)
df.head(Some(5));            // 5 premières lignes
df.get_column_names();       // noms des colonnes
df.column("amount")?.dtype();// le type de données de la colonne

Pourquoi Polars (vs pandas / Spark)

  • vs pandas — beaucoup plus rapide, multithreadé, optimisation lazy, comportement mémoire bien meilleur ; les types sont plus stricts (moins de surprises silencieuses)
  • vs Spark — pas besoin de cluster pour des charges de travail sur une seule machine ; beaucoup de jobs "nous avons besoin de Spark" sont en réalité "pandas était trop lent sur une seule machine"
  • Polars vous donne performance et exactitude sans la surcharge des systèmes distribués

Comment cela se connecte aux bases de Rust

  • PolarsResult et ? = le Result + opérateur ? de Rust
  • &customers dans une jointure = emprunt (lecture sans prendre la propriété)
  • &mut df lors de l'écriture Parquet = un emprunt mutable
  • df!, col! macros de style = la syntaxe de macro !
  • Schémas et DataType = l'idée de Rust "tout a un type connu", appliquée aux colonnes de table

Démo

Un CSV délibérément imparfait

Utilisez un fichier avec une colonne de type mixte, un nul, et une mauvaise ligne :

order_id,customer_id,amount,status
1,100,42.50,shipped
2,101,,pending
3,102,17.00,shipped
4,bad_id,9.99,shipped

La ligne 4 a un customer_id non numérique. Dans un pipeline lâche, cela devient un NaN silencieux ou une colonne d'objets. Nous voulons que ce soit bruyant.

Lecture Eager avec schéma déduit (le chemin facile et dangereux)

use polars::prelude::*;

fn main() -> PolarsResult<()> {
    let df = CsvReadOptions::default()
        .with_has_header(true)
        .try_into_reader_with_file_path(Some("orders.csv".into()))?
        .finish()?;

    println!("{df}");
    Ok(())
}

Cela fonctionne — mais l'inférence a regardé un échantillon et a deviné les types. Sur un fichier différent, ou plus de lignes, la supposition peut changer. L'inférence est pratique et non déterministe ; cette combinaison est ce qui vous mord en production.

Schéma explicite (la leçon de fiabilité)

Arrêtez de deviner. Énoncez le contrat :

use polars::prelude::*;
use std::sync::Arc;

fn read_orders(path: &str) -> PolarsResult<DataFrame> {
    let mut schema = Schema::default();
    schema.with_column("order_id".into(), DataType::Int64);
    schema.with_column("customer_id".into(), DataType::Int64);
    schema.with_column("amount".into(), DataType::Float64);
    schema.with_column("status".into(), DataType::String);

    CsvReadOptions::default()
        .with_has_header(true)
        .with_schema(Some(Arc::new(schema)))
        .try_into_reader_with_file_path(Some(path.into()))?
        .finish()
}

Maintenant customer_id est déclaré Int64. La mauvaise ligne (bad_id) ne peut plus passer comme texte — Polars retourne une Err, pas une colonne silencieusement corrompue. L'échec se produit au moment de la lecture, avec une cause claire, au lieu de trois transformations plus tard.

C'est le point fort de Rust + Polars

  • Le schéma est du code — il est versionné, révisé et testé comme tout autre contrat
  • finish() retourne PolarsResult. Il n'y a aucun moyen d'ignorer un échec de parsing par accident — le ? vous force à le gérer ou à le propager
  • Comparez à un pipeline typé dynamiquement où un mauvais parsing devient NaN et circule en aval silencieusement. Ici, le système de types et le type d'erreur rendent le silence impossible.

La gestion d'erreurs comme préoccupation de premier ordre

Montrez les deux comportements pour que l'audience ressente la différence :

fn main() {
    match read_orders("orders.csv") {
        Ok(df) => println!("Loaded {} rows\n{df}", df.height()),
        Err(e) => eprintln!("CSV failed its contract: {e}"),
    }
}

Dans un pipeline, Err signifie que le job s'arrête ici, bruyamment, avec un message — pas à 3h du matin, après quarante millions de lignes.

Verrouillez avec un test

Le thème de la fiabilité rendu concret — un test qui affirme le contrat, pour qu'un fichier amont mal formé échoue en CI, pas en prod :

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn schema_is_enforced() {
        let df = read_orders("tests/data/orders_good.csv").unwrap();
        assert_eq!(df.height(), 3);
        assert_eq!(
            df.column("amount").unwrap().dtype(),
            &DataType::Float64
        );
    }

    #[test]
    fn bad_types_are_rejected() {
        // Le fichier avec `bad_id` ne doit PAS charger silencieusement.
        assert!(read_orders("tests/data/orders_bad.csv").is_err());
    }
}

bad_types_are_rejected est toute la philosophie dans un seul test : nous affirmons que les mauvaises données échouent. La plupart des pipelines n'écrivent jamais ce test car dans leur stack, les mauvaises données n'échouent pas — elles se propagent.

Gérer les nuls intentionnellement (pas par accident)

Le amount vide sur la ligne 2 est un vrai nul. Décidez de ce qu'il signifie au lieu de laisser une supposition décider :

use polars::prelude::*;

fn parse_options() -> CsvParseOptions {
    CsvParseOptions::default()
        .with_null_values(Some(NullValues::AllColumns(
            vec!["".into(), "NA".into(), "null".into()].into(),
        )))
}

Clarté opérationnelle : les nuls sont une décision documentée dans le code, pas un artefact de ce que le parser a eu envie de faire.

Points à retenir de la section

  • Le CSV est non typé et non sûr par défaut — traitez chaque lecture comme une frontière qui doit être validée
  • Les schémas explicites transforment "j'espère que ça parse" en "ça parse ou ça génère une erreur" — le déterminisme plutôt que la commodité
  • PolarsResult rend l'ignorance de l'échec impossible à la compilation
  • Un test (bad_types_are_rejected) démontre toute la thèse de la fiabilité
  • Rust + Polars compte ici non pas parce qu'il est plus rapide, mais parce qu'il rend la corruption silencieuse de données structurellement difficile