Робота з даними з використанням NumPy і pandas
Робота з даними з використанням NumPy і pandas
🎯 Мета лекції
- Зрозуміти архітектурну різницю між вбудованими структурами Python та оптимізованими масивами NumPy.
- Опанувати концепції векторизації, трансляції розмірностей (broadcasting), кроків у пам'яті (strides) та розмежування копій і подань (copy vs view).
- Навчитися маніпулювати одновимірними (
Series) та двовимірними (DataFrame) структурами pandas без використання явних циклів. - Засвоїти канонічні методи читання, фільтрації, очищення, групування, реляційного об'єднання та експорту великих масивів даних.
🔑 Ключові терміни
- ndarray: багатовимірний однорідний числовий масив фіксованого розміру в суцільному буфері пам'яті.
- Векторизація (Vectorization): виконання математичних операцій над усім масивом на рівні скомпільованого C-коду без повільних інтерпретованих циклів.
- Трансляція (Broadcasting): автоматичне віртуальне узгодження розмірностей масивів різної форми для поелементних обчислень.
- Кроки в пам'яті (Strides): кількість байтів, на яку потрібно зміститися в пам'яті для переходу до наступного елемента по відповідній осі.
- DataFrame / Series: основні табличні структури pandas із мітками осей, автоматичним вирівнюванням та оптимізованими операціями.
Завдання, яке розкриє правду про чистий Python
Уявіть: вам надіслали CSV-файл із 10 000 рядків — дані про продажі магазину за рік. Кожен рядок — одна транзакція: дата, категорія товару, ціна, кількість.
Ваш менеджер хоче знати:
- Яка середня ціна товарів у категорії «Електроніка»?
- Який товар продали найбільше разів?
- Скільки продажів було у грудні?
Здається, нічого складного. Спробуємо зробити це «чистим Python» — тобто тільки вбудованими засобами мови без жодних бібліотек:
import csv
# Читаємо CSV вручну
data = []
with open("sales.csv", encoding="utf-8") as f:
reader = csv.DictReader(f)
for row in reader:
data.append(row)
# Завдання 1: середня ціна в категорії "Електроніка"
total = 0
count = 0
for row in data:
if row["category"] == "Електроніка":
total += float(row["price"])
count += 1
average = total / count if count > 0 else 0
print(f"Середня ціна: {average:.2f}")
# Завдання 2: найпопулярніший товар
product_counts = {}
for row in data:
name = row["product"]
product_counts[name] = product_counts.get(name, 0) + int(row["quantity"])
best_product = max(product_counts, key=product_counts.get)
print(f"Лідер продажів: {best_product}")
# Завдання 3: продажі у грудні
december_sales = 0
for row in data:
if row["date"].startswith("2024-12"):
december_sales += 1
print(f"Продажів у грудні: {december_sales}")
Ось правда: цей код працює, але він:
- займає ~25 рядків для трьох простих запитань
- виконується повільно (цикл
forпо 10 000 рядків — три рази!) - важко читати і підтримувати
- не масштабується — для 1 000 000 рядків буде ще гірше
А тепер подивіться на те саме з pandas:
Аналіз продажів з pandas
Три завдання розв'язані в кілька рядків коду
import pandas as pd
from io import StringIO
# Створимо тестові дані для демонстрації
csv_data = StringIO("""date,product,category,price,quantity
2024-11-15,Телефон Samsung Galaxy,Електроніка,12000,5
2024-12-01,Ноутбук Dell,Електроніка,28000,2
2024-12-05,Футболка Nike,Одяг,850,10
2024-11-20,iPhone 15,Електроніка,45000,3
2024-12-10,Навушники Sony,Електроніка,3500,8
2024-11-25,Джинси Levi's,Одяг,2200,4
2024-12-15,Телефон Samsung Galaxy,Електроніка,12000,7""")
df = pd.read_csv(csv_data, parse_dates=["date"])
# Завдання 1: середня ціна в "Електроніці"
average = df[df["category"] == "Електроніка"]["price"].mean()
# Завдання 2: найпопулярніший товар
best_product = df.groupby("product")["quantity"].sum().idxmax()
# Завдання 3: продажі у грудні за допомогою дат
december_sales = (df["date"].dt.month == 12).sum()
print(f"Середня ціна в Електроніці: {average:.2f} грн")
print(f"Лідер продажів: {best_product}")
print(f"Продажів у грудні: {december_sales}")
Середня ціна в Електроніці: 20100.00 грн
Лідер продажів: Телефон Samsung Galaxy
Продажів у грудні: 4
Три рядки замість 25. Швидше у 10–100 разів. Читається як англійська мова.
Аналогія: чистий Python для роботи з даними — це як рахувати тисячі транзакцій на звичайному калькуляторі. NumPy та pandas — це як Excel із формулами, але на стероїдах: не тільки швидше та потужніше, а й у тисячу разів зручніше для складних запитань.
У цій статті ми побудуємо розуміння обох бібліотек з нуля: спочатку NumPy — бо саме він є «двигуном» усіх обчислень, а потім pandas — інструмент для роботи з таблицями.
Типовий процес роботи з даними
Перед тим як почати, подивимось на загальну схему роботи з даними — від завантаження до збереження результатів:
Ці п'ять кроків — стандартна послідовність інженерії даних. У цій статті ми опануємо кожен з них.
Архітектура NumPy та векторизовані обчислення
Чому стандартного списку Python недостатньо
Ви вже знаєте списки Python — це один із найбільш гнучких типів даних:
numbers = [1, 2, 3, 4, 5]
mixed = [1, "два", 3.0, True, None] # Список може містити об'єкти будь-яких типів
Саме ця гнучкість і стає головною перешкодою для високої продуктивності. З'ясуємо, як список Python розташовується в пам'яті комп'ютера:
Кожен елемент списку — це покажчик (pointer) на окремий повноцінний об'єкт мови Python у купі (heap). Кожен числовий об'єкт містить службовий заголовок:
- тип даних (
ob_type) - лічильник посилань (
ob_refcntдля збирача сміття) - безпосереднє значення (
ob_digit)
Один стандартний 64-бітний int у Python важить 28 байтів, тоді як «чисте» 64-бітне число потребує лише 8 байтів!
Що відбувається при виконанні циклу for по списку?
total = 0
for x in numbers:
# 1. Прочитати адресу вказівника зі списку
# 2. Розпакувати покажчик і перейти за адресою в купі (випадковий стрибок пам'яті)
# 3. Динамічно з'ясувати тип об'єкта (Type Dispatching)
# 4. Прочитати значення об'єкта
# 5. Створити НОВИЙ об'єкт int для результату суми
# 6. Оновити лічильники посилань
total += x
Для кожного окремого елемента інтерпретатор CPython виконує десятки інструкцій перевірки типів і розіменування покажчиків. При 10 000 елементів це вже помітно, а при 10 000 000 — призводить до багатосекундних зависань і промахів кешу процесора (CPU Cache Misses).
NumPy ndarray: однорідний масив у пам'яті
NumPy (Numerical Python) — фундаментальна інженерна бібліотека для наукових та числових обчислень. Її центральна структура — ndarray (N-dimensional array, N-вимірний масив).
Погляньмо, як зберігається аналогічний числовий вектор у середовищі NumPy:
Усі елементи масиву строго однорідні (homogeneous, наприклад int64 або float32) та розташовуються в пам'яті суцільним, нерозривним блоком (contiguous memory buffer). Немає жодних проміжних покажчиків і службових заголовків для кожного числа.
Кроки в пам'яті (Strides) та кеш-лінії процесора
Як NumPy знаходить потрібний елемент без перебору списку? Завдяки атрибуту strides (кроки в пам'яті).
Крок — це кількість байтів, на яку потрібно зміститися в неперервному буфері для переходу до наступного елемента вздовж обраної осі:
Для двовимірної матриці $M \times N$ типу int64 (8 байтів на число):
- Перехід до наступного рядка вимагає зміщення на $N \times 8$ байтів (
strides[0]). - Перехід до наступного стовпця вимагає зміщення на $8$ байтів (
strides[1]).
Аналогія: список Python — це шафа, де кожна річ запакована в окрему коробку з ярликом (що це, скільки важить, хто власник), і розкидана по різних кімнатах. Щоб зібрати речі, ви бігаєте від кімнати до кімнати. Масив NumPy — це єдиний стрічковий конвеєр, де абсолютно однакові деталі лежать впритул одна до одної. Щоб взяти соту деталь, ви просто робите $100 \times 8$ кроків уперед.
Встановлення та перший запуск
pip install numpy
uv pip install numpy
poetry add numpy
import numpy as np # np — загальноприйнятий псевдонім
import numpy as np, а не просто import numpy? Суто зручність — np.array(...) коротше, ніж numpy.array(...). Це загальноприйнятий стандарт — весь світ пише np.Створення масивів
Створення NumPy масивів різними способами
Розберемо 5 основних способів створення масивів
import numpy as np
# 1. З Python-списку
arr1 = np.array([1, 2, 3, 4, 5])
print("З списку:", arr1)
print("Тип:", type(arr1))
З списку: [1 2 3 4 5]
Тип: <class 'numpy.ndarray'>
# 2. Масив нулів
zeros = np.zeros(5)
print("Нулів:", zeros)
# 3. Масив одиниць
ones = np.ones(5)
print("Одиниць:", ones)
Нулів: [0. 0. 0. 0. 0.]
Одиниць: [1. 1. 1. 1. 1.]
# 4. Послідовність (аналог range)
seq = np.arange(0, 10, 2)
print("Послідовність:", seq)
# 5. Рівномірно розподілені
lin = np.linspace(0, 1, 5)
print("Лінійні:", lin)
Послідовність: [0 2 4 6 8]
Лінійні: [0. 0.25 0.5 0.75 1. ]
Атрибути масиву
Кожен ndarray зберігає повні метадані про свою структуру:
Атрибути NumPy масиву
Кожен масив зберігає метадані про свою структуру, тип і кроки в пам'яті
import numpy as np
arr = np.array([[1, 2, 3],
[4, 5, 6]], dtype=np.int64)
print(f"shape (форма): {arr.shape}")
print(f"ndim (вимірність): {arr.ndim}")
print(f"size (елементів): {arr.size}")
print(f"dtype (тип даних): {arr.dtype}")
print(f"itemsize (байт/ел): {arr.itemsize}")
print(f"strides (кроки Б): {arr.strides}")
shape (форма): (2, 3)
ndim (вимірність): 2
size (елементів): 6
dtype (тип даних): int64
itemsize (байт/ел): 8
strides (кроки Б): (24, 8)
strides = (24, 8):- Щоб перейти до наступного рядка тієї ж колонки, NumPy перестрибує на 24 байти вперед ($3 \text{ елементи} \times 8 \text{ байтів}$).
- Щоб перейти до наступного стовпця того ж рядка, зміщення становить рівно 8 байтів ($1 \text{ елемент} \times 8 \text{ байтів}$).
Саме завдяки
stridesоперації зрізів і транспонування виконуються миттєво!
Векторизовані операції та лінійна алгебра
Ось де проявляється головна перевага NumPy: замість повільних інтерпретованих циклів — прямі математичні операції на рівні C-бібліотек BLAS/LAPACK.
Векторизовані та матричні операції
Поелементна арифметика та матричне множення
import numpy as np
arr = np.array([1, 2, 3, 4, 5])
# Операції зі скалярами — застосовуються до КОЖНОГО елемента
print("arr + 10:", arr + 10)
print("arr * 2: ", arr * 2)
print("arr ** 2:", arr ** 2)
print("arr > 3: ", arr > 3)
arr + 10: [11 12 13 14 15]
arr * 2: [ 2 4 6 8 10]
arr ** 2: [ 1 4 9 16 25]
arr > 3: [False False False True True]
# Поелементні операції (добуток Адамара)
a = np.array([1, 2, 3])
b = np.array([10, 20, 30])
print("a + b (поелементно):", a + b)
print("a * b (поелементно):", a * b)
# Матричне множення (Dot Product / Matrix Multiplication)
A = np.array([[1, 2],
[3, 4]])
B = np.array([[5, 6],
[7, 8]])
# Оператор @ або np.matmul (рядки на стовпці)
print("\nМатричне множення A @ B:\n", A @ B)
a + b (поелементно): [11 22 33]
a * b (поелементно): [10 40 90]
Матричне множення A @ B:
[[19 22]
[43 50]]
_ завжди виконує поелементне множення (Hadamard product). Якщо вам потрібне класичне алгебраїчне множення матриць (скалярний добуток рядка на стовпець), завжди використовуйте оператор @ або функцію np.matmul(A, B).Порівняємо швидкість — наочний тест продуктивності:
Тест швидкості: Python list vs NumPy array
Помножимо 1 мільйон чисел на 2 двома способами
import time
import numpy as np
# Зі списком Python
python_list = list(range(1_000_000))
start = time.time()
python_result = [x * 2 for x in python_list]
python_time = time.time() - start
print(f"Python list: {python_time:.3f}с")
Python list: 0.124с
# З NumPy масивом
numpy_arr = np.arange(1_000_000)
start = time.time()
numpy_result = numpy_arr * 2
numpy_time = time.time() - start
print(f"NumPy array: {numpy_time:.3f}с")
NumPy array: 0.002с
# Висновок
print(f"🚀 NumPy швидше у {python_time/numpy_time:.0f} разів!")
🚀 NumPy швидше у 62 разів!
Індексація, зрізи та пастка Copy vs View
Індексація в NumPy нагадує списки Python, проте має принципову архітектурну відмінність.
Індексація та зрізи масивів
Робота з 1D та 2D масивами
import numpy as np
arr = np.array([10, 20, 30, 40, 50])
print("Перший елемент:", arr[0])
print("Останній:", arr[-1])
print("Зріз [1:4]:", arr[1:4])
print("Кожен другий:", arr[::2])
Перший елемент: 10
Останній: 50
Зріз [1:4]: [20 30 40]
Кожен другий: [10 30 50]
# 2D масив (матриця)
matrix = np.array([[1, 2, 3],
[4, 5, 6],
[7, 8, 9]])
print("Елемент [1, 2]:", matrix[1, 2])
print("Весь стовпець 1:", matrix[:, 1])
print("Весь рядок 0:", matrix[0, :])
print("Підматриця [1:, 1:]:\n", matrix[1:, 1:])
Елемент [1, 2]: 6
Весь стовпець 1: [2 5 8]
Весь рядок 0: [1 2 3]
Підматриця [1:, 1:]:
[[5 6]
[8 9]]
Подання проти копії (Copy vs View)
У стандартному Python зріз списку sub = my_list[1:4] завжди створює новий незалежний список (копію).
У NumPy для оптимізації швидкодії базовий зріз створює подання (view) — новий заголовок ndarray, який вказує на той самий буфер пам'яті!
Демонстрація View vs Copy у NumPy
Зміна зрізу мутує базовий масив
import numpy as np
original = np.array([10, 20, 30, 40, 50])
# Зріз створює VIEW
view_slice = original[1:4]
print("До зміни view_slice:", view_slice)
# Змінюємо перший елемент зрізу
view_slice[0] = 999
# Оригінальний масив теж змінився!
print("Після зміни original:", original)
print("view_slice ділить пам'ять з original?", np.shares_memory(original, view_slice))
print("view_slice.base is original?", view_slice.base is original)
До зміни view_slice: [20 30 40]
Після зміни original: [ 10 999 30 40 50]
view_slice ділить пам'ять з original? True
view_slice.base is original? True
# Щоб захистити оригінальні дані — створюйте явну копію .copy()
safe_copy = original[1:4].copy()
safe_copy[0] = 777
print("Після зміни safe_copy original не змінився:", original)
print("safe_copy ділить пам'ять з original?", np.shares_memory(original, safe_copy))
Після зміни safe_copy original не змінився: [ 10 999 30 40 50]
safe_copy ділить пам'ять з original? False
Булева індексація та пріоритет операторів
Булева індексація дозволяє фільтрувати масиви без жодного циклу:
Фільтрація даних за допомогою булевих масок
Булева індексація ЗАВЖДИ створює копію даних
import numpy as np
scores = np.array([85, 42, 91, 67, 78, 55, 96, 33])
# Створюємо булеву маску
mask = scores > 70
print("Маска:", mask)
# Застосовуємо маску для фільтрації
good_scores = scores[scores > 70]
print("Хороші оцінки:", good_scores)
Маска: [ True False True False True False True False]
Хороші оцінки: [85 91 78 96]
# Декілька умов: ОБОВ'ЯЗКОВО в круглих дужках з операторами &, |
middle_scores = scores[(scores >= 50) & (scores <= 85)]
print("Оцінки від 50 до 85:", middle_scores)
# Статистика відфільтрованих даних
print(f"Хороших оцінок: {len(good_scores)}")
print(f"Середня серед хороших: {good_scores.mean():.1f}")
Оцінки від 50 до 85: [85 67 78 55]
Хороших оцінок: 4
Середня серед хороших: 87.5
and та or вимагають привести весь масив до одного булевого значення (True чи False). Оскільки у масиві є різні значення, виникає фатальна помилка:ValueError: The truth value of an array with more than one element is ambiguous. Use a.any() or a.all()
& (І), | (АБО), ~ (НІ).
Оскільки побітовий оператор & має вищий пріоритет, ніж оператори порівняння >, <, вираз scores >= 50 & scores <= 85 інтерпретується як scores >= (50 & scores) <= 85, що призводить до помилки. Кожна умова обов'язково повинна бути взята в круглі дужки!Трансляція розмірностей (Broadcasting)
Broadcasting (трансляція розмірностей) — це алгоритм NumPy для виконання арифметичних операцій між масивами різної форми без фізичного копіювання даних.
Два фундаментальні правила Broadcasting:
- Масиви зіставляються за вимірами справа наліво (від останньої осі до першої).
- Дві розмірності вважаються сумісними, якщо:
- вони рівні між собою, АБО
- одна з них дорівнює 1 (у цьому випадку вона віртуально «розтягується» вздовж цієї осі зі
stride = 0).
Трансляція розмірностей на практиці: Центрування даних
Віднімаємо вектор середніх значень від матриці ознак
import numpy as np
# Матриця ознак 4 зразки x 3 характеристики
X = np.array([[10.0, 200.0, 1.5],
[12.0, 210.0, 1.8],
[14.0, 190.0, 1.4],
[16.0, 240.0, 2.1]])
# Обчислюємо середнє для кожного стовпця (форма: (3,))
mean_vector = X.mean(axis=0)
print("Форма X: ", X.shape)
print("Форма mean_vector: ", mean_vector.shape)
print("Середні стовпців: ", mean_vector)
# Віднімання завдяки Broadcasting: (4, 3) - (3,) -> (4, 3)
X_centered = X - mean_vector
print("\nЦентровані ознаки:\n", X_centered.round(2))
Форма X: (4, 3)
Форма mean_vector: (3,)
Середні стовпців: [ 13. 210. 1.7]
Центровані ознаки:
[[ -3. -10. -0.2]
[ -1. 0. 0.1]
[ 1. -20. -0.3]
[ 3. 30. 0.4]]
ValueError: operands could not be broadcast together with shapes (4,3) (4,)
vec.reshape(4, 1) або vec[:, np.newaxis].Агрегатні функції
Агрегатні функції для статистики
Обчислення суми, середнього, мінімуму та максимуму
import numpy as np
data = np.array([23, 45, 12, 67, 89, 34, 56, 78, 90, 11])
print(f"Сума: {data.sum()}")
print(f"Середнє: {data.mean():.1f}")
print(f"Мінімум: {data.min()}")
print(f"Максимум: {data.max()}")
print(f"Стд. відхилення: {data.std():.1f}")
print(f"Індекс мінімуму: {data.argmin()}")
print(f"Індекс максимуму: {data.argmax()}")
Сума: 505
Середнє: 50.5
Мінімум: 11
Максимум: 90
Стд. відхилення: 27.7
Індекс мінімуму: 9
Індекс максимуму: 8
Для 2D масивів можна вказати вісь (axis):
Агрегація по осях (axis)
Обчислення для рядків або стовпців окремо
import numpy as np
matrix = np.array([[1, 2, 3],
[4, 5, 6]])
print("Сума всіх елементів:", matrix.sum())
print("Сума по стовпцях (axis=0):", matrix.sum(axis=0))
print("Сума по рядках (axis=1):", matrix.sum(axis=1))
Сума всіх елементів: 21
Сума по стовпцях (axis=0): [5 7 9]
Сума по рядках (axis=1): [ 6 15]
Зміна форми: reshape
Зміна форми масиву (reshape)
Перетворення 1D масиву в 2D та назад без копіювання буфера
import numpy as np
arr = np.arange(12)
print("Одновимірний:", arr)
# reshape створює нове view з іншими strides, якщо пам'ять неперервна
matrix = arr.reshape(3, 4)
print("\nМатриця 3×4:\n", matrix)
flat = matrix.flatten()
print("\nЗнову 1D (копія):", flat)
Одновимірний: [ 0 1 2 3 4 5 6 7 8 9 10 11]
Матриця 3×4:
[[ 0 1 2 3]
[ 4 5 6 7]
[ 8 9 10 11]]
Знову 1D (копія): [ 0 1 2 3 4 5 6 7 8 9 10 11]
reshape — фундаментальна операція в ML. Зображення $28 \times 28$ пікселів потрібно перетворити у вектор із 784 елементів для подачі в нейронну мережу? image.reshape(784). Або навпаки — вектор у 2D зображення? flat.reshape(28, 28).Генерація випадкових чисел: сучасний default_rng
У машинному навчанні випадкові числа потрібні для ініціалізації ваг моделей, розбиття вибірок та стохастичних симуляцій.
У сучасному NumPy (NumPy 1.17+) стандартний спосіб генерації — це np.random.default_rng(), який використовує сучасний високошвидкісний алгоритм PCG-64 замість застарілого вихору Мерсенна.
Сучасна генерація випадкових чисел через Generator
Використання np.random.default_rng для відтворюваних експериментів
import numpy as np
# Створюємо генератор із фіксованим seed
rng = np.random.default_rng(seed=42)
# Рівномірний розподіл [0.0, 1.0)
random_floats = rng.random(5)
print("Випадкові [0, 1):", random_floats.round(3))
# Випадкові цілі числа від 1 до 100
random_ints = rng.integers(1, 100, size=5)
print("Випадкові цілі:", random_ints)
# Стандартний нормальний розподіл (μ=0, σ=1)
normal_dist = rng.standard_normal(5)
print("Нормальний розподіл:", normal_dist.round(3))
Випадкові [0, 1): [0.774 0.439 0.859 0.697 0.094]
Випадкові цілі: [43 27 68 83 45]
Нормальний розподіл: [-0.095 0.495 -0.669 1.026 -0.198]
# Вибірка з повторенням і без
items = np.array(["CPU", "GPU", "RAM", "SSD"])
choice_sample = rng.choice(items, size=3, replace=False)
print("Вибір без повернення:", choice_sample)
Вибір без повернення: ['GPU' 'SSD' 'CPU']
np.random.seed(42) та np.random.randn(). Це застаріле глобальне API, яке не є потокобезпечним і зафіксоване розробниками лише для зворотної сумісності. У нових проєктах завжди віддавайте перевагу default_rng().Порівняльна таблиця: список Python vs NumPy
| Характеристика | Список Python (list) | Масив NumPy (ndarray) |
|---|---|---|
| Тип елементів | Будь-які (гетерогенні) | Однорідні (homogeneous) |
| Пам'ять | Вказівники + обгортки об'єктів | Суцільний буфер пам'яті |
| Швидкість операцій | Повільно (цикли CPython) | У 10–100× швидше (SIMD, C/C++) |
| Арифметика | Конкатенація (+) | Векторизована поелементна |
| Багатовимірність | Вкладені списки (громіздко) | Вбудовані осі $N$-D (shape) |
| Булева фільтрація | Лише через list comprehension | Булеві маски без циклів |
| Математичні функції | Окремий модуль math у циклі | Вбудовані у векторному вигляді |
Табличні структури даних бібліотеки pandas
Що таке pandas і чому він скрізь
pandas — це промисловий стандарт бібліотеки для обробки, агрегації та структурування табличних даних. Назва походить від терміну «панельні дані» (Panel Data) з економетрики.
pandas побудований безпосередньо поверх NumPy: кожен стовпець таблиці — це оптимізований одновимірний масив ndarray. Завдяки цьому pandas поєднує надзвичайну гнучкість табличного представлення (іменовані рядки, назви колонок, інтелектуальне вирівнювання за індексами) зі швидкістю низькорівневих C-масивів.
Аналогія: якщо NumPy — це надпотужний двигун внутрішнього згоряння спортивного авто, то pandas — це ергономічна панель приладів, кермо, автоматична коробка передач і клімат-контроль. Ви отримуєте швидкість формульного двигуна у форматі комфортного автомобіля.
pip install pandas
uv pip install pandas
poetry add pandas
import pandas as pd # pd — загальноприйнятий псевдонім
Series: одновимірна структура даних
Series — це одновимірна структура даних pandas. Уявіть її як один стовпець Excel-таблиці: зліва — мітка рядка (індекс), справа — значення.
Створення Series
Найпростіший спосіб — зі списку Python
import pandas as pd
# Найпростіший Series: зі списку Python
temperatures = pd.Series([22, 25, 19, 27, 23])
temperatures
0 22
1 25
2 19
3 27
4 23
dtype: int64
За замовчуванням індекс — це числа 0, 1, 2... Але можна задати власний:
Series з власним індексом
Використаємо імена студентів як індекс
import pandas as pd
# Series з іменованим індексом
grades = pd.Series(
[85, 92, 78, 96, 71],
index=["Іван", "Олена", "Петро", "Марія", "Андрій"]
)
grades
Іван 85
Олена 92
Петро 78
Марія 96
Андрій 71
dtype: int64
Аналогія: Series — це як стовпець «Оцінки» у класному журналі. Зліва — прізвища учнів (індекс), справа — їхні бали (значення). Кожен рядок — пара «ім'я: оцінка».
Доступ до елементів Series:
Доступ до елементів Series
За іменем індексу або за позицією
import pandas as pd
grades = pd.Series(
[85, 92, 78, 96, 71],
index=["Іван", "Олена", "Петро", "Марія", "Андрій"]
)
print("Оцінка Олени:", grades["Олена"])
print("За позицією 1:", grades.iloc[1])
print("Перші три:\n", grades.iloc[0:3])
Оцінка Олени: 92
За позицією 1: 92
Перші три:
Іван 85
Олена 92
Петро 78
dtype: int64
Векторизовані операції над Series:
Векторизовані операції над Series
Операції застосовуються до всіх елементів відразу
import pandas as pd
grades = pd.Series(
[85, 92, 78, 96, 71],
index=["Іван", "Олена", "Петро", "Марія", "Андрій"]
)
# Додати 5 балів всім
print("Додати 5 балів всім:")
print(grades + 5)
Додати 5 балів всім:
Іван 90
Олена 97
Петро 83
Марія 101
Андрій 76
dtype: int64
# Відбір за умовою
print("Хто має оцінку > 80:")
excellent = grades[grades > 80]
print(excellent)
Хто має оцінку > 80:
Іван 85
Олена 92
Марія 96
dtype: int64
# Агрегатні функції
print(f"Середня оцінка: {grades.mean():.1f}")
print(f"Найвища: {grades.max()}")
print(f"Найнижча: {grades.min()}")
Series зі словника Python:
Series зі словника
Словник автоматично перетворюється на Series, де ключі стають індексом
import pandas as pd
# Словник автоматично перетворюється на Series
city_populations = pd.Series({
"Київ": 2952301,
"Харків": 1421125,
"Одеса": 1015826,
"Дніпро": 980775
})
city_populations.sort_values(ascending=False)
Київ 2952301
Харків 1421125
Одеса 1015826
Дніпро 980775
dtype: int64
DataFrame: двовимірна таблиця даних
DataFrame — це двовимірна структура даних pandas, аналог Excel-таблиці або SQL-таблиці бази даних. Складається зі стовпців (кожен стовпець — це Series) та рядків (кожен рядок — одне спостереження).
Аналогія: якщо Series — це один стовпець класного журналу (скажімо, оцінки з математики), то DataFrame — це весь журнал з усіма предметами. У кожному рядку — один учень, у кожному стовпці — один предмет.
Створення DataFrame зі словника
Створення DataFrame із даними про студентів
Побудуємо таблицю з іменами, віком та оцінками з трьох предметів
import pandas as pd
data = {
"Ім'я": ["Іван", "Олена", "Петро", "Марія"],
"Вік": [20, 22, 21, 23],
"Математика": [85, 92, 78, 96],
"Фізика": [78, 88, 82, 91],
"Python": [91, 95, 73, 89]
}
df = pd.DataFrame(data)
df
| Ім'я | Вік | Математика | Фізика | Python | |
|---|---|---|---|---|---|
| 0 | Іван | 20 | 85 | 78 | 91 |
| 1 | Олена | 22 | 92 | 88 | 95 |
| 2 | Петро | 21 | 78 | 82 | 73 |
| 3 | Марія | 23 | 96 | 91 | 89 |
# Перевіримо структуру
print(f"Розмір: {df.shape} (рядків × стовпців)")
print(f"Стовпці: {list(df.columns)}")
print(f"Типи даних:\n{df.dtypes}")
Структура DataFrame:
- Рядки (rows): кожен рядок — один студент (спостереження)
- Стовпці (columns): кожен стовпець — одна характеристика (ознака)
- Індекс (index): автоматичний (0, 1, 2...) або кастомний — мітки рядків
Доступ до даних у DataFrame
Це одна з найважливіших тем — є кілька різних способів, і кожен для свого випадку:
1. Доступ до стовпця — як до ключа словника:
Доступ до стовпця DataFrame
Отримання одного стовпця як Series
import pandas as pd
data = {
"Ім'я": ["Іван", "Олена", "Петро", "Марія"],
"Вік": [20, 22, 21, 23],
"Математика": [85, 92, 78, 96]
}
df = pd.DataFrame(data)
# Один стовпець → Series
ages = df["Вік"]
print("Тип:", type(ages))
print(ages)
Тип: <class 'pandas.core.series.Series'>
0 20
1 22
2 21
3 23
Name: Вік, dtype: int64
2. Доступ до рядків через .iloc — за числовою позицією:
Доступ до рядків через iloc
Доступ за числовою позицією (integer location)
import pandas as pd
data = {
"Ім'я": ["Іван", "Олена", "Петро", "Марія"],
"Вік": [20, 22, 21, 23],
"Математика": [85, 92, 78, 96]
}
df = pd.DataFrame(data)
# iloc = integer location (позиція, як у списку)
first_row = df.iloc[0]
print("Перший рядок:\n", first_row)
print("\nКонкретна комірка [1, 2]:", df.iloc[1, 2])
Перший рядок:
Ім'я Іван
Вік 20
Математика 85
Name: 0, dtype: object
Конкретна комірка [1, 2]: 92
3. Доступ до рядків та стовпців через .loc — за мітками індексу:
# loc = label-based location (за міткою)
# Якщо індекс числовий від 0 до N:
row = df.loc[0] # Рядок з міткою 0 (Іван)
# Якщо задати кастомний індекс:
df_named = df.set_index("Ім'я") # Тепер індекс — імена студентів
olena_data = df_named.loc["Олена"] # Доступ за міткою
df.iloc[0:2]працює за класичними правилами Python: права межа виключається (повертає лише 2 рядки: з індексами 0 та 1).df.loc[0:2](якщо індекс числовий) абоdf.loc['Іван':'Петро']: ОБИДВІ МЕЖІ ВКЛЮЧАЮТЬСЯ (повертає 3 рядки: 0, 1 та 2)! Це фундаментальне джерело прихованих багів у початківців.
Порівняння зрізів iloc та loc
Вибір рядків і стовпців одночасно (2D slicing)
import pandas as pd
data = {
"Ім'я": ["Іван", "Олена", "Петро", "Марія"],
"Вік": [20, 22, 21, 23],
"Математика": [85, 92, 78, 96],
"Python": [82, 95, 88, 91]
}
df = pd.DataFrame(data)
# 1. iloc: перші 2 рядки (0, 1) та перші 3 стовпці (0, 1, 2)
print("--- df.iloc[0:2, 0:3] (права межа виключається) ---")
print(df.iloc[0:2, 0:3])
# 2. loc: рядки від 0 до 2 (0, 1, 2!) та вибрані назви стовпців
print("\n--- df.loc[0:2, ['Ім\\'я', 'Python']] (обидві межі включаються) ---")
print(df.loc[0:2, ["Ім'я", "Python"]])
--- df.iloc[0:2, 0:3] (права межа виключається) ---
Ім'я Вік Математика
0 Іван 20 85
1 Олена 22 92
--- df.loc[0:2, ['Ім\'я', 'Python']] (обидві межі включаються) ---
Ім'я Python
0 Іван 82
1 Олена 95
2 Петро 88
4. Фільтрація рядків за умовою (Boolean Indexing):
Фільтрація рядків DataFrame
Відбір даних за умовою з використанням бітових операторів
import pandas as pd
data = {
"Ім'я": ["Іван", "Олена", "Петро", "Марія"],
"Вік": [20, 22, 21, 23],
"Математика": [85, 92, 78, 96],
"Python": [82, 95, 88, 91]
}
df = pd.DataFrame(data)
# Студенти, яким більше 21 року
older = df[df["Вік"] > 21]
print("Старші за 21:\n", older[["Ім'я", "Вік"]])
Старші за 21:
Ім'я Вік
1 Олена 22
3 Марія 23
# Кілька умов: обов'язково (& = AND, | = OR) у круглих дужках
top_students = df[(df["Математика"] > 85) & (df["Python"] > 85)]
print("Топ-студенти:\n", top_students[["Ім'я", "Математика", "Python"]])
Топ-студенти:
Ім'я Математика Python
1 Олена 92 95
3 Марія 96 91
# ❌ НЕБЕЗПЕЧНО: Chained Indexing
df[df["price"] > 10000]["discount"] = 0.2
df[...] повернула копію, чи посилання на пам'ять (view). Це призводить до сумнозвісного попередження SettingWithCopyWarning, і значення у вихідній таблиці може так і не оновитися!Канонічне інженерне рішення — пряме присвоєння через .loc:# ✅ ПРАВИЛЬНО: чітка адресація [рядки, стовпець]
df.loc[df["price"] > 10000, "discount"] = 0.2
df_expensive = df[df["price"] > 10000].copy()
df_expensive["discount"] = 0.2 # Повністю безпечно
5. Додавання нових стовпців:
Додавання обчисленого стовпця
Створимо стовпець "Середня" з середніх оцінок
import pandas as pd
data = {
"Ім'я": ["Іван", "Олена", "Петро", "Марія"],
"Математика": [85, 92, 78, 96],
"Фізика": [80, 88, 75, 90],
"Python": [89, 95, 80, 90]
}
df = pd.DataFrame(data)
# Обчислити середню оцінку кожного студента
df["Середня"] = df[["Математика", "Фізика", "Python"]].mean(axis=1)
df[["Ім'я", "Середня"]]
Ім'я Середня
0 Іван 84.666667
1 Олена 91.666667
2 Петро 77.666667
3 Марія 92.000000
6. Сортування:
Сортування DataFrame
Сортуємо студентів за середньою оцінкою
import pandas as pd
data = {
"Ім'я": ["Іван", "Олена", "Петро", "Марія"],
"Математика": [85, 92, 78, 96],
"Фізика": [80, 88, 75, 90],
"Python": [89, 95, 80, 90]
}
df = pd.DataFrame(data)
df["Середня"] = df[["Математика", "Фізика", "Python"]].mean(axis=1)
# Відсортувати за середньою оцінкою (від найвищої)
df_sorted = df.sort_values("Середня", ascending=False)
df_sorted[["Ім'я", "Середня"]]
Ім'я Середня
3 Марія 92.000000
1 Олена 91.666667
0 Іван 84.666667
2 Петро 77.666667
Читання, запис та інтеграція джерел даних
У реальній практиці інженерії даних датасети рідко вводять вручну. Зазвичай це вивантаження з реляційних баз даних (PostgreSQL, MySQL), файли з Data Lake (CSV, Parquet) або відповіді від REST/GraphQL API (JSON).
Читання CSV файлів
CSV (Comma-Separated Values) — найпоширеніший текстовий формат табличних даних:
df = pd.read_csv("sales.csv")
У реальних файлах виникають нестандартні роздільники, пошкоджені кодування або пропущені значення. Метод pd.read_csv має понад 50 параметрів — розглянемо найважливіші:
# Ключові параметри промислового читання CSV
df = pd.read_csv(
"sales.csv",
sep=";", # Роздільник колонок (наприклад, крапка з комою для європейських файлів)
header=0, # Номер рядка із заголовками (0 = перший рядок)
encoding="utf-8", # Кодування файлу (або "utf-8-sig", "cp1251")
na_values=["N/A", "-", "null", ""], # Значення, які автоматично трактувати як NaN
usecols=["date", "price", "quantity"], # Читати ЛИШЕ потрібні стовпці (економить RAM)
dtype={"price": "float32", "quantity": "int32"}, # Економні типи даних
parse_dates=["date"], # Автоматичне перетворення на datetime64
nrows=5000 # Читати лише перші 5000 рядків для швидкого тестування
)
UnicodeDecodeError, використовуйте encoding="utf-8-sig" (для файлів із BOM-маркером з Excel) або encoding="cp1251" (для кириличних файлів старих Windows-систем).Симптом: При спробі виконати pd.read_csv("file.csv") виникає виняток:
UnicodeDecodeError: 'utf-8' codec can't decode byte 0xe0 in position 15
Причина: Файл збережений у кодуванні, відмінному від UTF-8 (найчастіше Windows-1251 або Latin-1).
Рішення — поступова перевірка кодувань:
Вирішення проблеми з кодуванням файлу
Спробуємо варіанти безпечного читання
import pandas as pd
# Спроба 1: за замовчуванням (UTF-8) — помилка!
try:
df = pd.read_csv("sales.csv")
except UnicodeDecodeError as e:
print("❌ Помилка кодування:", e)
Читання та запис файлів Excel та JSON
# Читання файлів Excel (потрібен пакет openpyxl)
df_excel = pd.read_excel("report.xlsx", sheet_name="Продажі_2024")
# Читання JSON із REST API
df_json = pd.read_json("orders.json", orient="records")
# Запис результатів (завжди встановлюйте index=False для CSV!)
df.to_csv("clean_sales.csv", index=False, encoding="utf-8-sig")
df.to_excel("summary.xlsx", sheet_name="Аналітика", index=False)
df.to_json("output.json", orient="records", force_ascii=False)
index=False при виклику to_csv() майже завжди обов'язковий. Якщо його не вказати, pandas запише технічний індекс (0, 1, 2, 3...) як перший безіменний стовпець файлу. При наступному читанні в таблиці з'явиться зайва колонка Unnamed: 0.Реляційні операції над таблицями: merge та concat
У реальних проєктах дані майже ніколи не зберігаються в одній плоскій таблиці. Вони нормалізовані: окремо зберігається довідник клієнтів, окремо — каталог товарів, окремо — журнал транзакцій.
Об'єднання таблиць за ключем: pd.merge() (Аналог SQL JOIN)
Реляційне об'єднання двох таблиць (pd.merge)
Об'єднуємо транзакції з довідником користувачів за ID
import pandas as pd
# Таблиця 1: Замовлення
orders = pd.DataFrame({
"order_id": [101, 102, 103, 104],
"user_id": [1, 2, 1, 5],
"amount": [1200, 3400, 450, 890]
})
# Таблиця 2: Користувачі
users = pd.DataFrame({
"user_id": [1, 2, 3],
"name": ["Іван", "Олена", "Петро"],
"city": ["Київ", "Львів", "Одеса"]
})
# Внутрішнє об'єднання (INNER JOIN) — лише ті, хто є в обох таблицях
merged_inner = pd.merge(orders, users, on="user_id", how="inner")
print("--- INNER JOIN ---")
print(merged_inner)
# Ліве об'єднання (LEFT JOIN) — зберігає ВСІ замовлення
merged_left = pd.merge(orders, users, on="user_id", how="left")
print("\n--- LEFT JOIN (зверніть увагу на NaN для user_id=5) ---")
print(merged_left)
--- INNER JOIN ---
order_id user_id amount name city
0 101 1 1200 Іван Київ
1 103 1 450 Іван Київ
2 102 2 3400 Олена Львів
--- LEFT JOIN (зверніть увагу на NaN для user_id=5) ---
order_id user_id amount name city
0 101 1 1200 Іван Київ
1 102 2 3400 Олена Львів
2 103 1 450 Іван Київ
3 104 5 890 NaN NaN
Склеювання таблиць через pd.concat()
pd.concat() використовується для об'єднання кількох однакових таблиць по вертикалі (наприклад, логи за різні місяці) або по горизонталі:
Склеювання таблиць (pd.concat)
Поєднання квартальних звітів по вертикалі
import pandas as pd
q1_sales = pd.DataFrame({"місяць": ["Січень", "Лютий"], "виторг": [100000, 120000]})
q2_sales = pd.DataFrame({"місяць": ["Березень", "Квітень"], "виторг": [115000, 140000]})
# Склеюємо по вертикалі з оновленням індексів (ignore_index=True)
half_year = pd.concat([q1_sales, q2_sales], ignore_index=True)
print("Піврічний звіт:\n", half_year)
Піврічний звіт:
місяць виторг
0 Січень 100000
1 Лютий 120000
2 Березень 115000
3 Квітень 140000
Дослідницький аналіз та інформаційні методи
Щоразу коли ви отримуєте новий датасет, робочий процес аналітика чи ML-інженера розпочинається з розвідки: вивчити структуру, типи стовпців, баланс значень та аномалії.
import pandas as pd
# Завантажуємо датасет продажів для дослідження
df = pd.read_csv("sales_data.csv")
df.head(n) та df.tail(n) — погляд на дані
head() показує перші рядки (за замовчуванням — 5), tail() — останні рядки таблиці:
df.head(3) # Перші 3 рядки
df.tail(3) # Останні 3 рядки
df.sample(n) — випадкова репрезентативна вибірка
df.sample(5, random_state=42) # 5 випадкових рядків із фіксованим seed
df.sample(frac=0.01) # Випадкова вибірка в 1% від усього датасету
df.info() — повний технічний паспорт датасету
Це один із найважливіших інженерних методів. Він дає миттєву відповідь про кількість пам'яті, типи даних та пропуски:
Технічна довідка про датасет
Використаємо df.info() для швидкого огляду структури
import pandas as pd
df = pd.read_csv("sales_data.csv")
df.info()
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 10000 entries, 0 to 9999
Data columns (total 6 columns):
# Column Non-Null Count Dtype
--- ------ -------------- -----
0 order_id 10000 non-null int64
1 date 10000 non-null object
2 product 10000 non-null object
3 category 9850 non-null object
4 price 10000 non-null float64
5 quantity 9920 non-null int64
dtypes: float64(1), int64(2), object(3)
memory usage: 468.8 KB
Що ми дізналися з технічної довідки:
- Загальний обсяг: 10 000 записів (рядків) та 6 стовпців.
- Типи стовпців: 1 дійсний (
float64), 2 цілих (int64), 3 рядкових (object). - Наявність пропусків: у
categoryє 150 пропусків ($10000 - 9850$), уquantity— 80 пропусків. - Споживання оперативної пам'яті: ~469 KB.
df.info() розповідає вам відразу:
- Скільки рядків у датасеті (10 000)
- Скільки стовпців (6)
- Тип кожного стовпця —
int64,float64,object(рядки) - Кількість не-пустих значень — якщо
Non-Null Countменше загальної кількості рядків, є пропуски! - Скільки пам'яті займає датасет
Аналогія:
df.info()— це як технічний паспорт товару. Не розгортаючи упаковку, ви вже знаєте: що всередині, скільки важить, які матеріали. У випадку датасету — що за стовпці, якого типу, де є «діри».
df.describe() — статистичний портрет даних
Статистичний аналіз числових даних
Обчислимо основні статистичні показники
import pandas as pd
df = pd.read_csv("sales_data.csv")
df.describe()
| price | quantity | |
|---|---|---|
| count | 10000.000 | 9920.000 |
| mean | 5234.120 | 4.832 |
| std | 8901.445 | 3.217 |
| min | 99.000 | 1.000 |
| 25% | 450.000 | 2.000 |
| 50% | 1200.000 | 4.000 |
| 75% | 5500.000 | 7.000 |
| max | 120000.000 | 50.000 |
Аналіз результатів:
- 📊 Середня ціна 5234 грн, але max = 120000 → є дорогі товари-аутлайери
- 📦 Типова кількість у замовленні: 2-7 шт (квартилі 25%-75%)
- 🔍 std > mean для price → дані дуже розкидані (велика різниця між товарами)
# Для текстових стовпців окремо
print("Статистика категорій:")
print(df["category"].value_counts().head(3))
df.describe() обчислює статистику для числових стовпців:
count— кількість непустих значень (10000 для price, 9920 для quantity — 80 пропусків)mean— середнє арифметичне (5234.12 грн середня ціна)std— стандартне відхилення (наскільки «розкидані» дані навколо середнього)min,max— екстремальні значення (від 99 до 120000 грн!)25%,50%,75%— квартилі (50% = медіана — половина даних менше 1200 грн)
Що можна дізнатися з одного погляду:
max = 120 000приmean = 5 234— є дуже дорогі товари (аутлайери!)min = 1дляquantity— алеmax = 50? Реалістично для магазинуstd > meanдляprice— дані дуже «розкидані»
# Для текстових стовпців — окремо:
df.describe(include="object")
# Або включити все:
df.describe(include="all")
Швидка перевірка пропусків
Після df.info() ми вже знаємо, де є пропуски. Але для детальної картини:
Перевірка пропусків (NaN) у даних
Кількість та відсоток пропущених значень
import pandas as pd
import numpy as np
# Створимо тестовий DataFrame з пропусками
data = {
"order_id": range(1, 11),
"date": pd.date_range("2024-01-01", periods=10),
"product": ["Товар" + str(i) for i in range(1, 11)],
"category": ["Електроніка", "Одяг", None, "Електроніка", None, "Одяг", "Електроніка", None, "Одяг", "Електроніка"],
"price": [1000 + i*100 for i in range(10)],
"quantity": [5, None, 3, 4, None, 2, 6, 1, None, 4]
}
df = pd.DataFrame(data)
# Кількість пропусків у кожному стовпці
print("Пропуски:")
print(df.isnull().sum())
print("\nВідсоток пропусків:")
print((df.isnull().sum() / len(df) * 100).round(2))
Пропуски:
order_id 0
date 0
product 0
category 3
price 0
quantity 3
dtype: int64
Відсоток пропусків:
order_id 0.0
date 0.0
product 0.0
category 30.0
price 0.0
quantity 30.0
dtype: float64
Підсумкова таблиця методів
Аналогія: уявіть, що вам принесли товсту папку з незнайомими документами (датасет). Ось що ви робите в першу чергу:
| Метод | Аналогія | Що дізнаєтеся |
|---|---|---|
df.head() | Переглянути першу сторінку | Структура, назви стовпців, перші дані |
df.tail() | Перегорнути до кінця | Чи немає зайвих рядків у кінці |
df.sample() | Відкрити навмання | Різноманітність та типовість даних |
df.info() | Прочитати зміст | Типи, розмір, де є пропуски |
df.describe() | Прочитати резюме | Статистичний портрет числових даних |
df.isnull().sum() | Знайти порожні аркуші | Точна кількість пропусків |
Робота з категоріальними даними: value_counts()
Коли ви маєте категоріальні стовпці (наприклад, «Категорія товару», «Місто», «Статус замовлення»), один із найкорисніших методів — value_counts(). Він показує, скільки разів зустрічається кожне унікальне значення:
Аналіз категоріальних даних
Підрахунок частоти значень у категоріальному стовпці
import pandas as pd
# Створимо тестові дані продажів
data = {
"product": ["Телефон", "Ноутбук", "Футболка", "Телефон", "Книга", "Ноутбук", "Футболка", "Телефон"],
"category": ["Електроніка", "Електроніка", "Одяг", "Електроніка", "Книги", "Електроніка", "Одяг", "Електроніка"]
}
df = pd.DataFrame(data)
# Підрахунок частоти категорій
df["category"].value_counts()
category
Електроніка 5
Одяг 2
Книги 1
Name: count, dtype: int64
Це відразу показує: «Електроніка» — найпопулярніша категорія (3421 транзакція), «Іграшки» — найменш популярна (717).
Корисні параметри:
# З відсотками замість абсолютних чисел
df["category"].value_counts(normalize=True) * 100
# Електроніка 34.21%
# Одяг 28.34%
# ...
# Відсортувати за значенням (від найменшого до найбільшого)
df["category"].value_counts(ascending=True)
# Включити пропуски (NaN) у підрахунок
df["category"].value_counts(dropna=False)
Коли використовувати value_counts():
- Ви хочете дізнатися розподіл категорій (скільки чоловіків/жінок, скільки замовлень кожного статусу)
- Шукаєте найпопулярніші значення (топ-10 товарів, найчастіші міста)
- Перевіряєте баланс класів (чи є 90% одного класу і 10% іншого — дисбаланс!)
Аналогія:
value_counts()— це як підрахунок голосів після виборів. Ви бачите, скільки людей проголосувало за кожного кандидата, і хто переміг.
Групування даних: groupby()
Одна з найпотужніших операцій у pandas — групування даних. Уявіть: у вас є дані про продажі з різних міст, і ви хочете знати середній чек у кожному місті окремо.
Основний принцип groupby():
- Розділити (split) дані на групи за певною ознакою
- Застосувати (apply) функцію до кожної групи
- Об'єднати (combine) результати в одну таблицю
Групування даних (groupby)
Обчислення середньої ціни по містах
import pandas as pd
# Створимо датасет продажів
data = {
"city": ["Київ", "Харків", "Київ", "Харків", "Одеса", "Київ", "Одеса", "Львів"],
"product": ["Телефон", "Ноутбук", "Футболка", "Телефон", "Ноутбук", "Книга", "Телефон", "Футболка"],
"price": [12000, 28000, 450, 11500, 27000, 320, 12500, 480]
}
df = pd.DataFrame(data)
# Середня ціна товарів у кожному місті
df.groupby("city")["price"].mean()
city
Київ 4256.67
Львів 480.00
Одеса 19750.00
Харків 19750.00
Name: price, dtype: float64
Що відбулось:
- pandas розділив DataFrame на 4 групи (по одній для кожного міста)
- Для кожної групи обчислив
mean()стовпцяprice - Об'єднав результати в Series
Інші корисні агрегатні функції:
# Кількість транзакцій у кожному місті
df.groupby("city").size()
# Загальна сума продажів (не середня, а сума!)
df.groupby("city")["price"].sum()
# Мінімальна та максимальна ціна в кожному місті
df.groupby("city")["price"].agg(["min", "max", "mean"])
# Групування за ДВОМА стовпцями (місто + категорія)
df.groupby(["city", "category"])["price"].mean()
Приклад із реального життя:
Аналіз оцінок студентів за курсами
Обчислимо середню оцінку для кожного курсу окремо
import pandas as pd
# Датасет студентів
students = pd.DataFrame({
"Ім'я": ["Іван", "Олена", "Петро", "Марія", "Андрій", "Софія"],
"Курс": [2, 3, 2, 4, 3, 2],
"Оцінка": [85, 92, 78, 96, 88, 91]
})
students
| Ім'я | Курс | Оцінка | |
|---|---|---|---|
| 0 | Іван | 2 | 85 |
| 1 | Олена | 3 | 92 |
| 2 | Петро | 2 | 78 |
| 3 | Марія | 4 | 96 |
| 4 | Андрій | 3 | 88 |
| 5 | Софія | 2 | 91 |
# Середня оцінка по курсах
avg_by_course = students.groupby("Курс")["Оцінка"].mean()
avg_by_course
Курс
2 84.666667
3 90.000000
4 96.000000
Name: Оцінка, dtype: float64
# Кількість студентів на кожному курсі
print("Студентів на курсі:")
print(students.groupby("Курс").size())
print("\n📊 Висновок: найкраща середня оцінка на 4 курсі!")
Студентів на курсі:
Курс
2 3
3 2
4 1
dtype: int64
📊 Висновок: найкраща середня оцінка на 4 курсі!
Збереження колонок після групування: as_index=False та reset_index()
За замовчуванням стовпець, за яким виконується групування, стає індексом результуючого DataFrame. Це призводить до класичної помилки новачків KeyError, коли вони намагаються звернутися до df_grouped["city"].
Щоб зберегти стовпець як звичайну колонку, використовуйте as_index=False або викликайте .reset_index():
Збереження структури колонок після groupby
Два способи зберегти стовпці групування
import pandas as pd
data = {
"city": ["Київ", "Харків", "Київ", "Харків", "Одеса"],
"price": [12000, 28000, 450, 11500, 27000]
}
df = pd.DataFrame(data)
# Спосіб 1: as_index=False прямо в groupby
df_res1 = df.groupby("city", as_index=False)["price"].mean()
print("--- Спосіб 1 (as_index=False) ---")
print(df_res1)
# Спосіб 2: Іменована агрегація (Named Aggregation) з кастомними назвами колонок
df_res2 = df.groupby("city", as_index=False).agg(
середня_ціна=("price", "mean"),
кількість_замовлень=("price", "count")
)
print("\n--- Спосіб 2 (Іменована агрегація) ---")
print(df_res2)
--- Спосіб 1 (as_index=False) ---
city price
0 Київ 6225.00
1 Одеса 27000.00
2 Харків 19750.00
--- Спосіб 2 (Іменована агрегація) ---
city середня_ціна кількість_замовлень
0 Київ 6225.00 2
1 Одеса 27000.00 1
2 Харків 19750.00 2
Векторні аксесоари для рядків (.str) та дат (.dt)
pandas надає спеціальні аксесоари для роботи з текстом та часом без явних циклів.
Векторна обробка рядків через .str
# Пошук підрядка (регістронезалежний)
df[df["product"].str.contains("телефон", case=False)]
# Очищення від зайвих пробілів та зміна регістру
df["product"] = df["product"].str.strip().str.title()
Векторна робота з датами через .dt
Якщо стовпець має тип datetime64 (через parse_dates або pd.to_datetime), відкривається аксесор .dt:
# Отримання окремих компонентів часу
df["рік"] = df["date"].dt.year
df["місяць"] = df["date"].dt.month
df["день_тижня"] = df["date"].dt.day_name()
# Фільтрація транзакцій за грудень
december_orders = df[df["date"].dt.month == 12]
Оптимізація пам'яті: категоріальний тип (category)
За замовчуванням рядкові стовпці зберігаються з типом object (покажчики на рядки в купі Python). Якщо в таблиці 1 000 000 рядків, а унікальних міст лише 5, тип category замінює повторювані рядки цілочисельними кодами, зменшуючи пам'ять на 80–90%:
# До оптимізації
print(df["city"].memory_usage(deep=True)) # наприклад, ~65 MB
# Перетворення у категоріальний тип
df["city"] = df["city"].astype("category")
# Після оптимізації
print(df["city"].memory_usage(deep=True)) # зменшилось до ~2 MB!
Очищення від дублікатів: duplicated() та drop_duplicates()
Перед навчанням моделей машинного навчання обов'язково видаляють повторні записи:
# Знайти кількість повних рядків-дублікатів
print("Дублікатів у таблиці:", df.duplicated().sum())
# Видалити дублікати, залишивши перше входження
df = df.drop_duplicates()
# Видалити дублікати лише за конкретними стовпцями (наприклад, order_id)
df = df.drop_duplicates(subset=["order_id"], keep="first")
Корисні методи для трансформації даних
unique() та nunique() — унікальні значення
unique() повертає масив унікальних значень, а nunique() — їхню кількість:
Унікальні значення у стовпці
Знаходимо всі унікальні міста та їх кількість
import pandas as pd
data = {
"city": ["Київ", "Харків", "Київ", "Одеса", "Київ", "Харків", "Львів"],
"price": [1200, 1500, 1100, 1300, 1250, 1450, 1350]
}
df = pd.DataFrame(data)
# Унікальні міста
print("Унікальні міста:")
print(df["city"].unique())
# Кількість унікальних міст
print(f"\nКількість унікальних міст: {df['city'].nunique()}")
Унікальні міста:
['Київ' 'Харків' 'Одеса' 'Львів']
Кількість унікальних міст: 4
drop() — видалення стовпців або рядків
Для видалення колонок чи рядків використовуйте df.drop().
Видалення стовпців та рядків
Безпечне оновлення таблиці через переприсвоєння
import pandas as pd
data = {
"Ім'я": ["Іван", "Олена", "Петро"],
"Вік": [20, 22, 21],
"Temp_ID": [101, 102, 103], # Технічний стовпець
"Оцінка": [85, 92, 78]
}
df = pd.DataFrame(data)
# Рекомендований сучасний синтаксис: видалення за назвою колонки
df_clean = df.drop(columns=["Temp_ID"])
print("Без Temp_ID:")
print(df_clean)
Без Temp_ID:
Ім'я Вік Оцінка
0 Іван 20 85
1 Олена 22 92
2 Петро 21 78
# Видалення кількох стовпців або рядка за індексом
df_minimal = df.drop(columns=["Temp_ID", "Вік"])
df_no_first = df.drop(index=0)
print("Тільки ім'я та оцінка:\n", df_minimal)
print("\nБез першого рядка:\n", df_no_first)
Тільки ім'я та оцінка:
Ім'я Оцінка
0 Іван 85
1 Олена 92
2 Петро 78
Без першого рядка:
Ім'я Вік Temp_ID Оцінка
1 Олена 22 102 92
2 Петро 21 103 78
inplace=True:df.drop("Temp_ID", axis=1, inplace=True) # ❌ Антипатерн
inplace=True офіційно визнаний шкідливим антипатерном і запланований до вилучення:- Він не економить пам'ять: у переважній більшості випадків C-код все одно робить копію об'єкта під капотом.
- Він ламає конвеєрний стиль (method chaining), унеможливлюючи ланцюжки на кшталт
df.drop(...).fillna(...).sort_values(...). - Викликає неочікувані баги при роботі зі зрізами та посиланнями.
df = df.drop(columns=["Temp_ID"]) # ✅ Сучасний інженерний стандарт
dropna() та fillna() — обробка пропусків
Робота з пропусками (NaN)
Два підходи: видалити або заповнити
import pandas as pd
import numpy as np
data = {
"Ім'я": ["Іван", "Олена", "Петро", "Марія"],
"Вік": [20, np.nan, 21, 23],
"Оцінка": [85, 92, np.nan, 96]
}
df = pd.DataFrame(data)
print("DataFrame з пропусками:")
print(df)
print(f"\nПропусків: {df.isnull().sum().sum()}")
DataFrame з пропусками:
Ім'я Вік Оцінка
0 Іван 20.0 85.0
1 Олена NaN 92.0
2 Петро 21.0 NaN
3 Марія 23.0 96.0
Пропусків: 2
# Підхід 1: Видалити рядки з пропусками
df_dropped = df.dropna()
print("Видалили рядки з NaN:")
print(df_dropped)
Видалили рядки з NaN:
Ім'я Вік Оцінка
0 Іван 20.0 85.0
3 Марія 23.0 96.0
# Підхід 2: Заповнити пропуски нулем
df_filled = df.fillna(0)
print("Заповнили нулями:")
print(df_filled)
Заповнили нулями:
Ім'я Вік Оцінка
0 Іван 20.0 85.0
1 Олена 0.0 92.0
2 Петро 21.0 0.0
3 Марія 23.0 96.0
# Підхід 3: Заповнити оцінки середнім балом
df["Оцінка"] = df["Оцінка"].fillna(df["Оцінка"].mean())
print("Заповнили середнім:")
print(df)
Заповнили середнім:
Ім'я Вік Оцінка
0 Іван 20.0 85.0
1 Олена NaN 92.0
2 Петро 21.0 91.0
3 Марія 23.0 96.0
rename() — перейменування стовпців
df = df.rename(columns={"nm": "Ім'я", "ag": "Вік", "scr": "Оцінка"})
Типові помилки при роботі з даними та шляхи їх уникнення
Перед тим як перейти до практики, розберемо п'ять найпоширеніших помилок, які роблять початківці в Data Science.
# ❌ НЕПРАВИЛЬНО — створює зайвий безіменний стовпець з індексами 0, 1, 2...
df.to_csv("output.csv")
# Файл виглядає так:
# ,Ім'я,Вік,Оцінка ← зайва перша колонка!
# 0,Іван,20,85
# 1,Олена,22,92
# ✅ ПРАВИЛЬНО
df.to_csv("output.csv", index=False)
# Файл чистий і готовий для BI-систем та баз даних:
# Ім'я,Вік,Оцінка
# Іван,20,85
# Олена,22,92
Чому це проблема: Інші програми (Excel, Power BI, PostgreSQL) сприймають безіменний стовпець як звичайні дані, створюючи зайву колонку Unnamed: 0.
Правило: Завжди вказуйте index=False, якщо індекс — це просто автоматична нумерація 0, 1, 2...
df = pd.DataFrame({
"Ім'я": ["Іван", "Олена", "Петро"],
"Вік": [20, 22, 21]
})
# iloc — за ЦІЛОЧИСЕЛЬНОЮ ПОЗИЦІЄЮ (як індекс у списку Python)
print(df.iloc[0]) # Перший рядок (Іван)
# loc — за МІТКОЮ індексу
print(df.loc[0]) # Рядок з міткою 0 (збігається з iloc, якщо індекс за замовчуванням)
# Але якщо встановити кастомний індекс:
df_named = df.set_index("Ім'я")
print(df_named.loc["Олена"]) # ✅ Працює — пошук за текстовою міткою "Олена"
print(df_named.iloc[1]) # ✅ Працює — пошук за числовою позицією 1 (другий рядок)
# print(df_named.loc[1]) # ❌ KeyError! Мітки "1" не існує в індексі імен
Правило: Якщо потрібен «$N$-й за порядком рядок» — обирайте iloc. Якщо потрібен «рядок із конкретним ID або назвою» — loc.
df = pd.DataFrame({"price": [100, 200, 300, 400]})
# ❌ Ланцюговий доступ — ризик SettingWithCopyWarning
expensive = df[df["price"] > 200]
expensive["discount"] = 0.1 # Непередбачувана поведінка
# ✅ ПРАВИЛЬНО 1: Пряме оновлення значень в оригінальному DataFrame
df.loc[df["price"] > 200, "discount"] = 0.1
# ✅ ПРАВИЛЬНО 2: Створення незалежної ізольованої копії для підзадач
df_sub = df[df["price"] > 200].copy()
df_sub["discount"] = 0.1
df = pd.read_csv("sales.csv")
# Очікується число, але тип виявляється object (текст)
print(df["price"].dtype) # object (рядки!)
# Причина: у файлі були пробіли, символи валют ("1200 грн") або коми ("1,234.50")
# Спроба підсумувати дасть конкатенацію рядків замість арифметичної суми!
# Рішення: примусова безпечна конвертація
df["price"] = pd.to_numeric(
df["price"].astype(str).str.replace(",", "").str.extract(r"(\d+\.?\d*)")[0],
errors="coerce" # Замінить пошкоджені значення на NaN
)
df = pd.DataFrame({
"Оцінка": [85, 92, None, 78, 96]
})
# ❌ Антипатерн:
# df["Оцінка"].fillna(0, inplace=True)
# ✅ Сучасний стандарт: явне переприсвоєння
df["Оцінка"] = df["Оцінка"].fillna(df["Оцінка"].median())
Пам'ятайте: Такі функції, як mean() та sum(), автоматично ігнорують NaN, тоді як функції на кшталт len() враховують абсолютно всі рядки.
Практика
Завантажте класичний датасет Iris (150 рядків, 4 числові біологічні ознаки та клас квітки):
import pandas as pd
url = "https://raw.githubusercontent.com/mwaskom/seaborn-data/master/iris.csv"
df = pd.read_csv(url)
Завдання:
- Виведіть перші 10 рядків (
head(10)). - За допомогою
df.info()визначте кількість колонок, їхні типи та наявність пропусків. - Отримайте базову описову статистику через
df.describe(). Яка середня довжина пелюстки (petal_length)? - Знайдіть кількість пропусків у кожному стовпці за допомогою
isna().sum(). - Виведіть унікальні види квіток (
species) та їхню кількість черезvalue_counts().
import pandas as pd
# Завантаження датасету
url = "https://raw.githubusercontent.com/mwaskom/seaborn-data/master/iris.csv"
df = pd.read_csv(url)
# 1. Перші 10 рядків
print("--- Перші 10 рядків ---")
print(df.head(10))
# 2. Технічна довідка
print("\n--- Технічна інформація ---")
df.info()
# 3. Описова статистика числових стовпців
print("\n--- Статистичний портрет ---")
print(df.describe())
avg_petal = df["petal_length"].mean()
print(f"\nСередня довжина пелюстки (petal_length): {avg_petal:.2f} см")
# 4. Перевірка наявності пропусків
print("\n--- Кількість пропусків у стовпцях ---")
print(df.isna().sum())
# 5. Унікальні класи та частотний розподіл
print("\n--- Унікальні види рослин ---")
print(df["species"].value_counts())
Створіть навчальну таблицю та проведіть розрахунки:
import pandas as pd
data = {
"Ім'я": ["Аліна", "Богдан", "Вікторія", "Григорій", "Дарина"],
"Курс": [2, 3, 2, 4, 1],
"Математика": [88, 72, 95, 61, 84],
"Програмування": [91, 85, 98, 70, 79],
"Статистика": [76, 68, 92, 74, 88]
}
df = pd.DataFrame(data)
Завдання:
- Додайте розрахунковий стовпець
"Середній бал"як середнє арифметичне трьох предметів для кожного студента. - Відфільтруйте студентів із середнім балом вище 80.
- Знайдіть студента з найвищим балом із дисципліни
"Програмування"без явного циклу. - Відсортуйте таблицю за середнім балом у спадному порядку.
- Збережіть відсортовані результати у файл
students_ranked.csvбез запису колонки індексу.
import pandas as pd
data = {
"Ім'я": ["Аліна", "Богдан", "Вікторія", "Григорій", "Дарина"],
"Курс": [2, 3, 2, 4, 1],
"Математика": [88, 72, 95, 61, 84],
"Програмування": [91, 85, 98, 70, 79],
"Статистика": [76, 68, 92, 74, 88]
}
df = pd.DataFrame(data)
# 1. Додавання стовпця середнього балу по рядках (axis=1)
subjects = ["Математика", "Програмування", "Статистика"]
df["Середній бал"] = df[subjects].mean(axis=1).round(2)
# 2. Фільтрація відмінників
high_achievers = df[df["Середній бал"] > 80]
print("--- Студенти з середнім балом > 80 ---")
print(high_achievers[["Ім'я", "Курс", "Середній бал"]])
# 3. Пошук лідера з програмування через idxmax()
best_coder_idx = df["Програмування"].idxmax()
best_coder_name = df.loc[best_coder_idx, "Ім'я"]
best_score = df.loc[best_coder_idx, "Програмування"]
print(f"\nНайкращий результат з програмування: {best_coder_name} ({best_score} балів)")
# 4. Сортування за середнім балом від найвищого
df_sorted = df.sort_values(by="Середній бал", ascending=False)
print("\n--- Рейтинг студентів ---")
print(df_sorted[["Ім'я", "Середній бал"]])
# 5. Запис у CSV файл
df_sorted.to_csv("students_ranked.csv", index=False, encoding="utf-8-sig")
print("\n✅ Файл 'students_ranked.csv' успішно збережено!")
Використайте реальний історичний датасет:
import pandas as pd
url = "https://raw.githubusercontent.com/datasciencedojo/datasets/master/titanic.csv"
df = pd.read_csv(url)
Завдання:
- Проведіть розвідку датасету: оцініть форму (
shape), виведітьinfo(). У якому стовпці найбільше пропущених значень? - Обчисліть загальний відсоток виживання пасажирів на борту.
- Обчисліть частку виживших окремо для кожного класу каюти (
Pclass) за допомогоюgroupby, зберігши результат у вигляді датасету зі стовпцем відсотків. - Знайдіть середню вартість квитка (
Fare) для групи тих, хто вижив, і тих, хто загинув. - Обчисліть кількість та відсоток пропущених значень у стовпці віку
Age.
import pandas as pd
url = "https://raw.githubusercontent.com/datasciencedojo/datasets/master/titanic.csv"
df = pd.read_csv(url)
# 1. Розвідка даних
print(f"Розмірність датасету: {df.shape[0]} рядків, {df.shape[1]} колонок")
print("\nПропуски у стовпцях:")
print(df.isna().sum()[df.isna().sum() > 0])
# 2. Загальний відсоток виживання
overall_survival = df["Survived"].mean() * 100
print(f"\nЗагальна виживаність: {overall_survival:.2f}%")
# 3. Виживаність за класом каюти з Named Aggregation
class_analysis = df.groupby("Pclass", as_index=False).agg(
всього_пасажирів=("PassengerId", "count"),
відсоток_виживання=("Survived", lambda x: round(x.mean() * 100, 2))
)
print("\n--- Аналіз за класом каюти ---")
print(class_analysis)
# 4. Середній тариф для загиблих (0) та виживших (1)
fare_comparison = df.groupby("Survived", as_index=False).agg(
середній_тариф=("Fare", "mean")
)
print("\n--- Середня ціна квитка залежно від виживання ---")
print(fare_comparison)
# 5. Аналіз пропусків у стовпці Age
age_missing = df["Age"].isna().sum()
age_missing_pct = (age_missing / len(df)) * 100
print(f"\nПропусків у стовпці Age: {age_missing} ({age_missing_pct:.2f}% від усіх пасажирів)")
Концептуальні запитання для самоконтролю
У класичному Python елементи списку зберігаються як розрізнені посилання на об'єкти в купі. Копіювання зрізу списку створює новий масив вказівників, що має прийнятну ціну для невеликих масивів.
У наукових обчисленнях розмір масиву NumPy часто досягає сотень гігабайтів (наприклад, 3D томографія чи ваги нейромережі). Якби кожен зріз дублював байти в пам'яті, комп'ютер миттєво вичерпав би оперативну пам'ять (Out of Memory). Завдяки дескриптору ndarray, NumPy просто створює новий легковагий заголовок з іншим зсувом data та оновленими strides, вказуючи на той самий буфер пам'яті.
Оператори and та or викликають вбудований метод __bool__() операнда, намагаючись звести весь об'єкт до одного логічного значення (True чи False). Оскільки Series або ndarray містить безліч булевих значень одночасно, інтерпретатор не може однозначно вирішити, чи є масив істинним, і викидає виняток ValueError: The truth value of a Series is ambiguous.
Побітові оператори & (І), | (АБО), ~ (НІ) перевантажені у NumPy та pandas для виконання поелементної бінарної операції, повертаючи новий масив такої ж довжини.
None — це єдиний унікальний синглтон-об'єкт мови Python (тип NoneType). Масив або стовпець, що містить None, змушений переходити у тип object, втрачаючи всі переваги векторизованих SIMD-обчислень.
np.nan (Not a Number) — це спеціальне значення стандарту IEEE 754 для чисел із рухомою комою (тип float). Завдяки цьому стовпець із np.nan залишається оптимізованим типом float64, що дозволяє виконувати векторизовані операції C-кодом без розпакування об'єктів. Зверніть увагу: за стандартом IEEE np.nan != np.nan, тому для перевірки слід використовувати np.isnan(x) або df.isna().
- Ілюзія оптимізації пам'яті: Більшість методів pandas під капотом все одно створюють нову копію блоку пам'яті у C/Cython коді, а потім просто підміняють посилання.
- Руйнування Method Chaining: Методи з
inplace=TrueповертаютьNone, що унеможливлює лаконічні конвеєри обробки:# З inplace це неможливо: df = df.dropna(subset=["price"]).sort_values("date").reset_index(drop=True) - Попередження про депрекацію: У спільноті розробників pandas затверджено план вилучення параметра
inplaceу мажорній версії pandas 3.0.
stride = 0.
Це означає, що при проході вздовж цієї осі покажчик у фізичній пам'яті зміщується на 0 байтів вперед, тобто багаторазово зчитує одне й те саме фізичне число! Завдяки цьому обчислення відбуваються зі швидкістю повнорозмірної матриці без виділення жодного додаткового байта оперативної пам'яті.Резюме
NumPy ndarray
Strides та пам'ять
.T) та зміну форми (reshape) за нульовий час $O(1)$ без дублювання даних.Broadcasting
stride = 0. Дозволяє додавати вектори до матриць без фізичного копіювання даних.Copy vs View
.copy().iloc проти loc
iloc адресизує за числовими позиціями і виключає праву межу. loc адресизує за мітками індексу і включає обидві межі діапазону.Copy-on-Write та loc
df[...][...] = ...) через прямий виклик df.loc[mask, col] = val. Архітектура CoW гарантує передбачуваність змін.Реляційні операції
pd.merge() (аналог SQL JOIN) та конкатенація вздовж осей через pd.concat().Сучасні ідіоми
inplace=True, збереження стовпців у групуванні через as_index=False, оптимізація пам'яті через .astype('category') та робота з часом через .dt.df.describe().Знайомство з Jupyter Notebook та JupyterLab
Чому Data Science використовує Jupyter замість .py файлів, як встановити та запустити JupyterLab, що таке комірки коду та Markdown, і як ефективно працювати з нотбуком
Випадкові величини та їх типи
Що таке випадкова величина, різниця між дискретними та неперервними випадковими величинами, приклади з реального світу та перші обчислення з NumPy