Rust para Data Science — Processamento de Dados com Polars e ndarray

Rust para Data Science — Processamento de Dados com Polars e ndarray

Rust entra em processamento de dados quando o custo de execução pesa mais que o de iteração — e não exclui o Python, já que dá para chamar um do outro. O texto cobre o ndarray contíguo, o Polars com sua API lazy que otimiza o plano antes de executar, e a leitura que não carrega o que não será usado.
Rust

• • 18 min de leitura

Python domina o data science há uma década — NumPy, Pandas, scikit-learn formam um ecossistema maduro e poderoso. Mas há um custo: performance. Análises sobre datasets grandes em Pandas podem demorar minutos onde uma implementação otimizada demoraria segundos.

Rust está mudando essa equação. Polars — escrito em Rust — é frequentemente 5 a 20 vezes mais rápido que Pandas em operações equivalentes. ndarray oferece arrays N-dimensionais com semântica similar ao NumPy mas com segurança de tipos em tempo de compilação. E o melhor: você pode chamar código Rust a partir do Python via PyO3, combinando a conveniência do Python com a performance de Rust.

Dependências

[package]
name = "data_science_rs"
version = "0.1.0"
edition = "2021"

[dependencies]
# DataFrame — similar ao Pandas mas muito mais rápido
polars = { version = "0.39", features = [
    "lazy",          # API lazy (mais eficiente)
    "csv",           # leitura/escrita CSV
    "json",          # leitura/escrita JSON
    "parquet",       # formato Parquet (colunar, comprimido)
    "temporal",      # tipos de data/hora
    "strings",       # operações em strings
    "dtype-categorical",
    "describe",      # estatísticas descritivas
    "rolling_window",# janelas deslizantes
    "groupby_list",
    "pivot",
]}

# Arrays N-dimensionais — similar ao NumPy
ndarray = { version = "0.15", features = ["rayon"] }
ndarray-stats = "0.5"
ndarray-rand = "0.14"

# Álgebra linear
nalgebra = "0.32"

# Geração de números aleatórios
rand = "0.8"
rand_distr = "0.4"

# Visualização (output de texto)
textplots = "0.8"

# Serialização
serde = { version = "1", features = ["derive"] }
serde_json = "1"

# Erros
anyhow = "1"

# Paralelismo
rayon = "1"

ndarray — Arrays N-dimensionais

use ndarray::{Array1, Array2, Axis, arr1, arr2, s};
use ndarray_rand::RandomExt;
use ndarray_rand::rand_distr::{Normal, Uniform};
use ndarray_stats::QuantileExt;

