Polars

Моделі та методи обробки великих даних

Ігор Мірошниченко

КНУ імені Тараса Шевченка, ФІТ

Що таке Polars?

Проблема з Pandas

Pandas — де-факто стандарт, але:

  • Однопотокове виконання
  • Високе споживання пам’яті
  • Складний API (index, SettingWithCopy)
  • Повільна обробка рядків
  • Немає lazy evaluation

Типова ситуація:

# 😩 SettingWithCopyWarning
df[df["a"] > 5]["b"] = 10

# 😩 Повільний apply
df["col"].apply(lambda x: x.upper())

# 😩 Надмірне споживання RAM
df = pd.read_csv("big_file.csv")  # x3 RAM

Polars: сучасна альтернатива

Polars — надшвидка бібліотека DataFrame, написана на Rust з Python API.

  • Багатопотокове виконання з коробки
  • Lazy та Eager API
  • Виразний (Expression) API замість Index
  • Підтримка Apache Arrow як формату пам’яті
  • Інтуїтивніший API ніж Pandas

flowchart TD
    A["Python API"] --> B["Polars Engine<br>(Rust)"]
    B --> C["Apache Arrow<br>(Columnar Memory)"]
    C --> D["CSV / Parquet / JSON"]
    C --> E["Database"]
    C --> F["Cloud Storage"]
    style B fill:#f9b928,stroke:#333,stroke-width:2px

Ключові характеристики

Характеристика Polars Pandas
Мова ядра Rust C / Python
Потоки Багатопотоковий Однопотоковий
Пам’ять Apache Arrow NumPy
Lazy API Так Ні
Index Немає Є (складний)
Null values Нативна підтримка NaN / None / NaT
String type Нативний (Utf8) object / StringDtype
Copy-on-write За замовчуванням Опціонально (Pandas 2.0+)

Архітектура Polars

flowchart LR
    subgraph "Python"
        A[Polars API]
    end
    subgraph "Rust Engine"
        B["Query Planner"] --> C["Optimizer"]
        C --> D["Execution Engine<br>(multi-threaded)"]
    end
    subgraph "Memory"
        E["Apache Arrow<br>Columnar Format"]
    end
    subgraph "I/O"
        F["CSV / Parquet / JSON<br>IPC / Database / S3"]
    end
    A --> B
    D --> E
    E --> F
    style C fill:#f9b928,stroke:#333

Переваги Apache Arrow:

  • Zero-copy обмін даними між бібліотеками
  • Стовпчастий формат — оптимальний для аналітики
  • SIMD-оптимізація для векторних операцій

Polars vs Pandas: Benchmarks

  • Benchmark від DuckDB Labs
  • Polars стабільно у топ-3 серед DataFrame-бібліотек
  • На типових операціях: 5-50x швидше за Pandas

graph TD
    A["🥇 DuckDB / Polars"] --> B["🥈 data.table (R)"]
    B --> C["🥉 Pandas 2.0"]
    C --> D["Dask / Spark<br>(overhead)"]
    style A fill:#d9f6ec,stroke:#28a87d

Встановлення

uv add polars pyarrow

Або:

pip install polars pyarrow
import polars as pl
print(f"Polars version: {pl.__version__}")
Polars version: 1.39.3

Eager vs Lazy API

Два режими роботи

flowchart LR
    subgraph "Eager Mode"
        direction LR
        A1["read_csv()"] --> B1["filter()"] --> C1["group_by()"] --> D1["Результат"]
    end
    style D1 fill:#fde8e8,stroke:#c10000

flowchart LR
    subgraph "Lazy Mode"
        direction LR
        A2["scan_csv()"] --> B2["filter()"] --> C2["group_by()"] --> D2["collect()"]
        D2 --> E2["Оптимізований результат"]
    end
    style E2 fill:#d9f6ec,stroke:#28a87d

Eager — виконання одразу:

df = pl.read_csv("data.csv")
result = df.filter(...)

Lazy — відкладене виконання:

lf = pl.scan_csv("data.csv")
result = lf.filter(...).collect()

Eager Mode — приклад

# Eager: все виконується відразу
df = pl.DataFrame({
    "name": ["Олена", "Андрій", "Марія", "Петро", "Ірина", "Сергій"],
    "department": ["DS", "Eng", "DS", "Eng", "Mkt", "DS"],
    "salary": [45000, 52000, 48000, 55000, 42000, 51000],
    "age": [28, 32, 25, 35, 29, 31],
})

# Кожна операція виконується негайно
result = (
    df
    .filter(pl.col("salary") > 45000)
    .select(["name", "department", "salary"])
    .sort("salary", descending=True)
)
result
shape: (4, 3)
name department salary
str str i64
"Петро" "Eng" 55000
"Андрій" "Eng" 52000
"Сергій" "DS" 51000
"Марія" "DS" 48000

Lazy Mode — приклад

# Lazy: будує план виконання
lf = df.lazy()

query = (
    lf
    .filter(pl.col("salary") > 45000)
    .select(["name", "department", "salary"])
    .sort("salary", descending=True)
)

print(f"Тип: {type(query)}")
print("Нічого не виконано поки що!")
Тип: <class 'polars.lazyframe.frame.LazyFrame'>
Нічого не виконано поки що!
# .collect() запускає оптимізоване виконання
query.collect()
shape: (4, 3)
name department salary
str str i64
"Петро" "Eng" 55000
"Андрій" "Eng" 52000
"Сергій" "DS" 51000
"Марія" "DS" 48000

Оптимізації Lazy Mode

# Створимо великий DataFrame
np.random.seed(42)
n = 1_000_000
big_df = pl.DataFrame({
    "id": range(n),
    "category": np.random.choice(["A", "B", "C", "D", "E"], n),
    "value": np.random.exponential(100, n).round(2),
    "region": np.random.choice(["Північ", "Південь", "Схід", "Захід"], n),
    "date": pl.Series(
        "date",
        [date(2020, 1, 1) + timedelta(days=int(d)) for d in np.random.randint(0, 1096, n)],
    ),
})
big_df.write_parquet("demo_data.parquet")
# Lazy — DuckDB-подібні оптимізації
query = (
    pl.scan_parquet("demo_data.parquet")
    .filter(pl.col("category") == "A")
    .select(["category", "value", "region"])
    .group_by("region")
    .agg(pl.col("value").mean().alias("avg_value"))
)

query.explain()
'AGGREGATE[maintain_order: false]\n  [col("value").mean().alias("avg_value")] BY [col("region")]\n  FROM\n  simple π 2/2 ["value", "region"]\n    Parquet SCAN [demo_data.parquet]\n    PROJECT 3/5 COLUMNS\n    SELECTION: [(col("category")) == ("A")]\n    ESTIMATED ROWS: 1000000'

Predicate & Projection Pushdown

flowchart TD
    subgraph "Без оптимізації"
        A1["Читання ВСІХ стовпців"] --> B1["Читання ВСІХ рядків"]
        B1 --> C1["Фільтрація"] --> D1["Вибір стовпців"]
    end
    subgraph "З оптимізацією (Lazy)"
        A2["Читання лише 3 стовпців<br>(projection pushdown)"] --> B2["Читання лише category='A'<br>(predicate pushdown)"]
        B2 --> C2["Агрегація"]
    end
    style A2 fill:#d9f6ec,stroke:#28a87d
    style B2 fill:#d9f6ec,stroke:#28a87d
    style A1 fill:#fde8e8,stroke:#c10000
    style B1 fill:#fde8e8,stroke:#c10000

Вирази (Expressions)

Серце Polars — Expressions

Вирази (Expressions) — це основна одиниця обчислень у Polars. Вони описують що зробити, а не як.

# Вираз — це ще не обчислення, а план
expr = pl.col("salary") * 1.1

print(f"Тип: {type(expr)}")
Тип: <class 'polars.expr.expr.Expr'>
# Вираз виконується в контексті DataFrame
df.select(
    pl.col("name"),
    pl.col("salary"),
    (pl.col("salary") * 1.1).alias("salary_raised"),
)
shape: (6, 3)
name salary salary_raised
str i64 f64
"Олена" 45000 49500.0
"Андрій" 52000 57200.0
"Марія" 48000 52800.0
"Петро" 55000 60500.0
"Ірина" 42000 46200.0
"Сергій" 51000 56100.0

Контексти виразів

Вирази працюють у різних контекстах:

# 1. select — вибір / трансформація стовпців
df.select(
    pl.col("name"),
    pl.col("salary").mean().alias("avg_salary"),
)
shape: (6, 2)
name avg_salary
str f64
"Олена" 48833.333333
"Андрій" 48833.333333
"Марія" 48833.333333
"Петро" 48833.333333
"Ірина" 48833.333333
"Сергій" 48833.333333
# 2. with_columns — додавання нових стовпців
df.with_columns(
    (pl.col("salary") / 1000).alias("salary_k"),
    pl.col("department").alias("dept"),
)
shape: (6, 6)
name department salary age salary_k dept
str str i64 i64 f64 str
"Олена" "DS" 45000 28 45.0 "DS"
"Андрій" "Eng" 52000 32 52.0 "Eng"
"Марія" "DS" 48000 25 48.0 "DS"
"Петро" "Eng" 55000 35 55.0 "Eng"
"Ірина" "Mkt" 42000 29 42.0 "Mkt"
"Сергій" "DS" 51000 31 51.0 "DS"

Контексти виразів (продовження)

# 3. filter — фільтрація рядків
df.filter(
    (pl.col("salary") > 45000) & (pl.col("department") == "DS")
)
shape: (2, 4)
name department salary age
str str i64 i64
"Марія" "DS" 48000 25
"Сергій" "DS" 51000 31
# 4. group_by + agg — агрегація
df.group_by("department").agg(
    pl.col("salary").mean().alias("avg_salary"),
    pl.col("name").count().alias("n_employees"),
    pl.col("age").min().alias("min_age"),
)
shape: (3, 4)
department avg_salary n_employees min_age
str f64 u32 i64
"DS" 48000.0 3 25
"Mkt" 42000.0 1 29
"Eng" 53500.0 2 32

Множинні операції з pl.col

Polars дозволяє застосовувати одну операцію до кількох стовпців одночасно:

data = pl.DataFrame({
    "a": [1, 2, 3, 4],
    "b": [10, 20, 30, 40],
    "c": [100, 200, 300, 400],
    "label": ["x", "y", "x", "y"],
})

# Одна операція — кілька стовпців
data.select(
    pl.col(["a", "b", "c"]).sum()
)
shape: (1, 3)
a b c
i64 i64 i64
10 100 1000
# Або через регулярний вираз / типи
data.select(
    pl.col(pl.Int64).mean()
)
shape: (1, 3)
a b c
f64 f64 f64
2.5 25.0 250.0

Селектори (Selectors)

import polars.selectors as cs

df_mixed = pl.DataFrame({
    "name": ["Alice", "Bob"],
    "age": [30, 25],
    "salary": [50000.0, 45000.0],
    "active": [True, False],
    "joined": [date(2020, 1, 1), date(2021, 6, 15)],
})

# Вибір за типом
df_mixed.select(cs.numeric())
shape: (2, 2)
age salary
i64 f64
30 50000.0
25 45000.0
# Виключення стовпців
df_mixed.select(cs.all() - cs.by_dtype(pl.Boolean) - cs.temporal())
shape: (2, 3)
name age salary
str i64 f64
"Alice" 30 50000.0
"Bob" 25 45000.0

Індексація та фільтрація

Polars НЕ має Index

На відміну від Pandas, Polars не має поняття Index. Це спрощує API:

Pandas (з Index):

# Встановити index
df.set_index("date", inplace=True)

# Вибірка по index
df.loc["2024-01-01"]

# Забули reset_index?
df.groupby(...).reset_index()

# SettingWithCopyWarning!
df[df["a"] > 5]["b"] = 10

Polars (без Index):

# Фільтрація напряму
df.filter(pl.col("date") == "2024-01-01")

# Просто group_by
df.group_by(...)

# Безпечна зміна
df.with_columns(
    pl.when(pl.col("a") > 5)
    .then(10)
    .otherwise(pl.col("b"))
)

Рядки за номером, стовпці за назвою

employees = pl.DataFrame({
    "name": ["Олена", "Андрій", "Марія", "Петро", "Ірина",
             "Сергій", "Наталія", "Олексій"],
    "department": ["DS", "Eng", "DS", "Eng", "Mkt", "DS", "Mkt", "Eng"],
    "salary": [45000, 52000, 48000, 55000, 42000, 51000, 44000, 58000],
})
employees.select(["name", "salary"]).head(5).tail(3)
shape: (3, 2)
name salary
str i64
"Марія" 48000
"Петро" 55000
"Ірина" 42000
employees.select(pl.col(["name", "salary"]).gather([2, 3, 4]))
shape: (3, 2)
name salary
str i64
"Марія" 48000
"Петро" 55000
"Ірина" 42000
employees.to_pandas().loc[2:4, ["name", "salary"]]
name salary
2 Марія 48000
3 Петро 55000
4 Ірина 42000

Фільтрація рядків

# Фільтрація за значенням стовпця
employees.filter(
    pl.col("department").is_in(["DS", "Eng"])
)
shape: (6, 3)
name department salary
str str i64
"Олена" "DS" 45000
"Андрій" "Eng" 52000
"Марія" "DS" 48000
"Петро" "Eng" 55000
"Сергій" "DS" 51000
"Олексій" "Eng" 58000
# Комбіновані умови
employees.filter(
    (pl.col("salary") > 45000) & (pl.col("department") != "Mkt")
)
shape: (5, 3)
name department salary
str str i64
"Андрій" "Eng" 52000
"Марія" "DS" 48000
"Петро" "Eng" 55000
"Сергій" "DS" 51000
"Олексій" "Eng" 58000

SettingWithCopy — проблема Pandas

SettingWithCopy - це коли Pandas не може визначити, чи ви працюєте з оригіналом, чи з копією.

f_pd = pd.DataFrame({"a": [1, 2, 3, 4, 5], "b": [10, 20, 30, 40, 50]})

# ⚠️ SettingWithCopyWarning!
# f_pd[f_pd["a"] <= 3]["b"] = f_pd["b"] // 10

# Правильний спосіб:
f_pd.loc[f_pd["a"] <= 3, "b"] = f_pd["b"] // 10
f_pd
a b
0 1 1
1 2 2
2 3 3
3 4 40
4 5 50
f_pl = pl.DataFrame({"a": [1, 2, 3, 4, 5], "b": [10, 20, 30, 40, 50]})