fn demonstrar_ndarray() {
    println!("══ ndarray: Arrays N-dimensionais ══
");

    // Array 1D
    let v: Array1<f64> = arr1(&[1.0, 2.0, 3.0, 4.0, 5.0]);
    println!("Vetor: {v}");
    println!("Soma: {}", v.sum());
    println!("Média: {}", v.mean().unwrap());

    // Operações vetorizadas — sem loop explícito
    let dobrado = &v * 2.0;
    let quadrado = v.mapv(|x| x * x);
    println!("Dobrado: {dobrado}");
    println!("Quadrado: {quadrado}");

    // Array 2D (matriz)
    let m: Array2<f64> = arr2(&[
        [1.0, 2.0, 3.0],
        [4.0, 5.0, 6.0],
        [7.0, 8.0, 9.0],
    ]);

    println!("
Matriz 3x3:
{m}");
    println!("Shape: {:?}", m.shape());
    println!("Transposta:
{}", m.t());

    // Soma por eixos
    println!("Soma por coluna: {}", m.sum_axis(Axis(0)));
    println!("Soma por linha:  {}", m.sum_axis(Axis(1)));

    // Slicing — sem cópia
    let submatriz = m.slice(s![0..2, 1..3]);
    println!("
Submatriz [0..2, 1..3]:
{submatriz}");

    // Produto de matrizes
    let a: Array2<f64> = arr2(&[[1.0, 2.0], [3.0, 4.0]]);
    let b: Array2<f64> = arr2(&[[5.0, 6.0], [7.0, 8.0]]);
    let produto = a.dot(&b);
    println!("
A × B =
{produto}");

    // Arrays aleatórios
    let normal = Array1::random(1000, Normal::new(0.0, 1.0).unwrap());
    println!("
Amostra N(0,1): n={}", normal.len());
    println!("Média amostral: {:.4}", normal.mean().unwrap());
    println!("Desvio padrão:  {:.4}", normal.std(0.0));
    println!("Min: {:.4}", *normal.min().unwrap());
    println!("Max: {:.4}", *normal.max().unwrap());
}

// Regressão linear com ndarray
fn regressao_linear_ols(
    x: &Array2<f64>,  // matriz de features (n_amostras × n_features)
    y: &Array1<f64>,  // vetor de targets
) -> Array1<f64> {
    // β = (XᵀX)⁻¹Xᵀy — mínimos quadrados ordinários

    let xt = x.t();
    let xtx = xt.dot(x);

    // Inversão via decomposição LU (simplificada)
    // Em produção use nalgebra ou linfa
    let xty = xt.dot(y);

    // Para regressão simples: β = Σ(xi*yi) / Σ(xi²)
    // Versão completa requereria inversão de matriz
    xty
}

fn main() {
    demonstrar_ndarray();
}

Estatísticas descritivas com ndarray

use ndarray::{Array1, Array2};
use ndarray_stats::QuantileExt;
use rand::SeedableRng;
use rand_distr::{Distribution, Normal, Uniform};

struct EstatisticasDescritivas {
    n: usize,
    media: f64,
    mediana: f64,
    desvio_padrao: f64,
    variancia: f64,
    minimo: f64,
    maximo: f64,
    q1: f64,
    q3: f64,
    assimetria: f64,
    curtose: f64,
}

impl EstatisticasDescritivas {
    fn calcular(dados: &Array1<f64>) -> Self {
        let n = dados.len();
        let media = dados.mean().unwrap();
        let variancia = dados.var(0.0); // divisão por n
        let desvio_padrao = variancia.sqrt();

        // Centraliza os dados
        let centralizado = dados.mapv(|x| x - media);

        // Assimetria (skewness)
        let assimetria = centralizado.mapv(|x| x.powi(3)).mean().unwrap()
            / desvio_padrao.powi(3);

        // Curtose (kurtosis) — excesso em relação à normal
        let curtose = centralizado.mapv(|x| x.powi(4)).mean().unwrap()
            / desvio_padrao.powi(4) - 3.0;

        // Ordenar para percentis
        let mut ordenado: Vec<f64> = dados.to_vec();
        ordenado.sort_by(|a, b| a.partial_cmp(b).unwrap());
        let arr_ord = Array1::from(ordenado.clone());

        let percentil = |p: f64| -> f64 {
            let idx = p / 100.0 * (n - 1) as f64;
            let i = idx.floor() as usize;
            let f = idx - i as f64;
            if i + 1 < n {
                arr_ord[i] * (1.0 - f) + arr_ord[i + 1] * f
            } else {
                arr_ord[n - 1]
            }
        };

        EstatisticasDescritivas {
            n,
            media,
            mediana: percentil(50.0),
            desvio_padrao,
            variancia,
            minimo: ordenado[0],
            maximo: *ordenado.last().unwrap(),
            q1: percentil(25.0),
            q3: percentil(75.0),
            assimetria,
            curtose,
        }
    }

    fn exibir(&self) {
        println!("┌─────────────────────────────────────┐");
        println!("│       Estatísticas Descritivas       │");
        println!("├─────────────────────────────────────┤");
        println!("│  n              : {:>14}    │", self.n);
        println!("│  Média          : {:>14.4}    │", self.media);
        println!("│  Mediana        : {:>14.4}    │", self.mediana);
        println!("│  Desvio Padrão  : {:>14.4}    │", self.desvio_padrao);
        println!("│  Variância      : {:>14.4}    │", self.variancia);
        println!("├─────────────────────────────────────┤");
        println!("│  Mínimo         : {:>14.4}    │", self.minimo);
        println!("│  Q1             : {:>14.4}    │", self.q1);
        println!("│  Mediana        : {:>14.4}    │", self.mediana);
        println!("│  Q3             : {:>14.4}    │", self.q3);
        println!("│  Máximo         : {:>14.4}    │", self.maximo);
        println!("├─────────────────────────────────────┤");
        println!("│  IQR            : {:>14.4}    │", self.q3 - self.q1);
        println!("│  Assimetria     : {:>14.4}    │", self.assimetria);
        println!("│  Curtose        : {:>14.4}    │", self.curtose);
        println!("└─────────────────────────────────────┘");
    }
}

fn histograma_texto(dados: &Array1<f64>, n_bins: usize) {
    let min = dados.iter().cloned().fold(f64::INFINITY, f64::min);
    let max = dados.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
    let largura = (max - min) / n_bins as f64;

    let mut contagens = vec![0usize; n_bins];
    for &x in dados.iter() {
        let idx = ((x - min) / largura).floor() as usize;
        let idx = idx.min(n_bins - 1);
        contagens[idx] += 1;
    }

    let max_contagem = *contagens.iter().max().unwrap();
    let largura_barra = 40;

    println!("
Histograma:");
    for (i, &contagem) in contagens.iter().enumerate() {
        let inicio = min + i as f64 * largura;
        let fim = inicio + largura;
        let tamanho = contagem * largura_barra / max_contagem;
        let barra = "█".repeat(tamanho);
        println!("{:6.2}–{:6.2} │{:<40}│ {}", inicio, fim, barra, contagem);
    }
}

fn main() {
    let mut rng = rand::rngs::SmallRng::seed_from_u64(42);
    let normal = Normal::new(170.0, 10.0).unwrap(); // altura em cm

    let alturas: Array1<f64> = Array1::from(
        (0..1000).map(|_| normal.sample(&mut rng)).collect::<Vec<_>>()
    );

    println!("Dados: alturas de 1000 pessoas (simulado)
");

    let stats = EstatisticasDescritivas::calcular(&alturas);
    stats.exibir();

    histograma_texto(&alturas, 12);
}

Polars — DataFrames de alta performance

use polars::prelude::*;
use std::fs;

fn demonstrar_polars() -> PolarsResult<()> {
    println!("══ Polars: DataFrames ══
");

    // Criando DataFrame manualmente
    let df = DataFrame::new(vec![
        Series::new("nome",   &["Alice", "Bob", "Carlos", "Diana", "Eduardo"]),
        Series::new("idade",  &[28i32, 34, 45, 29, 52]),
        Series::new("salario",&[5500.0f64, 8200.0, 12000.0, 6800.0, 15000.0]),
        Series::new("depto",  &["TI", "TI", "RH", "TI", "RH"]),
        Series::new("ativo",  &[true, true, false, true, true]),
    ])?;

    println!("DataFrame original:");
    println!("{df}
");

    // Informações básicas
    println!("Shape: {:?}", df.shape());
    println!("Colunas: {:?}", df.get_column_names());
    println!("Schema:
{:?}
", df.schema());

    // Seleção de colunas
    let cols_selecionadas = df.select(["nome", "salario"])?;
    println!("Colunas selecionadas:
{cols_selecionadas}
");

    // Filtro
    let ti_ativos = df.filter(
        &df["depto"].equal("TI")?.bitand(&df["ativo"].bool()?.clone())
    )?;
    println!("TI ativos:
{ti_ativos}
");

    // Ordenação
    let por_salario = df.sort(["salario"], SortMultipleOptions::default()
        .with_order_descending(true))?;
    println!("Por salário (desc):
{por_salario}
");

    // Estatísticas descritivas
    println!("Describe:
{}
", df.describe(None)?);

    Ok(())
}

API Lazy do Polars — plano de execução otimizado

use polars::prelude::*;

fn analise_lazy() -> PolarsResult<()> {
    println!("══ Polars Lazy API ══
");

    // Lazy frames — operações não são executadas imediatamente
    // O Polars otimiza o plano antes de executar
    let lf = DataFrame::new(vec![
        Series::new("produto",    &["A","B","C","D","E","A","B","C"]),
        Series::new("categoria",  &["X","X","Y","Y","X","Y","X","Y"]),
        Series::new("vendas",     &[100i32,200,150,300,120,180,220,90]),
        Series::new("mes",        &[1i32,1,1,1,2,2,2,2]),
    ])?.lazy();

    // Encadeamento de operações — só executa no .collect()
    let resultado = lf
        .filter(col("vendas").gt(lit(100)))
        .group_by([col("categoria"), col("mes")])
        .agg([
            col("vendas").sum().alias("total_vendas"),
            col("vendas").mean().alias("media_vendas"),
            col("produto").count().alias("num_produtos"),
        ])
        .sort(["categoria", "mes"], SortMultipleOptions::default())
        .collect()?;

    println!("Análise por categoria e mês:
{resultado}
");

    // Expressões customizadas
    let df = DataFrame::new(vec![
        Series::new("valor", &[10.0f64, 20.0, 30.0, 40.0, 50.0]),
    ])?;

    let com_derivadas = df.lazy()
        .with_columns([
            col("valor").alias("original"),
            (col("valor") * lit(2.0)).alias("dobrado"),
            (col("valor") - col("valor").mean()).alias("centralizado"),
            col("valor").rank(RankOptions::default(), None).alias("rank"),
        ])
        .collect()?;

    println!("DataFrame com colunas derivadas:
{com_derivadas}
");

    Ok(())
}

Leitura e escrita de arquivos

use polars::prelude::*;
use std::io::Cursor;

fn io_polars() -> PolarsResult<()> {
    // ── CSV ─────────────────────────────────────────

    // Dados CSV em memória para demonstração
    let csv_dados = r#"nome,idade,salario,cidade
Alice,28,5500.0,São Paulo
Bob,34,8200.0,Rio de Janeiro
Carlos,45,12000.0,Belo Horizonte
Diana,29,6800.0,São Paulo
Eduardo,52,15000.0,Curitiba
Fernanda,31,7200.0,São Paulo
"#;

    // Leitura de CSV
    let df = CsvReader::new(Cursor::new(csv_dados))
        .has_header(true)
        .with_try_parse_dates(true)
        .finish()?;

    println!("CSV lido:
{df}
");

    // Escrita em CSV
    let mut buffer = Vec::new();
    CsvWriter::new(&mut buffer)
        .finish(&mut df.clone())?;
    println!("CSV escrito: {} bytes
", buffer.len());

    // ── JSON ─────────────────────────────────────────

    let mut json_buf = Vec::new();
    JsonWriter::new(&mut json_buf)
        .with_json_format(JsonFormat::Json)
        .finish(&mut df.clone())?;

    println!("JSON (primeiros 200 chars):
{}
",
        &String::from_utf8_lossy(&json_buf)[..200.min(json_buf.len())]);

    // ── Parquet ──────────────────────────────────────

    // Parquet é o formato preferido para análise — colunar e comprimido
    let parquet_path = "/tmp/dados_teste.parquet";

    let mut file = std::fs::File::create(parquet_path)?;
    ParquetWriter::new(&mut file)
        .with_compression(ParquetCompression::Snappy)
        .finish(&mut df.clone())?;

    let file_leitura = std::fs::File::open(parquet_path)?;
    let df_parquet = ParquetReader::new(file_leitura).finish()?;

    println!("Parquet lido de volta ({} linhas):
{df_parquet}
",
        df_parquet.height());

    std::fs::remove_file(parquet_path)?;

    Ok(())
}

Análise exploratória completa

use polars::prelude::*;
use std::io::Cursor;

fn analise_exploratoria() -> PolarsResult<()> {
    println!("══ Análise Exploratória de Dados ══
");

    // Dataset simulado de vendas
    let csv = r#"data,produto,categoria,quantidade,preco_unit,vendedor,regiao
2024-01-05,Notebook,Eletrônicos,2,3500.0,Ana,Sul
2024-01-08,Mouse,Periféricos,15,85.0,Bob,Norte
2024-01-12,Teclado,Periféricos,8,200.0,Ana,Sul
2024-01-15,Monitor,Eletrônicos,3,1200.0,Carlos,Sudeste
2024-02-02,Notebook,Eletrônicos,1,3500.0,Bob,Norte
2024-02-10,Headset,Periféricos,12,350.0,Ana,Sul
2024-02-18,Monitor,Eletrônicos,5,1200.0,Ana,Sul
2024-02-22,Mouse,Periféricos,20,85.0,Carlos,Sudeste
2024-03-05,Notebook,Eletrônicos,4,3800.0,Carlos,Sudeste
2024-03-14,Teclado,Periféricos,6,200.0,Bob,Norte
2024-03-20,Headset,Periféricos,8,350.0,Carlos,Sudeste
2024-03-28,Monitor,Eletrônicos,2,1200.0,Bob,Norte
"#;

    let df = CsvReader::new(Cursor::new(csv))
        .has_header(true)
        .finish()?;

    println!("Dataset de vendas:
{df}
");

    // Coluna derivada: receita total
    let df = df.lazy()
        .with_column(
            (col("quantidade") * col("preco_unit"))
                .alias("receita")
        )
        .collect()?;

    // 1. Resumo geral
    println!("── Resumo Geral ──");
    let total_receita = df["receita"].sum::<f64>().unwrap();
    let total_vendas  = df["quantidade"].sum::<i64>().unwrap();
    println!("Receita total: R${total_receita:.2}");
    println!("Unidades vendidas: {total_vendas}");
    println!("Ticket médio: R${:.2}
", total_receita / df.height() as f64);

    // 2. Por categoria
    println!("── Receita por Categoria ──");
    let por_categoria = df.clone().lazy()
        .group_by([col("categoria")])
        .agg([
            col("receita").sum().alias("receita_total"),
            col("quantidade").sum().alias("unidades"),
            col("receita").mean().alias("ticket_medio"),
        ])
        .sort(["receita_total"], SortMultipleOptions::default()
            .with_order_descending(true))
        .collect()?;
    println!("{por_categoria}
");

    // 3. Por vendedor
    println!("── Performance por Vendedor ──");
    let por_vendedor = df.clone().lazy()
        .group_by([col("vendedor")])
        .agg([
            col("receita").sum().alias("receita_total"),
            col("receita").count().alias("num_vendas"),
            col("receita").max().alias("maior_venda"),
        ])
        .sort(["receita_total"], SortMultipleOptions::default()
            .with_order_descending(true))
        .collect()?;
    println!("{por_vendedor}
");

    // 4. Por região
    println!("── Receita por Região ──");
    let por_regiao = df.clone().lazy()
        .group_by([col("regiao")])
        .agg([col("receita").sum().alias("total")])
        .sort(["total"], SortMultipleOptions::default()
            .with_order_descending(true))
        .collect()?;
    println!("{por_regiao}
");

    // 5. Top produtos
    println!("── Top Produtos ──");
    let top_produtos = df.clone().lazy()
        .group_by([col("produto")])
        .agg([
            col("receita").sum().alias("receita_total"),
            col("quantidade").sum().alias("unidades"),
        ])
        .sort(["receita_total"], SortMultipleOptions::default()
            .with_order_descending(true))
        .collect()?;
    println!("{top_produtos}
");

    Ok(())
}

fn main() -> anyhow::Result<()> {
    demonstrar_ndarray();
    println!("
{}
", "═".repeat(50));

    analise_lazy()?;
    println!("{}
", "═".repeat(50));

    io_polars()?;
    println!("{}
", "═".repeat(50));

    analise_exploratoria()?;

    Ok(())
}

Regressão Linear com nalgebra

use nalgebra::{DMatrix, DVector};

struct RegressaoLinear {
    coeficientes: DVector<f64>,
    intercepto: f64,
}

impl RegressaoLinear {
    /// Ajusta o modelo por mínimos quadrados ordinários.
    /// β = (XᵀX)⁻¹Xᵀy
    fn ajustar(x: &DMatrix<f64>, y: &DVector<f64>) -> Option<Self> {
        let n = x.nrows();
        let p = x.ncols();

        // Adiciona coluna de uns para o intercepto
        let mut x_aug = DMatrix::zeros(n, p + 1);
        for i in 0..n {
            x_aug[(i, 0)] = 1.0; // intercepto
            for j in 0..p {
                x_aug[(i, j + 1)] = x[(i, j)];
            }
        }

        // β = (XᵀX)⁻¹Xᵀy
        let xt = x_aug.transpose();
        let xtx = &xt * &x_aug;
        let xty = &xt * y;

        let beta = xtx.try_inverse()?.mul(&xty);

        Some(RegressaoLinear {
            intercepto: beta[0],
            coeficientes: beta.rows(1, p).into_owned(),
        })
    }

    fn prever(&self, x: &DMatrix<f64>) -> DVector<f64> {
        let previsoes = x * &self.coeficientes;
        previsoes.map(|v| v + self.intercepto)
    }

    fn r_quadrado(&self, x: &DMatrix<f64>, y: &DVector<f64>) -> f64 {
        let previsoes = self.prever(x);
        let media_y = y.mean();

        let ss_res: f64 = y.iter().zip(previsoes.iter())
            .map(|(yi, fi)| (yi - fi).powi(2))
            .sum();

        let ss_tot: f64 = y.iter()
            .map(|yi| (yi - media_y).powi(2))
            .sum();

        1.0 - ss_res / ss_tot
    }

    fn rmse(&self, x: &DMatrix<f64>, y: &DVector<f64>) -> f64 {
        let previsoes = self.prever(x);
        let mse: f64 = y.iter().zip(previsoes.iter())
            .map(|(yi, fi)| (yi - fi).powi(2))
            .sum::<f64>() / y.len() as f64;
        mse.sqrt()
    }
}

fn demonstrar_regressao() {
    println!("══ Regressão Linear com nalgebra ══
");

    // Dataset: área (m²) e quartos → preço do imóvel
    let x_dados = vec![
        50.0, 2.0,
        65.0, 2.0,
        80.0, 3.0,
        90.0, 3.0,
        110.0, 4.0,
        120.0, 4.0,
        140.0, 5.0,
        160.0, 5.0,
    ];

    let y_dados = vec![
        280_000.0,
        350_000.0,
        420_000.0,
        480_000.0,
        580_000.0,
        630_000.0,
        720_000.0,
        850_000.0,
    ];

    let n = y_dados.len();
    let x = DMatrix::from_row_slice(n, 2, &x_dados);
    let y = DVector::from_vec(y_dados.clone());

    match RegressaoLinear::ajustar(&x, &y) {
        Some(modelo) => {
            println!("Coeficientes:");
            println!("  Intercepto : R${:.2}", modelo.intercepto);
            println!("  Área (m²)  : R${:.2} por m²", modelo.coeficientes[0]);
            println!("  Quartos    : R${:.2} por quarto", modelo.coeficientes[1]);

            println!("
Métricas:");
            println!("  R²   : {:.4}", modelo.r_quadrado(&x, &y));
            println!("  RMSE : R${:.2}", modelo.rmse(&x, &y));

            println!("
Previsões vs Real:");
            let previsoes = modelo.prever(&x);
            println!("{:>10} {:>14} {:>10}", "Real", "Previsto", "Erro%");
            println!("{}", "─".repeat(36));
            for (real, prev) in y_dados.iter().zip(previsoes.iter()) {
                let erro = (prev - real) / real * 100.0;
                println!("R${:>10.0} R${:>12.0} {:>9.1}%", real, prev, erro);
            }

            // Previsão para novo imóvel
            let novo = DMatrix::from_row_slice(1, 2, &[100.0, 3.0]);
            let preco = modelo.prever(&novo)[0];
            println!("
Previsão: 100m² / 3 quartos → R${preco:.2}");
        }
        None => eprintln!("Falha ao ajustar o modelo"),
    }
}

fn main() {
    demonstrar_regressao();
}

Integração com Python via PyO3

Uma das aplicações mais poderosas: expor funções de análise Rust para uso em notebooks Python:

# Cargo.toml para extensão Python
[lib]
name = "rust_data"
crate-type = ["cdylib"]

[dependencies]
pyo3 = { version = "0.21", features = ["extension-module"] }
ndarray = "0.15"
polars = { version = "0.39", features = ["lazy"] }
numpy = "0.21"  # integração ndarray ↔ numpy
use pyo3::prelude::*;
use numpy::{PyArray1, PyReadonlyArray1};
use ndarray::Array1;

/// Calcula estatísticas descritivas de um array NumPy.
///
/// Retorna dicionário com: média, dp, min, max, q1, mediana, q3.
#[pyfunction]
fn estatisticas<'py>(
    py: Python<'py>,
    dados: PyReadonlyArray1<f64>,
) -> PyResult<PyObject> {
    let arr: Array1<f64> = dados.as_array().to_owned();

    if arr.is_empty() {
        return Err(pyo3::exceptions::PyValueError::new_err("Array vazio"));
    }

    let n = arr.len() as f64;
    let media = arr.sum() / n;
    let variancia = arr.mapv(|x| (x - media).powi(2)).sum() / n;
    let dp = variancia.sqrt();

    let mut ordenado: Vec<f64> = arr.to_vec();
    ordenado.sort_by(|a, b| a.partial_cmp(b).unwrap());

    let percentil = |p: f64| -> f64 {
        let idx = p / 100.0 * (ordenado.len() - 1) as f64;
        let i = idx.floor() as usize;
        let f = idx - i as f64;
        if i + 1 < ordenado.len() {
            ordenado[i] * (1.0 - f) + ordenado[i + 1] * f
        } else {
            *ordenado.last().unwrap()
        }
    };

    let resultado = pyo3::types::PyDict::new(py);
    resultado.set_item("n", arr.len())?;
    resultado.set_item("media", media)?;
    resultado.set_item("dp", dp)?;
    resultado.set_item("min", ordenado[0])?;
    resultado.set_item("q1", percentil(25.0))?;
    resultado.set_item("mediana", percentil(50.0))?;
    resultado.set_item("q3", percentil(75.0))?;
    resultado.set_item("max", *ordenado.last().unwrap())?;

    Ok(resultado.into())
}

/// Normalização Z-score: (x - μ) / σ
#[pyfunction]
fn normalizar_zscore<'py>(
    py: Python<'py>,
    dados: PyReadonlyArray1<f64>,
) -> PyResult<&'py PyArray1<f64>> {
    let arr: Array1<f64> = dados.as_array().to_owned();
    let media = arr.sum() / arr.len() as f64;
    let dp = (arr.mapv(|x| (x - media).powi(2)).sum() / arr.len() as f64).sqrt();

    let normalizado = arr.mapv(|x| (x - media) / dp);
    Ok(PyArray1::from_array(py, &normalizado))
}

/// Detecta outliers pelo método IQR.
/// Retorna índices dos outliers.
#[pyfunction]
fn detectar_outliers<'py>(
    py: Python<'py>,
    dados: PyReadonlyArray1<f64>,
    fator_iqr: f64,
) -> PyResult<&'py PyArray1<i64>> {
    let arr: Array1<f64> = dados.as_array().to_owned();
    let mut ordenado: Vec<f64> = arr.to_vec();
    ordenado.sort_by(|a, b| a.partial_cmp(b).unwrap());

    let n = ordenado.len();
    let q1 = ordenado[n / 4];
    let q3 = ordenado[3 * n / 4];
    let iqr = q3 - q1;

    let limite_inf = q1 - fator_iqr * iqr;
    let limite_sup = q3 + fator_iqr * iqr;

    let indices: Vec<i64> = arr.iter()
        .enumerate()
        .filter(|(_, &x)| x < limite_inf || x > limite_sup)
        .map(|(i, _)| i as i64)
        .collect();

    Ok(PyArray1::from_vec(py, indices))
}

#[pymodule]
fn rust_data(_py: Python, m: &PyModule) -> PyResult<()> {
    m.add_function(wrap_pyfunction!(estatisticas, m)?)?;
    m.add_function(wrap_pyfunction!(normalizar_zscore, m)?)?;
    m.add_function(wrap_pyfunction!(detectar_outliers, m)?)?;
    Ok(())
}

Uso em Python/Jupyter:

import rust_data
import numpy as np

dados = np.random.normal(loc=170, scale=10, size=1000)
# Insere outliers
dados[0] = 250.0
dados[1] = 50.0

stats = rust_data.estatisticas(dados)
print(f"Média: {stats['media']:.2f}")
print(f"DP:    {stats['dp']:.2f}")

normalizado = rust_data.normalizar_zscore(dados)
print(f"Z-score média: {normalizado.mean():.6f}")

outliers = rust_data.detectar_outliers(dados, fator_iqr=1.5)
print(f"Outliers encontrados: {len(outliers)}")
print(f"Índices: {outliers}")

Exercícios

Exercício 1

Explique a diferença entre a API eager e a lazy do Polars, e por que a lazy costuma ser mais rápida.

Ver resposta

✓ Resposta: Na eager, cada operação é executada na hora e produz um DataFrame novo. Na lazy, as operações apenas descrevem um plano, e nada roda até o collect(). Com o plano inteiro em mãos, o Polars otimiza antes de executar: empurra os filtros para o começo, lê do arquivo só as colunas usadas e funde etapas que poderiam ser uma. O ganho aparece justamente onde a eager desperdiça — carregar tudo e descartar depois.

Exercício 2

Por que ndarray não é simplesmente um Vec<Vec<f64>>?

Ver resposta

✓ Resposta: Porque o vetor de vetores aloca cada linha separadamente, espalhando os dados pela memória e desperdiçando cache a cada salto. O ndarray guarda tudo num bloco contíguo e mantém as dimensões e os passos à parte, o que permite percorrer com eficiência, fatiar sem copiar e transpor apenas trocando os passos. É a mesma decisão do NumPy, e é o que torna as operações de álgebra linear viáveis.

Exercício 3

Escreva o código que lê um CSV, filtra as linhas com valor acima de 100 e agrupa por categoria somando o valor.

Ver resposta

✓ Resposta:

use polars::prelude::*;

let resultado = LazyCsvReader::new("vendas.csv")
    .finish()?
    .filter(col("valor").gt(lit(100)))
    .group_by([col("categoria")])
    .agg([col("valor").sum().alias("total")])
    .collect()?;

Com a API lazy, o filtro é aplicado durante a leitura e só as duas colunas usadas saem do arquivo — o resto nem chega à memória. Em um CSV grande, essa diferença é a que decide se a operação cabe ou não na máquina.

Exercício 4

Por que operações de DataFrame em Rust costumam devolver Result?

Ver resposta

✓ Resposta: Porque o esquema só é conhecido em tempo de execução: a coluna citada pode não existir, o tipo pode não suportar a operação, o arquivo pode estar malformado. Nada disso o compilador consegue verificar, já que os nomes de coluna são strings. O Result obriga a tratar — e é por isso que o erro mais comum de quem chega do pandas é encadear tudo com unwrap(), transformando um nome de coluna digitado errado em queda do programa em vez de mensagem.

Exercício 5

Quando faz sentido usar Rust em vez de Python para processar dados?

Ver resposta

✓ Resposta: Quando o processamento é o gargalo e roda com frequência — ETL pesado, serviço que responde consulta em tempo real, trabalho que precisa caber em memória limitada. Quando o trabalho é exploratório, o ecossistema Python ganha de longe em bibliotecas, notebooks e visualização. Vale notar que não é escolha exclusiva: com PyO3 é possível escrever em Rust a parte crítica e chamá-la do Python — que é, aliás, como o próprio Polars é distribuído para usuários de Python.

Fontes e leituras recomendadas

Documentação oficial

  • Polars — User Guide — O guia oficial, com a distinção entre as APIs eager e lazy, expressões e o otimizador de plano.
  • polars — docs.rs — Referência da API em Rust, útil para descobrir quais features da crate ativar.
  • ndarray — docs.rs — Referência dos arrays N-dimensionais: fatiamento, iteração, operações elemento a elemento.

Para quem vem do Python

  • ndarray for NumPy users — Tabela de equivalências entre NumPy e ndarray, operação por operação. O caminho mais curto para quem já conhece o NumPy.
  • PyO3 — User Guide — Como expor código Rust para Python, que é a forma de usar as duas linguagens no mesmo projeto em vez de escolher uma.
Comentários

Mais em Rust

Construindo uma API REST com Axum
Construindo uma API REST com Axum

Uma API REST em Rust começa por entender extratores: tipos que sabem se…

Rust e Blockchain — Smart Contracts, Criptografia Aplicada e Web3
Rust e Blockchain — Smart Contracts, Criptografia Aplicada e Web3

Blockchain é menos sobre moeda e mais sobre tornar a alteração do passado cara…

Macros Procedurais — Criando Derive Macros e Atributos Customizados
Macros Procedurais — Criando Derive Macros e Atributos Customizados

Macro procedural roda durante a compilação de quem a usa, e por isso mora em…