# Єдиний спосіб — with_columns (немає неоднозначності!)
f_pl.with_columns(
    pl.when(pl.col("a") <= 3)
    .then(pl.col("b") // 10)
    .otherwise(pl.col("b"))
    .alias("b")
)
shape: (5, 2)
a b
i64 i64
1 1
2 2
3 3
4 40
5 50

Пайплайни (Method Chaining)

Ланцюжки методів

Polars заохочує стиль method chaining — ланцюжки викликів методів:

np.random.seed(73)
flights = pl.DataFrame({
    "airline": np.random.choice(["MAU", "UIA", "SkyUp", "Wizz"], 10000),
    "origin": np.random.choice(["Київ", "Львів", "Одеса", "Харків"], 10000),
    "dest": np.random.choice(["Варшава", "Берлін", "Прага", "Відень", "Рим"], 10000),
    "delay": np.random.normal(10, 30, 10000).round(1),
    "distance": np.random.randint(500, 3000, 10000),
    "date": pl.Series("date", [date(2023, 1, 1) + timedelta(days=int(d)) for d in np.random.randint(0, 859, 10000)]),
})

(
    flights
    .filter(pl.col("delay") > 0)
    .group_by("airline")
    .agg(
        pl.col("delay").mean().alias("avg_delay"),
        pl.col("delay").max().alias("max_delay"),
        pl.len().alias("n_delayed"),
    )
    .sort("avg_delay", descending=True)
)

Ланцюжки методів

shape: (4, 4)
airline avg_delay max_delay n_delayed
str f64 f64 u32
"Wizz" 29.124225 126.6 1548
"SkyUp" 27.690691 107.3 1622
"UIA" 27.579936 103.8 1570
"MAU" 27.343909 108.7 1576

Вирази як функції

Polars-вирази можна виносити у функції, що повертають pl.Expr:

def extract_city() -> pl.Expr:
    """Витягти місто з формату 'Місто, Країна'"""
    return pl.col("route").str.split(",").list.get(0)

def delay_category() -> pl.Expr:
    """Класифікувати затримку"""
    d = pl.col("delay")
    return (
        pl.when(d <= 0).then(pl.lit("Вчасно"))
        .when(d <= 15).then(pl.lit("Незначна"))
        .when(d <= 60).then(pl.lit("Помірна"))
        .otherwise(pl.lit("Значна"))
        .alias("delay_category")
    )

flights_enriched = flights.with_columns(
    (pl.col("origin") + ", UA").alias("route"),
).with_columns(
    extract_city().alias("city"),
    delay_category(),
)
flights_enriched.select(["airline", "origin", "city", "delay", "delay_category"]).head()
shape: (5, 5)
airline origin city delay delay_category
str str str f64 str
"SkyUp" "Львів" "Львів" -3.6 "Вчасно"
"SkyUp" "Одеса" "Одеса" -64.8 "Вчасно"
"SkyUp" "Одеса" "Одеса" 7.5 "Незначна"
"SkyUp" "Київ" "Київ" 22.8 "Помірна"
"MAU" "Одеса" "Одеса" -3.7 "Вчасно"

with_columns vs select

select — обирає / трансформує стовпці (решта — відкидається):

df.select(
    pl.col("name"),
    (pl.col("salary") / 1000).alias("salary_k"),
)
shape: (6, 2)
name salary_k
str f64
"Олена" 45.0
"Андрій" 52.0
"Марія" 48.0
"Петро" 55.0
"Ірина" 42.0
"Сергій" 51.0

with_columns — додає / перезаписує стовпці (решта — зберігається):

df.with_columns(
    (pl.col("salary") / 1000).alias("salary_k"),
).head(3)
shape: (3, 5)
name department salary age salary_k
str str i64 i64 f64
"Олена" "DS" 45000 28 45.0
"Андрій" "Eng" 52000 32 52.0
"Марія" "DS" 48000 25 48.0

pipe для складних трансформацій

def add_salary_stats(frame: pl.DataFrame) -> pl.DataFrame:
    return frame.with_columns(
        pl.col("salary").mean().over("department").alias("dept_avg"),
        pl.col("salary").rank().over("department").alias("dept_rank"),
    )

def flag_top_earners(frame: pl.DataFrame, threshold: float = 0.8) -> pl.DataFrame:
    q = frame["salary"].quantile(threshold)
    return frame.with_columns(
        (pl.col("salary") >= q).alias("top_earner")
    )

result = (
    df
    .pipe(add_salary_stats)
    .pipe(flag_top_earners, threshold=0.75)
)
result
shape: (6, 7)
name department salary age dept_avg dept_rank top_earner
str str i64 i64 f64 f64 bool
"Олена" "DS" 45000 28 48000.0 1.0 false
"Андрій" "Eng" 52000 32 53500.0 1.0 true
"Марія" "DS" 48000 25 48000.0 2.0 false
"Петро" "Eng" 55000 35 53500.0 2.0 true
"Ірина" "Mkt" 42000 29 42000.0 1.0 false
"Сергій" "DS" 51000 31 48000.0 3.0 false

Типи даних

Система типів Polars

graph TD
    A[Polars Types] --> B[Числові]
    A --> C[Рядкові]
    A --> D[Темпоральні]
    A --> E[Вкладені]
    A --> F[Інші]

    B --> B1["Int8/16/32/64"]
    B --> B2["UInt8/16/32/64"]
    B --> B3["Float32/64"]

    C --> C1["Utf8 (String)"]
    C --> C2["Categorical"]
    C --> C3["Enum"]

    D --> D1["Date"]
    D --> D2["Time"]
    D --> D3["Datetime"]
    D --> D4["Duration"]

    E --> E1["List"]
    E --> E2["Struct"]
    E --> E3["Array"]

    F --> F1["Boolean"]
    F --> F2["Null"]
    F --> F3["Binary"]

    style A fill:#f9b928,stroke:#333

Оптимальні типи даних

Використання мінімально необхідних типів заощаджує пам’ять та час:

import sys

n = 1_000_000

# Int64 (за замовчуванням) vs UInt8
big_type = pl.Series("a", np.random.randint(0, 255, n), dtype=pl.Int64)
small_type = pl.Series("a", np.random.randint(0, 255, n), dtype=pl.UInt8)

print(f"Int64:  {big_type.estimated_size('mb'):.1f} MB")
print(f"UInt8:  {small_type.estimated_size('mb'):.1f} MB")
print(f"Економія: {(1 - small_type.estimated_size() / big_type.estimated_size()) * 100:.0f}%")
Int64:  7.6 MB
UInt8:  1.0 MB
Економія: 88%

Categorical vs String

n = 500_000
cities = np.random.choice(
    ["Київ", "Львів", "Одеса", "Харків", "Дніпро"], n
)

str_series = pl.Series("city", cities, dtype=pl.Utf8)
cat_series = pl.Series("city", cities, dtype=pl.Categorical)

print(f"Utf8:        {str_series.estimated_size('mb'):.1f} MB")
print(f"Categorical: {cat_series.estimated_size('mb'):.1f} MB")
print(f"Економія:    {(1 - cat_series.estimated_size() / str_series.estimated_size()) * 100:.0f}%")
Utf8:        5.0 MB
Categorical: 1.9 MB
Економія:    62%

Порада

Використовуйте pl.Categorical для стовпців з повторюваними рядками (міста, категорії, коди). Для унікальних рядків (імена, email) — залишайте Utf8.

List та Struct типи

# List — вкладені списки
df_list = pl.DataFrame({
    "name": ["Олена", "Андрій", "Марія"],
    "skills": [["Python", "SQL"], ["Rust", "Go", "Python"], ["R", "Python"]],
})
df_list
shape: (3, 2)
name skills
str list[str]
"Олена" ["Python", "SQL"]
"Андрій" ["Rust", "Go", "Python"]
"Марія" ["R", "Python"]
# Операції з List
df_list.with_columns(
    pl.col("skills").list.len().alias("n_skills"),
    pl.col("skills").list.contains("Python").alias("knows_python"),
)
shape: (3, 4)
name skills n_skills knows_python
str list[str] u32 bool
"Олена" ["Python", "SQL"] 2 true
"Андрій" ["Rust", "Go", "Python"] 3 true
"Марія" ["R", "Python"] 2 true

Struct тип

# Struct — іменовані поля (як dict)
df_struct = pl.DataFrame({
    "name": ["Олена", "Андрій"],
    "address": [
        {"city": "Київ", "zip": "01001"},
        {"city": "Львів", "zip": "79000"},
    ],
})
df_struct
shape: (2, 2)
name address
str struct[2]
"Олена" {"Київ","01001"}
"Андрій" {"Львів","79000"}
# Розпакування Struct
df_struct.with_columns(
    pl.col("address").struct.field("city").alias("city"),
    pl.col("address").struct.field("zip").alias("zip_code"),
)
shape: (2, 4)
name address city zip_code
str struct[2] str str
"Олена" {"Київ","01001"} "Київ" "01001"
"Андрій" {"Львів","79000"} "Львів" "79000"

Робота з файлами

Читання та запис

flowchart LR
    subgraph "Eager (read)"
        A1[read_csv] --> B1[DataFrame]
        A2[read_parquet] --> B1
        A3[read_json] --> B1
    end
    subgraph "Lazy (scan)"
        C1[scan_csv] --> D1[LazyFrame]
        C2[scan_parquet] --> D1
        C3[scan_ndjson] --> D1
    end
    D1 --> |".collect()"| B1
    style D1 fill:#d9f6ec,stroke:#28a87d

# Eager
df_eager = pl.read_parquet("demo_data.parquet")
print(f"Eager: {type(df_eager)}")

# Lazy (рекомендовано для великих файлів)
df_lazy = pl.scan_parquet("demo_data.parquet")
print(f"Lazy:  {type(df_lazy)}")
Eager: <class 'polars.dataframe.frame.DataFrame'>
Lazy:  <class 'polars.lazyframe.frame.LazyFrame'>

Parquet — оптимальний формат

# Порівняння розмірів файлів
big_df.write_csv("demo_data.csv")
big_df.write_parquet("demo_data.parquet")

import os
csv_size = os.path.getsize("demo_data.csv") / 1024 / 1024
parquet_size = os.path.getsize("demo_data.parquet") / 1024 / 1024

print(f"CSV:     {csv_size:.1f} MB")
print(f"Parquet: {parquet_size:.1f} MB")
print(f"Стиснення: {csv_size / parquet_size:.1f}x")
CSV:     36.3 MB
Parquet: 5.9 MB
Стиснення: 6.1x
# Порівняння швидкості читання
start = time.time()
_ = pl.read_csv("demo_data.csv")
csv_time = time.time() - start

start = time.time()
_ = pl.read_parquet("demo_data.parquet")
parquet_time = time.time() - start

print(f"CSV read:     {csv_time:.3f} с")
print(f"Parquet read: {parquet_time:.3f} с")
print(f"Прискорення:  {csv_time / parquet_time:.1f}x")
CSV read:     0.015 с
Parquet read: 0.006 с
Прискорення:  2.4x

Glob-патерни для кількох файлів

# Створимо кілька файлів
for year in [2020, 2021, 2022]:
    subset = big_df.filter(pl.col("date").dt.year() == year)
    subset.write_parquet(f"data_{year}.parquet")

# Читання кількох файлів одночасно
combined = pl.scan_parquet("data_*.parquet")
(
    combined
    .group_by(pl.col("date").dt.year().alias("year"))
    .agg(pl.len().alias("n_rows"), pl.col("value").sum().alias("total"))
    .sort("year")
    .collect()
)
shape: (3, 3)
year n_rows total
i32 u32 f64
2020 333507 3.3325e7
2021 333132 3.3308e7
2022 333361 3.3353e7

Агрегація та Group By

Базова агрегація

sales = pl.DataFrame({
    "product": np.random.choice(["Ноутбук", "Телефон", "Планшет", "Навушники"], 5000),
    "region": np.random.choice(["Київ", "Львів", "Одеса", "Харків", "Дніпро"], 5000),
    "amount": np.random.exponential(2000, 5000).round(2),
    "quantity": np.random.randint(1, 10, 5000),
    "date": pl.Series("date", [date(2023, 1, 1) + timedelta(days=int(d)) for d in np.random.randint(0, 600, 5000)]),
})

sales.group_by("product").agg(
    pl.col("amount").sum().alias("total_revenue"),
    pl.col("amount").mean().alias("avg_order"),
    pl.col("quantity").sum().alias("total_qty"),
    pl.len().alias("n_orders"),
).sort("total_revenue", descending=True)
shape: (4, 5)
product total_revenue avg_order total_qty n_orders
str f64 f64 i32 u32
"Телефон" 2.5721e6 2004.77993 6238 1283
"Ноутбук" 2.5154e6 1991.642692 6264 1263
"Планшет" 2.4638e6 2011.245045 6216 1225
"Навушники" 2.3536e6 1915.014133 6197 1229

Множинна агрегація

# Агрегація по кількох групах
sales.group_by(["product", "region"]).agg(
    pl.col("amount").mean().alias("avg_amount"),
    pl.col("amount").std().alias("std_amount"),
).sort(["product", "avg_amount"], descending=[False, True]).head(8)
shape: (8, 4)
product region avg_amount std_amount
str str f64 f64
"Навушники" "Харків" 1953.697036 1888.46038
"Навушники" "Львів" 1952.109321 2015.593795
"Навушники" "Одеса" 1904.76049 1886.537962
"Навушники" "Дніпро" 1898.011089 1765.803101
"Навушники" "Київ" 1872.052634 1989.539768
"Ноутбук" "Дніпро" 2220.290472 2205.624678
"Ноутбук" "Одеса" 2064.685882 2117.247452
"Ноутбук" "Харків" 1968.532227 2025.055531

Візуалізація: Виручка за продуктами

product_stats = sales.group_by("product").agg(
    pl.col("amount").sum().alias("revenue"),
    pl.col("amount").mean().alias("avg_order"),
).sort("revenue", descending=True)

fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 5))

colors = [red_pink, turquoise, orange, purple]

ax1.barh(
    product_stats["product"].to_list(),
    product_stats["revenue"].to_list(),
    color=colors
)
ax1.set_xlabel("Виручка, грн")
ax1.set_title("Загальна виручка")
ax1.xaxis.set_major_formatter(
    mticker.FuncFormatter(lambda x, p: f"{x/1e6:.1f}M")
)
ax1.invert_yaxis()

ax2.barh(
    product_stats["product"].to_list(),
    product_stats["avg_order"].to_list(),
    color=colors
)
ax2.set_xlabel("Середній чек, грн")
ax2.set_title("Середній чек")
ax2.invert_yaxis()

plt.tight_layout()
plt.show()

Віконні функції

Window Functions з .over()

Polars використовує .over() замість Pandas .groupby().transform().

# Pandas стиль:
# df.groupby("dept")["salary"].transform("mean")

# Polars стиль:
df.with_columns(
    pl.col("salary").mean().over("department").alias("dept_avg"),
    pl.col("salary").rank(descending=True).over("department").alias("rank_in_dept"),
    (pl.col("salary") - pl.col("salary").mean().over("department")).alias("diff_from_avg"),
)
shape: (6, 7)
name department salary age dept_avg rank_in_dept diff_from_avg
str str i64 i64 f64 f64 f64
"Олена" "DS" 45000 28 48000.0 3.0 -3000.0
"Андрій" "Eng" 52000 32 53500.0 2.0 -1500.0
"Марія" "DS" 48000 25 48000.0 2.0 0.0
"Петро" "Eng" 55000 35 53500.0 1.0 1500.0
"Ірина" "Mkt" 42000 29 42000.0 1.0 0.0
"Сергій" "DS" 51000 31 48000.0 1.0 3000.0

Віконні функції: приклади

(
    sales
    .with_columns(
        pl.col("amount")
            .rank(descending=True)
            .over("product")
            .alias("rank_in_product"),
    )
    .filter(pl.col("rank_in_product") <= 3)
    .select(["product", "region", "amount", "rank_in_product"])
    .sort(["product", "rank_in_product"])
)
shape: (12, 4)
product region amount rank_in_product
str str f64 f64
"Навушники" "Київ" 17043.99 1.0
"Навушники" "Одеса" 11780.01 2.0
"Навушники" "Дніпро" 10995.8 3.0
"Ноутбук" "Одеса" 16338.97 1.0
"Планшет" "Харків" 12779.78 3.0
"Телефон" "Одеса" 17718.0 1.0
"Телефон" "Київ" 15016.2 2.0
"Телефон" "Дніпро" 12736.13 3.0

Візуалізація: Boxplot затримок

flights_with_hour = (
    flights
    .filter(pl.col("delay").is_between(5, 120))
    .with_columns(pl.col("date").dt.month().alias("month"))
)

fig, ax = plt.subplots(figsize=(10, 5))
sns.boxplot(
    x="month",
    y="delay",
    data=flights_with_hour.to_pandas(),
    ax=ax,
    palette="viridis",
)
ax.set_xlabel("Місяць")
ax.set_ylabel("Затримка, хв")
ax.set_title("Розподіл затримок по місяцях")
plt.tight_layout()
plt.show()

Часові ряди

Темпоральні типи Polars

Тип Опис Приклад
pl.Date Лише дата 2024-03-15
pl.Time Лише час 14:30:00
pl.Datetime Дата + час 2024-03-15 14:30:00
pl.Duration Тривалість 2 days 3:00:00
ts = pl.DataFrame({
    "dt": pl.date_range(date(2024, 1, 1), date(2024, 12, 31), eager=True),
    "value": np.random.normal(100, 10, 366).cumsum(),
})
ts.head()
shape: (5, 2)
dt value
date f64
2024-01-01 103.680542
2024-01-02 207.779797
2024-01-03 304.583823
2024-01-04 383.965312
2024-01-05 478.293724

Фільтрація за датами

# Діапазон дат
ts.filter(
    pl.col("dt").is_between(date(2024, 3, 1), date(2024, 3, 31))
).head()
shape: (5, 2)
dt value
date f64
2024-03-01 6047.114609
2024-03-02 6144.128893
2024-03-03 6231.003949
2024-03-04 6344.721808
2024-03-05 6438.318467
# Компоненти дати
ts.with_columns(
    pl.col("dt").dt.year().alias("year"),
    pl.col("dt").dt.month().alias("month"),
    pl.col("dt").dt.weekday().alias("weekday"),
    pl.col("dt").dt.day().alias("day"),
).head()
shape: (5, 6)
dt value year month weekday day
date f64 i32 i8 i8 i8
2024-01-01 103.680542 2024 1 1 1
2024-01-02 207.779797 2024 1 2 2
2024-01-03 304.583823 2024 1 3 3
2024-01-04 383.965312 2024 1 4 4
2024-01-05 478.293724 2024 1 5 5

Resampling з group_by_dynamic

# Щотижнева агрегація
weekly = (
    ts
    .sort("dt")
    .group_by_dynamic("dt", every="1w")
    .agg(
        pl.col("value").mean().alias("mean"),
        pl.col("value").std().alias("std"),
        pl.col("value").min().alias("min"),
        pl.col("value").max().alias("max"),
    )
)
weekly.head()
shape: (5, 5)
dt mean std min max
date f64 f64 f64 f64
2024-01-01 391.684837 205.084799 103.680542 682.665631
2024-01-08 1084.96856 215.786128 782.390718 1385.56104
2024-01-15 1804.426016 223.672036 1490.029139 2116.060001
2024-01-22 2484.774219 197.477834 2208.018702 2756.123305
2024-01-29 3149.104744 209.360632 2859.958003 3444.637541

Візуалізація: Часовий ряд з MA

ts_ma = ts.with_columns(
    pl.col("value").rolling_mean(7).alias("MA_7"),
    pl.col("value").rolling_mean(30).alias("MA_30"),
)

fig, ax = plt.subplots(figsize=(10, 5))
ax.plot(ts_ma["dt"].to_list(), ts_ma["value"].to_list(),
        alpha=0.3, color="gray", label="Сирі дані")
ax.plot(ts_ma["dt"].to_list(), ts_ma["MA_7"].to_list(),
        color=turquoise, linewidth=1.5, label="MA(7)")
ax.plot(ts_ma["dt"].to_list(), ts_ma["MA_30"].to_list(),
        color=red_pink, linewidth=2, label="MA(30)")
ax.set_title("Часовий ряд з ковзними середніми")
ax.legend()
plt.tight_layout()
plt.show()

Rolling та Expanding

ts.select(
    pl.col("dt"),
    pl.col("value").alias("raw"),
    pl.col("value").rolling_mean(7).alias("rolling_7d"),
    pl.col("value").rolling_std(14).alias("rolling_std_14d"),
    (pl.col("value").cum_sum() / (pl.col("value").cum_count() + 1)).alias("expanding_mean"),
    pl.col("value").ewm_mean(alpha=0.1).alias("ewm_0.1"),
).tail(8)
shape: (8, 6)
dt raw rolling_7d rolling_std_14d expanding_mean ewm_0.1
date f64 f64 f64 f64 f64
2024-12-24 35798.572073 35476.753911 422.35812 17812.297131 34871.847129
2024-12-25 35895.788781 35578.741437 423.662331 17862.389906 34974.241294
2024-12-26 35979.080276 35678.960334 423.031131 17912.436012 35074.725192
2024-12-27 36094.112575 35783.565868 426.048769 17962.523275 35176.663931
2024-12-28 36189.544696 35887.052622 428.353049 18012.59751 35277.952007
2024-12-29 36285.582325 35989.095841 428.078417 18062.660482 35378.715039
2024-12-30 36400.482044 36091.880396 427.775021 18112.763819 35480.891739
2024-12-31 36514.503191 36194.15627 429.165233 18162.904798 35584.252885

Арифметика з Duration

ts.select(
    pl.col("dt"),
    (pl.col("dt") + pl.duration(days=7)).alias("plus_7d"),
    (pl.col("dt") + pl.duration(weeks=1)).alias("plus_1w"),
    pl.col("dt").diff().alias("diff"),
).head()
shape: (5, 4)
dt plus_7d plus_1w diff
date date date duration[μs]
2024-01-01 2024-01-08 2024-01-08 null
2024-01-02 2024-01-09 2024-01-09 1d
2024-01-03 2024-01-10 2024-01-10 1d
2024-01-04 2024-01-11 2024-01-11 1d
2024-01-05 2024-01-12 2024-01-12 1d

Pivot / Unpivot

Pivot — з довгого у широкий

prices = pl.DataFrame({
    "date": [*[date(2024, 1, 1)] * 4, *[date(2024, 1, 2)] * 4, *[date(2024, 1, 3)] * 4],
    "ticker": [*["AAPL", "TSLA", "MSFT", "NFLX"] * 3],
    "price": [190, 245, 375, 485, 192, 250, 378, 490, 188, 242, 380, 495],
})
prices
shape: (12, 3)
date ticker price
date str i64
2024-01-01 "AAPL" 190
2024-01-01 "TSLA" 245
2024-01-01 "MSFT" 375
2024-01-01 "NFLX" 485
2024-01-03 "AAPL" 188
2024-01-03 "TSLA" 242
2024-01-03 "MSFT" 380
2024-01-03 "NFLX" 495
pivoted = prices.pivot(index="date", on="ticker", values="price")
pivoted
shape: (3, 5)
date AAPL TSLA MSFT NFLX
date i64 i64 i64 i64
2024-01-01 190 245 375 485
2024-01-02 192 250 378 490
2024-01-03 188 242 380 495

Unpivot — з широкого у довгий

pivoted.unpivot(index="date", value_name="price", variable_name="ticker")
shape: (12, 3)
date ticker price
date str i64
2024-01-01 "AAPL" 190
2024-01-02 "AAPL" 192
2024-01-03 "AAPL" 188
2024-01-01 "TSLA" 245
2024-01-03 "MSFT" 380
2024-01-01 "NFLX" 485
2024-01-02 "NFLX" 490
2024-01-03 "NFLX" 495

Примітка

Polars використовує .unpivot() замість .melt() (починаючи з версії 1.0). Це inverse до .pivot().

Приклад: Квартальна виручка

quarterly = (
    sales
    .with_columns(pl.col("date").dt.quarter().alias("quarter"))
    .group_by(["product", "quarter"])
    .agg(pl.col("amount").sum().round().alias("revenue"))
    .sort(["product", "quarter"])
)

quarterly_wide = quarterly.pivot(
    index="product",
    on="quarter",
    values="revenue",
)
quarterly_wide
shape: (4, 5)
product 1 2 3 4
str f64 f64 f64 f64
"Навушники" 684048.0 692919.0 633666.0 342920.0
"Ноутбук" 721509.0 782435.0 594164.0 417337.0
"Планшет" 695577.0 764416.0 567998.0 435783.0
"Телефон" 823834.0 701027.0 606555.0 440717.0

Joins

Типи об’єднань

flowchart LR
    subgraph "inner"
        A1["A ∩ B"]
    end
    subgraph "left"
        A2["A + (A ∩ B)"]
    end
    subgraph "outer"
        A3["A ∪ B"]
    end
    subgraph "cross"
        A4["A × B"]
    end
    subgraph "anti"
        A5["A − B"]
    end
    subgraph "semi"
        A6["A, де ∃ B"]
    end

    %% Додаємо невидимі зв'язки між вузлами, щоб вишикувати їх горизонтально
    A1 ~~~ A2 ~~~ A3 ~~~ A4 ~~~ A5 ~~~ A6

customers = pl.DataFrame({
    "id": [1, 2, 3, 4],
    "name": ["Олена", "Андрій", "Марія", "Петро"],
    "city": ["Київ", "Львів", "Одеса", "Харків"],
})
orders = pl.DataFrame({
    "order_id": [101, 102, 103, 104],
    "customer_id": [1, 2, 2, 5],
    "amount": [1000, 2000, 500, 3000],
})

Inner, Left, Anti Join

customers.join(orders, left_on="id", right_on="customer_id", how="inner")
shape: (3, 5)
id name city order_id amount
i64 str str i64 i64
1 "Олена" "Київ" 101 1000
2 "Андрій" "Львів" 102 2000
2 "Андрій" "Львів" 103 500
customers.join(orders, left_on="id", right_on="customer_id", how="left")
shape: (5, 5)
id name city order_id amount
i64 str str i64 i64
1 "Олена" "Київ" 101 1000
2 "Андрій" "Львів" 102 2000
2 "Андрій" "Львів" 103 500
3 "Марія" "Одеса" null null
4 "Петро" "Харків" null null
# Клієнти БЕЗ замовлень
customers.join(orders, left_on="id", right_on="customer_id", how="anti")
shape: (2, 3)
id name city
i64 str str
3 "Марія" "Одеса"
4 "Петро" "Харків"

Semi та Cross Join

# Клієнти, ЩО мають замовлення (без дублювання)
customers.join(orders, left_on="id", right_on="customer_id", how="semi")
shape: (2, 3)
id name city
i64 str str
1 "Олена" "Київ"
2 "Андрій" "Львів"
sizes = pl.DataFrame({"size": ["S", "M", "L"]})
colors = pl.DataFrame({"color": ["Червоний", "Синій"]})
sizes.join(colors, how="cross")
shape: (6, 2)
size color
str str
"S" "Червоний"
"S" "Синій"
"M" "Червоний"
"M" "Синій"
"L" "Червоний"
"L" "Синій"

Продуктивність

Polars vs Pandas: бенчмарк

n = 2_000_000
bench_data = {
    "category": np.random.choice(["A", "B", "C", "D", "E"], n),
    "value": np.random.exponential(100, n),
    "region": np.random.choice(["N", "S", "E", "W"], n),
}
bench_pl = pl.DataFrame(bench_data)
bench_pd = bench_pl.to_pandas()
# GroupBy + Agg
start = time.time()
_ = bench_pd.groupby(["category", "region"])["value"].agg(["mean", "sum", "count"])
pd_groupby = time.time() - start

start = time.time()
_ = bench_pl.group_by(["category", "region"]).agg(
    pl.col("value").mean().alias("mean"), pl.col("value").sum().alias("sum"), pl.len()
)
pl_groupby = time.time() - start

print(f"Pandas GroupBy: {pd_groupby:.3f} с")
print(f"Polars GroupBy: {pl_groupby:.3f} с")
print(f"Прискорення:    {pd_groupby / pl_groupby:.1f}x")
Pandas GroupBy: 0.099 с
Polars GroupBy: 0.008 с
Прискорення:    12.3x

Візуалізація: Порівняння

ops = ["GroupBy+Agg", "Фільтрація", "Сортування", "Читання CSV"]

# Фільтрація
start = time.time()
_ = bench_pd[bench_pd["category"] == "A"]
pd_filter = time.time() - start
start = time.time()
_ = bench_pl.filter(pl.col("category") == "A")
pl_filter = time.time() - start

# Сортування
start = time.time()
_ = bench_pd.sort_values("value")
pd_sort = time.time() - start
start = time.time()
_ = bench_pl.sort("value")
pl_sort = time.time() - start

# CSV read
start = time.time()
_ = pd.read_csv("demo_data.csv")
pd_csv = time.time() - start
start = time.time()
_ = pl.read_csv("demo_data.csv")
pl_csv = time.time() - start

pd_times = [pd_groupby, pd_filter, pd_sort, pd_csv]
pl_times = [pl_groupby, pl_filter, pl_sort, pl_csv]

x = np.arange(len(ops))
width = 0.35

fig, ax = plt.subplots(figsize=(9, 5))
b1 = ax.bar(x - width/2, pd_times, width, label="Pandas", color=red_pink)
b2 = ax.bar(x + width/2, pl_times, width, label="Polars", color=yellow)
ax.set_ylabel("Час (секунди)")
ax.set_title("Pandas vs Polars (2M рядків)")
ax.set_xticks(x)
ax.set_xticklabels(ops)
ax.legend()
ax.bar_label(b1, fmt="%.3f", fontsize=8)
ax.bar_label(b2, fmt="%.3f", fontsize=8)
plt.tight_layout()
plt.show()

6 правил продуктивності

  1. Використовуйте Lazy API
    • scan_csv / scan_parquet
    • Оптимізація запитів автоматично
  2. Використовуйте Expressions
    • Уникайте .apply() / .map_elements()
    • Нативні вирази у 10-100x швидші
  3. Мінімальні типи даних
    • UInt8 замість Int64
    • Categorical замість Utf8
  1. Ефективне зберігання
    • Parquet > CSV
    • Стиснення: zstd, snappy
  2. Вибирайте лише потрібне
    • .select() замість SELECT *
    • Projection pushdown
  3. Паралелізм
    • Polars автоматично паралелить
    • Одна операція на кілька стовпців

apply vs вирази: приклад

test_series = pl.DataFrame({
    "text": np.random.choice(["hello", "WORLD", "Polars", "Data"], 100_000)
})

# ❌ Повільно: apply
start = time.time()
_ = test_series.with_columns(
    pl.col("text").map_elements(lambda x: x.upper(), return_dtype=pl.Utf8).alias("upper_slow")
)
slow_time = time.time() - start

# ✅ Швидко: нативний вираз
start = time.time()
_ = test_series.with_columns(
    pl.col("text").str.to_uppercase().alias("upper_fast")
)
fast_time = time.time() - start

print(f"map_elements: {slow_time:.3f} с")
print(f"Expression:   {fast_time:.3f} с")
print(f"Прискорення:  {slow_time / fast_time:.0f}x")
map_elements: 0.013 с
Expression:   0.003 с
Прискорення:  5x

Практичний приклад

Датасет: Замовлення

np.random.seed(73)
n = 300_000

orders_df = pl.DataFrame({
    "order_id": range(1, n + 1),
    "customer_id": np.random.randint(1, 5001, n),
    "product": np.random.choice(
        ["Ноутбук", "Телефон", "Планшет", "Навушники", "Монітор"], n
    ),
    "amount": np.round(np.random.exponential(800, n), 2),
    "city": np.random.choice(
        ["Київ", "Львів", "Одеса", "Харків", "Дніпро", "Вінниця"], n
    ),
    "order_date": pl.Series("order_date",
        sorted([date(2022, 1, 1) + timedelta(days=int(d)) for d in np.random.randint(0, 1096, n)])
    ),
})

orders_df.head()
shape: (5, 6)
order_id customer_id product amount city order_date
i64 i32 str f64 str date
1 147 "Монітор" 1677.27 "Дніпро" 2022-01-01
2 4015 "Телефон" 2168.07 "Одеса" 2022-01-01
3 395 "Планшет" 94.32 "Вінниця" 2022-01-01
4 4420 "Монітор" 18.65 "Дніпро" 2022-01-01
5 322 "Планшет" 1061.35 "Одеса" 2022-01-01

Повний аналітичний пайплайн

analytics = (
    orders_df.lazy()
    .with_columns(
        pl.col("order_date").dt.year().alias("year"),
        pl.col("order_date").dt.quarter().alias("quarter"),
        pl.col("order_date").dt.month().alias("month"),
    )
    .filter(pl.col("amount") > 0)
    .group_by(["year", "quarter", "product"])
    .agg(
        pl.col("amount").sum().alias("revenue"),
        pl.col("amount").mean().alias("avg_order"),
        pl.len().alias("n_orders"),
        pl.col("customer_id").n_unique().alias("unique_customers"),
    )
    .sort(["year", "quarter", "product"])
    .collect()
)

analytics.head(8)
shape: (8, 7)
year quarter product revenue avg_order n_orders unique_customers
i32 i8 str f64 f64 u32 u32
2022 1 "Монітор" 3.8945e6 790.127054 4929 3137
2022 1 "Навушники" 3.8698e6 788.155468 4910 3117
2022 1 "Ноутбук" 3.8898e6 797.746563 4876 3101
2022 1 "Планшет" 4.0848e6 813.703693 5020 3176
2022 1 "Телефон" 3.8252e6 810.938673 4717 3119
2022 2 "Монітор" 4.0351e6 790.420789 5105 3221
2022 2 "Навушники" 3.9078e6 792.015085 4934 3155
2022 2 "Ноутбук" 3.9757e6 803.65775 4947 3143

RFM-аналіз (Recency, Frequency, Monetary)

reference_date = date(2025, 1, 1)

rfm = (
    orders_df.lazy()
    .group_by("customer_id")
    .agg(
        (pl.lit(reference_date) - pl.col("order_date").max()).dt.total_days().alias("recency"),
        pl.len().alias("frequency"),
        pl.col("amount").sum().alias("monetary"),
    )
    .with_columns(
        pl.col("recency").qcut(4, labels=["1", "2", "3", "4"]).alias("r_score"),
        pl.col("frequency").qcut(4, labels=["4", "3", "2", "1"]).alias("f_score"),
        pl.col("monetary").qcut(4, labels=["4", "3", "2", "1"]).alias("m_score"),
    )
    .with_columns(
        (pl.col("r_score").cast(pl.Utf8)
         + pl.col("f_score").cast(pl.Utf8)
         + pl.col("m_score").cast(pl.Utf8)).alias("rfm_segment"),
    )
    .collect()
)
rfm.head(8)
shape: (8, 8)
customer_id recency frequency monetary r_score f_score m_score rfm_segment
i32 i64 u32 f64 cat cat cat str
4868 16 75 70235.2 "3" "1" "1" "311"
2763 50 71 55695.28 "4" "1" "1" "411"
3549 11 60 39076.34 "2" "3" "4" "234"
2239 33 68 52648.62 "4" "1" "2" "412"
4341 1 52 43137.64 "1" "4" "3" "143"
4603 33 63 47472.45 "4" "2" "3" "423"
4475 24 67 54596.23 "3" "1" "1" "311"
4600 36 67 53263.81 "4" "1" "2" "412"

Візуалізація: RFM розподіл

fig, axes = plt.subplots(1, 3, figsize=(14, 4))

for i, (col, title, color) in enumerate([
    ("recency", "Recency (днів)", turquoise),
    ("frequency", "Frequency (замовлень)", orange),
    ("monetary", "Monetary (грн)", purple),
]):
    axes[i].hist(rfm[col].to_list(), bins=40, color=color, alpha=0.7, edgecolor="white")
    axes[i].set_title(title)
    axes[i].set_ylabel("Кількість клієнтів")
    if col == "monetary":
        axes[i].xaxis.set_major_formatter(
            mticker.FuncFormatter(lambda x, p: f"{x/1e3:.0f}K")
        )

plt.suptitle("RFM-аналіз", fontsize=14, fontweight="bold")
plt.tight_layout()
plt.show()

Місячний когортний аналіз

cohort_data = (
    orders_df.lazy()
    .with_columns(
        pl.col("order_date").dt.truncate("1mo").alias("order_month"),
    )
    .with_columns(
        pl.col("order_month").min().over("customer_id").alias("cohort"),
    )
    .group_by(["cohort", "order_month"])
    .agg(pl.col("customer_id").n_unique().alias("users"))
    .sort(["cohort", "order_month"])
    .collect()
)

# Побудуємо для перших 6 когорт
top_cohorts = cohort_data["cohort"].unique().sort()[:6].to_list()

fig, ax = plt.subplots(figsize=(10, 5))
for cohort_date in top_cohorts:
    c = cohort_data.filter(pl.col("cohort") == cohort_date)
    ax.plot(
        c["order_month"].to_list(),
        c["users"].to_list(),
        marker="o", markersize=3,
        label=str(cohort_date)[:7],
    )

ax.set_title("Активність користувачів за когортами")
ax.set_ylabel("Унікальні користувачі")
ax.legend(title="Когорта", fontsize=8)
plt.tight_layout()
plt.show()

Візуалізація: Виручка по містах

city_monthly = (
    orders_df.lazy()
    .with_columns(pl.col("order_date").dt.truncate("1mo").alias("month"))
    .group_by(["month", "city"])
    .agg(pl.col("amount").sum().alias("revenue"))
    .sort("month")
    .collect()
)

fig, ax = plt.subplots(figsize=(10, 5))
for city in ["Київ", "Львів", "Одеса", "Харків", "Дніпро"]:
    subset = city_monthly.filter(pl.col("city") == city)
    ax.plot(
        subset["month"].to_list(),
        subset["revenue"].to_list(),
        linewidth=1.5, label=city
    )

ax.set_title("Місячна виручка по містах")
ax.set_ylabel("Виручка, грн")
ax.yaxis.set_major_formatter(
    mticker.FuncFormatter(lambda x, p: f"{x/1e6:.1f}M")
)
ax.legend()
plt.tight_layout()
plt.show()

Інтеграція з екосистемою

Polars + DuckDB

import duckdb

# DuckDB може запитувати Polars DataFrame напряму
result = duckdb.sql("""
    FROM orders_df
    SELECT product, city,
           ROUND(SUM(amount)) AS revenue,
           COUNT(*) AS orders
    GROUP BY ALL
    ORDER BY revenue DESC
    LIMIT 8
""").pl()

result
shape: (8, 4)
product city revenue orders
str str f64 i64
"Телефон" "Вінниця" 8.218825e6 10256
"Монітор" "Харків" 8.150321e6 10176
"Телефон" "Київ" 8.133639e6 9909
"Монітор" "Київ" 8.130884e6 10192
"Навушники" "Київ" 8.117292e6 10007
"Ноутбук" "Вінниця" 8.114303e6 9944
"Монітор" "Дніпро" 8.084575e6 10087
"Ноутбук" "Львів" 8.037585e6 10070

Polars + Pandas

# Polars → Pandas
pandas_df = orders_df.head(3).to_pandas()
print(f"Pandas: {type(pandas_df)}")
print(pandas_df)
Pandas: <class 'pandas.DataFrame'>
   order_id  customer_id  product   amount     city order_date
0         1          147  Монітор  1677.27   Дніпро 2022-01-01
1         2         4015  Телефон  2168.07    Одеса 2022-01-01
2         3          395  Планшет    94.32  Вінниця 2022-01-01
# Pandas → Polars
polars_df = pl.from_pandas(pandas_df)
print(f"\nPolars: {type(polars_df)}")
print(polars_df)

Polars: <class 'polars.dataframe.frame.DataFrame'>
shape: (3, 6)
┌──────────┬─────────────┬─────────┬─────────┬─────────┬─────────────────────┐
│ order_id ┆ customer_id ┆ product ┆ amount  ┆ city    ┆ order_date          │
│ ---      ┆ ---         ┆ ---     ┆ ---     ┆ ---     ┆ ---                 │
│ i64      ┆ i32         ┆ str     ┆ f64     ┆ str     ┆ datetime[ms]        │
╞══════════╪═════════════╪═════════╪═════════╪═════════╪═════════════════════╡
│ 1        ┆ 147         ┆ Монітор ┆ 1677.27 ┆ Дніпро  ┆ 2022-01-01 00:00:00 │
│ 2        ┆ 4015        ┆ Телефон ┆ 2168.07 ┆ Одеса   ┆ 2022-01-01 00:00:00 │
│ 3        ┆ 395         ┆ Планшет ┆ 94.32   ┆ Вінниця ┆ 2022-01-01 00:00:00 │
└──────────┴─────────────┴─────────┴─────────┴─────────┴─────────────────────┘

Polars + Matplotlib/Seaborn

# Polars DataFrame можна передати в seaborn
# через .to_pandas()
plot_data = (
    orders_df
    .filter(pl.col("amount") < 5000)
    .with_columns(pl.col("order_date").dt.year().alias("year"))
    .to_pandas()
)

fig, ax = plt.subplots(figsize=(10, 5))
sns.violinplot(
    data=plot_data,
    x="product",
    y="amount",
    hue="year",
    ax=ax,
    inner="quart",
    palette="Set2",
)
ax.set_title("Розподіл сум замовлень за продуктами та роками")
ax.set_ylabel("Сума, грн")
ax.set_xlabel("")
plt.tight_layout()
plt.show()

Порівняння з Pandas

Словник Pandas → Polars

Pandas Polars Коментар
df["col"] df["col"] або pl.col("col") Вирази — краще
df.loc[mask, "col"] df.filter(mask).select("col") Без index
df.assign(new=...) df.with_columns(...) Додає стовпці
df.groupby().transform() pl.col().over() Віконні функції
df.melt() df.unpivot() Перейменовано
df.apply(func) df.map_rows(func) Уникайте!
pd.to_datetime() pl.col().str.strptime() Парсинг дат
df.set_index() Немає index
df.reset_index() Не потрібно
df.rolling(7).mean() pl.col().rolling_mean(7) Ковзне середнє

Приклад міграції

df_pd = orders_df.head(1000).to_pandas()
result_pd = (
    df_pd
    .assign(year=lambda x: pd.to_datetime(x["order_date"]).dt.year)
    .groupby(["year", "product"])
    .agg(
        revenue=("amount", "sum"),
        orders=("amount", "count"),
    )
    .reset_index()
    .sort_values("revenue", ascending=False)
    .head(8)
)
result_pd
year product revenue orders
3 2022 Планшет 184284.38 207
1 2022 Навушники 165802.86 208
4 2022 Телефон 159319.30 203
2 2022 Ноутбук 158533.37 194
0 2022 Монітор 157277.29 188
result_pl = (
    orders_df.head(1000)
    .with_columns(pl.col("order_date").dt.year().alias("year"))
    .group_by(["year", "product"])
    .agg(
        pl.col("amount").sum().alias("revenue"),
        pl.len().alias("orders"),
    )
    .sort("revenue", descending=True)
    .head(8)
)
result_pl
shape: (5, 4)
year product revenue orders
i32 str f64 u32
2022 "Планшет" 184284.38 207
2022 "Навушники" 165802.86 208
2022 "Телефон" 159319.3 203
2022 "Ноутбук" 158533.37 194
2022 "Монітор" 157277.29 188

Підсумок

Що ми вивчили

  1. Що таке Polars та його переваги
  2. Архітектура (Rust + Arrow)
  3. Eager vs Lazy API
  4. Вирази (Expressions)
  5. Індексація без Index
  6. Method Chaining
  1. Типи даних та оптимізація
  2. Робота з файлами
  3. Агрегація та Group By
  4. Віконні функції (.over)
  5. Часові ряди
  6. Pivot / Unpivot / Joins

Ресурси

Домашнє завдання

  1. Встановіть Polars
  2. Завантажте будь-який датасет (>500K рядків):
  3. Виконайте:
    • Порівняння Eager vs Lazy (час виконання)
    • GroupBy з .over() віконними функціями
    • Pivot / Unpivot трансформації
    • Порівняння з Pandas (час + пам’ять)
  4. Збережіть дані в Parquet
  5. Створіть 3+ візуалізації

Дякую за увагу!



Матеріали курсу

ihor.miroshnychenko@knu.ua

Data Mirosh

@ihormiroshnychenko

@aranaur

aranaur.rbind.io