Python з нуля · Блок 8 · Тема 34

Таблиця як один обʼєкт

Розібрати CSV руками ми вже вміємо. Але щойно з таблиці треба порахувати щось по групах, відкинути порожні клітинки й відсортувати результат — акуратний код перетворюється на три словники-лічильники й цикл, у якому легко помилитись. Pandas прибирає цей цикл.

У темі 21 ми зʼясували, що розбирати CSV рядковими методами не можна, і навчилися робити це чесно — через csv.DictReader. На виході отримали список словників: кожен рядок файлу став одним словником, ключі — назви стовпців. Поки питання просте («скільки всього?»), цього цілком досить. А тепер спробуй сформулювати вголос: середній чек по кожній філії, без порожніх клітинок, найбільший зверху. Одне речення. Скільки в ньому рядків коду?

У попередній темі ми отримали масив: багато однакових чисел в одному обʼєкті й операції відразу над усіма. Це половина потрібного. Друга половина — імена. У таблиці стовпці називаються словами, типи в них різні («Вокзал» поруч зі 125), а рядки можуть мати власні позначки, які не збігаються з номерами по порядку. Саме це й додає pandas: масив, у якого є імена.

Наскрізний приклад — журнал продажів тієї самої кавʼярні з теми 21, тільки тепер йдеться не про меню, а про чеки. Вісім записів, три філії, три напої. Він маленький навмисне: усі числа тут можна перевірити в голові, і саме тому видно, що бібліотека не робить нічого чарівного.

01 / МотивНавіщо, коли є csv

Ось те саме завдання, розвʼязане знайомими засобами. Код чесний, робочий і саме такий, який написав би уважний новачок після теми 21:

import csv from collections import defaultdict суми = defaultdict(float) скільки = defaultdict(int) with open("продажі.csv", newline="", encoding="utf-8") as файл: for рядок in csv.DictReader(файл): філія = рядок["філія"] суми[філія] += float(рядок["сума"]) скільки[філія] += 1 середні = {ф: суми[ф] / скільки[ф] for ф in суми} for філія, середнє in sorted(середні.items(), key=lambda пара: -пара[1]): print(філія, round(середнє, 2))

Претензій до нього немає. Претензія в іншому: у ньому три структури даних, яких немає в задачі. Замовник просив середній чек — а в коді зʼявились суми, скільки і середні, і кожну з них треба тримати в голові. Тепер уяви, що завтра попросять «тільки капучино» — доведеться додати if усередині циклу. А післязавтра «і ще по днях тижня» — і словники стануть словниками словників.

Код росте не лінійно від складності запитання, а швидше. Причина в тому, що ми описуємо шлях — як саме пройти рядки й куди що додати, — замість того щоб описувати результат. Pandas дає мову, в якій пишеш друге:

import pandas as pd продажі = pd.read_csv("продажі.csv") print(продажі.groupby("філія")["сума"].mean().round(2).sort_values(ascending=False))
філія Парк 160.00 Вокзал 126.67 Центр 65.00 Name: сума, dtype: float64

Рядок читається майже як речення: згрупуй за філією, візьми стовпець суми, усередни, округли, відсортуй за спаданням. «Тільки капучино» тут — не новий if, а ще одна ланка в ланцюжку. Ціна цієї стислості — нові поняття, і зараз ми розберемо їх усі по черзі.

Це той самий Python. pd — звичайне скорочення при імпорті, як np для NumPy: import pandas as pd пишуть усі, і відступати від цієї домовленості немає сенсу — чужий код читатимеш саме в такому вигляді. Ставиться бібліотека через pip install pandas (тема 17) і в стандартну поставку не входить.

02 / БудоваDataFrame, Series та індекс

У бібліотеці всього два головні типи, і другий є частиною першого.

DataFrame — таблиця. Рядки, іменовані стовпці, у різних стовпцях різні типи. Це те, що ти бачиш в Excel.

Series — один стовпець. Послідовність значень одного типу плюс позначки для кожного значення.

Створити таблицю можна просто зі словника, де ключ — назва стовпця, а значення — список:

продажі = pd.DataFrame({ "філія": ["Центр", "Центр", "Вокзал", "Вокзал", "Центр", "Парк", "Парк", "Вокзал"], "напій": ["Еспресо", "Капучино", "Еспресо", "Чай", "Чай", "Капучино", "Еспресо", "Капучино"], "кількість": [3, 2, 5, 4, 1, 6, 2, 3], "сума": [75, 90, 125, 120, 30, 270, 50, 135], })

Друк дає рівно те, чого чекаєш — з одним доважком ліворуч:

>>> продажі філія напій кількість сума 0 Центр Еспресо 3 75 1 Центр Капучино 2 90 2 Вокзал Еспресо 5 125 3 Вокзал Чай 4 120 4 Центр Чай 1 30 5 Парк Капучино 6 270 6 Парк Еспресо 2 50 7 Вокзал Капучино 3 135

Стовпчик 0…7 зліва — не стовпець таблиці. Це індекс (index): окремий обʼєкт з позначкою для кожного рядка. Саме його немає у звичайній таблиці, і саме він далі буде джерелом і зручності, і плутанини.

Поки що індекс виглядає нудно, бо він створився сам:

>>> продажі.index RangeIndex(start=0, stop=8, step=1) >>> продажі.columns Index(['філія', 'напій', 'кількість', 'сума'], dtype='object')

Але індекс — не обовʼязково номери. Це можуть бути номери чеків, дати (тема 22), артикули, імена. Головне — індекс не змінюється разом із позицією рядка. Відфільтруєш таблицю, відсортуєш її, викинеш половину рядків — позначки поїдуть із рядками, і третій зверху рядок цілком може мати індекс 7. З цієї простої обставини виростає весь розділ 04.

Схема 1 · Анатомія DataFrame

Три складові таблиці й те, що з неї віддає звертання до одного стовпця.

Індекс праворуч той самий, що й ліворуч. Коли ти береш стовпець, ти не отримуєш голий список значень — ти отримуєш Series, у якої залишились позначки рядків. Через це продажі["сума"] > 100 згодом можна буде підставити назад у таблицю: pandas знатиме, який відповідь стосується якого рядка.

Звертання до стовпця виглядає як звертання до словника — і це не випадковість:

>>> продажі["сума"] 0 75 1 90 2 125 3 120 4 30 5 270 6 50 7 135 Name: сума, dtype: int64

Ліворуч — індекс, праворуч — значення, унизу — імʼя стовпця й тип. Тип int64 знайомий із теми 33: усередині кожного стовпця живе звичайний масив NumPy. DataFrame — це набір таких масивів, поставлених поруч і підписаних.

Для профі: чому стовпець із рядками має тип object. Масив NumPy зберігає значення однакового розміру підряд, а українські назви напоїв мають різну довжину. Тому в стовпці філія лежать не самі рядки, а вказівники на звичайні пітонівські обʼєкти — звідси й назва типу. Наслідок практичний: операції над таким стовпцем ідуть зі швидкістю Python, а не C. У сучасному pandas є альтернатива — dtype="string[pyarrow]", яка зберігає текст компактно й працює помітно швидше.

03 / ЧитанняПрочитати файл і роздивитись

Руками таблиці набирають рідко. Звичайний початок роботи — один рядок:

продажі = pd.read_csv("продажі.csv")

Це той самий файл, який у темі 21 ми відкривали з newline="", віддавали в csv.DictReader і перетворювали числа руками. Тут усе це вже всередині: шапка стала назвами стовпців, лапки й екранування розібрані за правилами формату, а стовпці, де всі значення схожі на числа, отримали числовий тип.

Усі три налаштування з теми 21 нікуди не поділись — вони просто стали іменованими аргументами:

аргументнавіщо
sep=";"роздільник не кома — типовий файл з українського Excel
encoding="cp1251"файл не в UTF-8; неправильне значення дає кракозябри або падіння
decimal=","дробові числа записані з комою: 45,50
parse_dates=["день"]стовпець із датами одразу стає датами, а не текстом
na_values=["—", "н/д"]які саме позначки в цьому файлі означають «немає значення»

Далі — чотири виклики, з яких починається знайомство з будь-якою незнайомою таблицею. Роби їх завжди, навіть коли впевнений, що знаєш, що всередині.

shape каже, з чим маєш справу: скільки рядків і скільки стовпців. Це не метод, а властивість — дужок немає:

>>> продажі.shape (8, 4)

head(n) показує перші n рядків (за замовчуванням пʼять), щоб не друкувати мільйон. Є й tail(n) — останні:

>>> продажі.head(3) філія напій кількість сума 0 Центр Еспресо 3 75 1 Центр Капучино 2 90 2 Вокзал Еспресо 5 125

info() — найкорисніший з чотирьох. Він відповідає одразу на два питання, які завжди виникають: де порожні клітинки і які типи прочиталися:

>>> продажі.info() <class 'pandas.core.frame.DataFrame'> RangeIndex: 8 entries, 0 to 7 Data columns (total 4 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 філія 8 non-null object 1 напій 8 non-null object 2 кількість 8 non-null int64 3 сума 8 non-null int64 dtypes: int64(2), object(2) memory usage: 388.0+ bytes

Стовпчик Non-Null Count порівнюй із 8 entries вгорі: де число менше, там дірки. А Dtype ловить класичну халепу — стовпець, який мав бути числовим, прочитався як object. Причина майже завжди одна: у файлі серед чисел трапився нечисловий символ — пробіл між тисячами, знак валюти або та сама кома замість крапки.

describe() рахує зведення по числових стовпцях. Текстові він за замовчуванням пропускає:

>>> продажі.describe() кількість сума count 8.000000 8.000000 mean 3.250000 111.875000 std 1.669046 73.821092 min 1.000000 30.000000 25% 2.000000 68.750000 50% 3.000000 105.000000 75% 4.250000 127.500000 max 6.000000 270.000000

Читати це варто не зверху вниз, а порівнюючи два рядки: 50% (медіану) з mean. У стовпці сума середнє 111.9, а медіана 105 — середнє помітно вище, бо його тягне вгору один чек на 270. Коли ці два числа розходяться, у даних майже завжди є або кілька дуже великих значень, або помилка введення.

04 / ВибірВибір: loc проти iloc

Із стовпцями все просто. Один стовпець у квадратних дужках дає Series, список стовпців — нову таблицю:

продажі["сума"] # Series — один стовпець продажі[["філія", "сума"]] # DataFrame — два стовпці, дужки подвійні

Подвійні дужки — не описка й не «спеціальний синтаксис». Усередині тих самих квадратних дужок лежить звичайний список ["філія", "сума"]. Одна назва — один стовпець, список назв — кілька стовпців.

А от із рядками починається головна плутанина теми. Для них є два способи звертання, і вони не взаємозамінні.

Поки індекс — це 0, 1, 2, …, вони дають те саме, і різниці не видно взагалі. Різниця зʼявляється рівно тоді, коли індекс перестає збігатися з позицією. А стається це не в екзотичних випадках, а після найзвичайнішої дії — фільтрації:

>>> дорогі = продажі[продажі["сума"] > 100] >>> дорогі філія напій кількість сума 2 Вокзал Еспресо 5 125 3 Вокзал Чай 4 120 5 Парк Капучино 6 270 7 Вокзал Капучино 3 135

Чотири рядки, позиції 0, 1, 2, 3 — а індекс 2, 3, 5, 7. Позначки поїхали разом із рядками з початкової таблиці, і тепер у тій самій таблиці існують дві різні системи координат. Пограйся з ними:

Інтерактив 1 · loc і iloc на одному ключі

Таблиця дорогі з попереднього прикладу: індекс 2, 3, 5, 7, позиції 0, 1, 2, 3. Обери ключ і подивись на обидві відповіді.

дорогі.loc[k]
дорогі.iloc[k]
Ключі 2 і 3 — серце цієї теми. Обидва звертання спрацьовують, обидва повертають рядок, і обидва повертають різні рядки. Помилки не буде, програма не впаде — просто в звіті виявиться не той продаж. Ключі 0 і 1 дають протилежну картину: iloc працює, а loc падає з KeyError, бо мітки 0 в цій таблиці більше немає.

Обидва вміють брати не лише рядок, а й клітинку — через кому: спершу рядки, потім стовпці. І тут ховається ще одна відмінність, про яку легко забути:

>>> дорогі.loc[5, "напій"] # мітка 5, стовпець "напій" 'Капучино' >>> дорогі.iloc[2, 1] # третій рядок, другий стовпець 'Капучино' >>> дорогі.loc[2:5] # ЗРІЗ ЗА МІТКАМИ — 5 включно, три рядки >>> дорогі.iloc[0:3] # зріз за позиціями — 3 НЕ включно, три рядки

Зріз за мітками включає праву межу, на відміну від усіх зрізів, які ти бачив у Python досі. Це не примха: мітки не обовʼязково числа, і для дат loc["2024-01-01":"2024-01-31"] природно очікувати, що січень увійде цілком. Але переплутати легко, тож правило варто запамʼятати окремо.

Просте правило вибору. Питай себе: я знаю імʼя рядка чи його номер зверху? Знаю імʼя — loc. Знаю номер — iloc. Якщо ж стало байдуже, бо «вони й так однакові», — це і є той момент, коли варто перевірити df.index: індекс збігається з позиціями лише в щойно прочитаній таблиці, до першого фільтра чи сортування.

05 / ФільтрФільтр умовою

Фільтрація в pandas виглядає дивно рівно доти, доки не розкласти її на два кроки. Крок перший: порівняння застосовується до всього стовпця одразу й дає стовпець відповідей «так/ні» — таку саму Series тієї ж довжини:

>>> продажі["сума"] > 100 0 False 1 False 2 True 3 True 4 False 5 True 6 False 7 True Name: сума, dtype: bool

Крок другий: цю маску підставляють у квадратні дужки таблиці, і залишаються тільки рядки, навпроти яких стоїть True:

дорогі = продажі[продажі["сума"] > 100]

Тобто продажі[…] означає то «дай стовпець» (якщо всередині рядок-назва), то «дай рядки» (якщо всередині маска). Це справді два різні значення однакового запису; pandas розрізняє їх за типом того, що в дужках.

Умов буває кілька, і тут — друга класична пастка теми. Природно написати так:

>>> продажі[(продажі["сума"] > 100) and (продажі["філія"] == "Вокзал")] ValueError: The truth value of a Series is ambiguous. Use a.empty, a.bool(), a.item(), a.any() or a.all()

Причина вже знайома з теми 11. Оператор and вимагає від кожного боку одну істину чи хибу: він обчислює лівий вираз, перевіряє його на правдивість і вирішує, чи дивитись на правий. А зліва в нас вісім значень одразу. На питання «ця Series правдива?» чесної відповіді немає, і pandas відмовляється вгадувати — тому й пропонує в тексті помилки уточнити: .any() чи .all().

Потрібні поелементні оператори — ті самі, що в темі 33:

требапишине пиши
і те, і те&and
або те, або те|or
навпаки~not

І тут одразу третя пастка — дужки. У Python & звʼязує сильніше за > і ==, тому вираз без дужок читається зовсім не так, як виглядає:

продажі["сума"] > 100 & продажі["філія"] == "Центр" # Python розуміє це як: продажі["сума"] > (100 & продажі["філія"]) == "Центр" # TypeError: Cannot perform 'rand_' with a dtyped [object] array...

Тому кожну умову беруть у власні дужки — завжди, без винятків:

продажі[(продажі["сума"] > 100) & (продажі["філія"] == "Вокзал")]

Інтерактив 2 · Конструктор умови

Збери фільтр із двох частин і подивись, які рядки лишились. Оператор and тут теж доступний — саме щоб побачити, що з ним стається.

рядків
сума
Спробуй усі чотири способи зʼєднання на одній парі умов. & лишає рядки, де справджуються обидві умови, | — де хоч одна. Два останні варіанти нічого не повертають: and падає одразу, а зникнення дужок міняє порядок дій так, що вираз перестає мати сенс. Зверни увагу й на комбінації, де під & не лишається жодного рядка — порожня таблиця це не помилка, і pandas про неї не попередить.

Коли умова — це перелік значень, замість довгого ланцюжка | беруть isin:

>>> продажі[продажі["напій"].isin(["Чай", "Какао"])] філія напій кількість сума 3 Вокзал Чай 4 120 4 Центр Чай 1 30

06 / СтовпціНові стовпці

Новий стовпець створюється присвоєнням — так само, як новий ключ у словнику (тема 08). Праворуч від знака рівності пишуть вираз над цілими стовпцями, без жодного циклу:

продажі["ціна_за_шт"] = продажі["сума"] / продажі["кількість"] продажі["дорогий"] = продажі["сума"] > 100
>>> продажі.head(3) філія напій кількість сума ціна_за_шт дорогий 0 Центр Еспресо 3 75 25.0 False 1 Центр Капучино 2 90 45.0 False 2 Вокзал Еспресо 5 125 25.0 True

Ділення відбулося порядково: перший елемент на перший, другий на другий. Це та сама векторна арифметика з теми 33, тільки тепер операнди знаходяться за іменами, а не за номерами. І вирівнюються вони теж за індексом, а не за позицією: якщо скласти два стовпці з різних таблиць, pandas зіставить рядки з однаковими мітками, а для міток, що є лише в одному боці, поставить NaN.

Дві дрібниці, які варто знати одразу. Перша: продажі.сума теж працює й дає той самий стовпець, але лише поки назва — коректне імʼя Python і не збігається з методом. Стовпець ціна за шт із пробілом так не візьмеш, а стовпець count віддасть метод замість даних. Квадратні дужки працюють завжди.

Друга: перейменувати стовпці можна словником, і це перше, що роблять із чужим файлом, де шапка написана як заманеться:

продажі = продажі.rename(columns={"сума": "виторг"})

07 / groupbyРозділити, застосувати, зібрати

Ось операція, заради якої pandas і вчать. Питання «скільки виторгу дала кожна філія» розкладається на три кроки, і всі три робить один вираз.

>>> продажі.groupby("філія")["сума"].sum() філія Вокзал 380 Парк 320 Центр 195 Name: сума, dtype: int64

Розділити (split): рядки роз'їжджаються по купках за значенням у стовпці філія — три рядки в «Центр», три у «Вокзал», два в «Парк». Застосувати (apply): у кожній купці окремо береться стовпець сума і згортається в одне число. Зібрати (combine): числа складаються в нову маленьку таблицю, де індексом стають самі назви філій.

Схема 2 · Розділити → застосувати → зібрати

Той самий вираз продажі.groupby("філія")["сума"].sum(), розкладений на три кроки.

Зверни увагу на індекс у правому стовпчику. Назви філій із даних перетворились на мітки рядків — тому далі до результату звертаються через .loc["Вокзал"], а не через номер. І порядок груп у відповіді алфавітний, а не такий, як у файлі: groupby сортує ключі, поки йому явно не сказати sort=False.

Замість sum може стояти будь-яка згортка: mean, count, min, max, median, std, nunique. Якщо не вибирати стовпець, згортка застосується до всіх числових одразу. А коли потрібні різні згортки різних стовпців — є agg, де кожен рядок задає одну колонку майбутнього звіту:

>>> продажі.groupby("філія").agg( ... чеків=("сума", "count"), ... виторг=("сума", "sum"), ... середній=("сума", "mean"), ... ) чеків виторг середній філія Вокзал 3 380 126.666667 Парк 2 320 160.000000 Центр 3 195 65.000000

Групувати можна й за кількома стовпцями — тоді ключем стає пара значень: продажі.groupby(["філія", "напій"])["сума"].sum(). Результат матиме дворівневий індекс; повернути його назад у звичайні стовпці — це .reset_index().

Пригадай itertools.groupby з теми 30. Та функція групувала лише сусідні однакові ключі, тож дані спершу треба було сортувати вручну — інакше «Центр» на початку й «Центр» у кінці давали дві окремі групи. Метод groupby у pandas такої вимоги не має: він сам будує відповідність «ключ → номери рядків», тому порядок у файлі не має значення. Однакова назва, різна поведінка — приклад того, чому назву методу завжди варто перевіряти за документацією саме тієї бібліотеки.

08 / ПропускиПропуски й NaN

У справжніх файлах клітинки бувають порожні: касир не пробив суму, прилад не зняв показник, поле не заповнили. Порожнє місце в CSV pandas читає як NaNnot a number, спеціальне значення з стандарту чисел із рухомою крапкою.

Перший наслідок помітний одразу: стовпець із пропуском не може лишитись int64, бо NaN — це число з рухомою крапкою. Тому цілі перетворюються на дробові, і замість 3 у друку зʼявляється 3.0.

Другий наслідок дивніший. NaN не дорівнює нічому — навіть самому собі:

>>> float("nan") == float("nan") False

Це не помилка реалізації, а свідоме рішення стандарту: NaN означає «результат невизначений», а два невідомі значення не можна оголосити рівними. Практичний висновок жорсткий: шукати пропуски порівнянням не можна. Запис таблиця[таблиця["сума"] == np.nan] завжди дасть порожню таблицю — і не поскаржиться. Для цього є окремий метод:

>>> з_дірками.isna().sum() напій 0 кількість 2 сума 2 dtype: int64

Читається так: isna() робить таблицю з True там, де пропуск, а sum() рахує True як одиниці — виходить кількість дірок у кожному стовпці. Цей рядок варто писати одразу після read_csv, разом із info().

Далі — рішення, і воно завжди змістовне, а не технічне. Варіантів два.

Схема 3 · Що роблять dropna і fillna

Одна таблиця з дірками, три погляди на неї — і середнє, яке в кожному випадку різне.

Дивись на нижній рядок із середніми. Ті самі дані дають 71.67, 62.50 і 43.00 — залежно від того, як повелися з двома порожніми клітинками. Обидва «прибирання» спотворюють відповідь, і кожне по-своєму: dropna() викинув ще й рядок 1, у якого сума була відома, а не було лише кількості; fillna(0) відверто бреше, бо нуль означає «продали на нуль гривень», а ми просто не знаємо суму. Заповнювати нулями можна лише там, де порожньо справді означає нуль — наприклад, у стовпці «повернень за день».

Викинути рядки. dropna() без аргументів прибирає рядок, де порожня хоч одна клітинка, — на нашій таблиці з пʼяти рядків лишається два. Зазвичай це занадто, тому вказують, які саме стовпці критичні:

з_дірками.dropna() # геть, якщо порожньо будь-де → лишилось 2 рядки з_дірками.dropna(subset=["сума"]) # геть, лише якщо немає суми → лишилось 3 рядки

Заповнити. fillna(значення) ставить на місце пропусків те, що скажеш. Нуль — найгірший вибір за замовчуванням, бо він тихо тягне середнє вниз. Чесніші варіанти: медіана стовпця (стійка до викидів), останнє відоме значення (ffill — доречно для показників у часі) або окрема категорія "невідомо" для текстових стовпців.

з_дірками["сума"].fillna(з_дірками["сума"].median()) з_дірками["напій"].fillna("невідомо") з_дірками.ffill() # взяти значення з попереднього рядка

І окремо про третій варіант, який часто найправильніший: не робити нічого. Згортки в pandas пропуски ігнорують самі. mean() ділить суму наявних значень на їхню кількість, а не на довжину стовпця, — тому 71.67 виходить і без жодного прибирання, і після dropna(subset=["сума"]). А от dropna() без аргументів дає вже 62.50: разом із зіпсованими рядками він викинув і той, де бракувало лише кількості. Витирати дірки треба тоді, коли далі йде операція, яка з ними не працює, — а не «про всяк випадок».

09 / ПорядокСортування й підрахунок

Сортують не за індексом, а за значенням стовпця:

>>> продажі.sort_values("сума", ascending=False).head(3) філія напій кількість сума 5 Парк Капучино 6 270 7 Вокзал Капучино 3 135 2 Вокзал Еспресо 5 125

Індекс поїхав разом із рядками — 5, 7, 2 — і це ще одна нагода згадати різницю loc й iloc. Сортувати можна за кількома стовпцями одразу, задавши для кожного свій напрямок:

продажі.sort_values(["філія", "сума"], ascending=[True, False])

Якщо потрібні тільки перші кілька, є коротший і швидший спосіб — продажі.nlargest(3, "сума"): він не сортує всю таблицю, а шукає три найбільші.

value_counts() відповідає на питання «яких значень скільки» — це groupby плюс count, згорнуті в один виклик:

>>> продажі["напій"].value_counts() напій Еспресо 3 Капучино 3 Чай 2 Name: count, dtype: int64 >>> продажі["напій"].value_counts(normalize=True) Еспресо 0.375 Капучино 0.375 Чай 0.250

Результат уже відсортований за спаданням. Це перший інструмент, яким варто дивитись на будь-який текстовий стовпець: він одразу показує і склад даних, і брудні дані — «Вокзал», «вокзал» і «Вокзал » з пробілом у кінці стануть трьома різними рядками.

10 / mergeДві таблиці разом

Дані рідко лежать в одному файлі. Поруч із журналом продажів є прайс:

ціни = pd.DataFrame({ "напій": ["Еспресо", "Капучино", "Чай", "Какао"], "ціна": [25, 45, 30, 55], })

merge зшиває дві таблиці за спільним стовпцем: до кожного рядка продажів підставляється ціна відповідного напою.

>>> pd.merge(продажі, ціни, on="напій").head(3) філія напій кількість сума ціна 0 Центр Еспресо 3 75 25 1 Центр Капучино 2 90 45 2 Вокзал Еспресо 5 125 25

Головне питання при зшиванні — що робити з тим, чому не знайшлося пари. Тут «Какао» є в прайсі, але його ніхто не купив. За замовчуванням how="inner": лишаються тільки рядки, ключ яких є в обох таблицях, — і «Какао» просто зникає, мовчки. Саме так і губляться дані у звітах.

howщо лишитьсярядків у нас
"inner"тільки спільні ключі8 — «Какао» зникло
"left"усі рядки лівої таблиці8 — «Какао» зникло
"outer"усі ключі з обох таблиць9 — «Какао» з NaN

Практичне правило: після кожного merge дивись на shape. Рядків стало менше, ніж було, — щось випало через inner. Рядків стало більше — ключ виявився неунікальним, і один рядок зліва зустрів кілька справа. Обидві несподіванки трапляються постійно й не викликають жодної помилки.

11 / СтильЛанцюжок замість змінних

Кожна операція в pandas повертає нову таблицю, а не змінює наявну. Тому природний стиль тут — не послідовність присвоєнь, а ланцюжок:

# так пишуть спочатку крок1 = продажі[продажі["напій"] == "Капучино"] крок2 = крок1.groupby("філія")["сума"] крок3 = крок2.mean() результат = крок3.sort_values(ascending=False) # так пишуть далі результат = (продажі [продажі["напій"] == "Капучино"] .groupby("філія")["сума"] .mean() .sort_values(ascending=False))

Справа не в красі. Проміжні змінні крок1, крок2 залишаються жити після обчислення, і за півсотні рядків уже незрозуміло, котра з них актуальна. Ланцюжок не лишає нічого, крім результату, а зовнішні дужки дозволяють розбити його на рядки й читати згори вниз як список дій. Вставити новий крок посередині — один рядок; викинути — теж один.

Схема 4 · Одне завдання, дві мови

Середня сума чека по філіях, найбільша зверху. Ліворуч — csv і словники з теми 21, праворуч — pandas.

Результат однаковий до копійки — різниця в тому, що доведеться змінити завтра. Щоб додати відсів пропусків, у лівому стовпці треба вставити if усередину циклу й не забути, що він міняє й лічильник; у правому це один рядок .dropna(subset=["сума"]) у ланцюжку. Ліва версія при цьому не «погана» — вона просто описує шлях, а не результат, і тому росте разом із питанням.

12 / ГлибшеДля тих, кому мало

Звідки береться SettingWithCopyWarning. Найзагадковіше попередження pandas виглядає так:

дорогі = продажі[продажі["сума"] > 100] дорогі["знижка"] = дорогі["сума"] * 0.1 # SettingWithCopyWarning: A value is trying to be set on a copy # of a slice from a DataFrame

Корінь тут той самий, що у вигляді проти копії в темі 33. Зріз масиву NumPy — це вікно в чужу памʼять, і запис у нього змінює оригінал. Але фільтрація за маскою вікном бути не може: відібрані рядки не лежать у памʼяті підряд. Тому pandas іноді повертає вигляд, а іноді копію — залежно від того, який саме зріз вийшов, — і сам не знає напевно, чим виявився дорогі. Отже, не знає й того, чи потрапить нова колонка в продажі. Попередження означає буквально: «я не можу пообіцяти, що з цього вийде».

Лікується однозначністю. Хочеш окрему таблицю — скажи це прямо:

дорогі = продажі[продажі["сума"] > 100].copy() # свідома копія продажі.loc[продажі["сума"] > 100, "знижка"] = ... # свідома правка оригіналу

У сучасних версіях цю двозначність прибирають на рівні бібліотеки: режим Copy-on-Write, який у pandas 3.0 став єдиним, робить так, що результат фільтрації завжди поводиться як копія. Попередження зникає разом із непевністю — але звичка писати .copy() явно лишається корисною, бо робить намір видимим для читача коду.

Чому apply рядок за рядком повільний. Спокуса написати таблиця.apply(функція, axis=1) велика: виглядає як звичайний цикл, тільки коротше. Ним він і є. На кожен рядок pandas створює окремий обʼєкт Series, викликає твою пітонівську функцію, забирає результат — і так мільйон разів. Замір на 200 000 рядках:

таблиця.apply(lambda р: р["a"] * р["b"], axis=1) # 1.7 с таблиця["a"] * таблиця["b"] # 0.0006 с

Різниця майже в три тисячі разів, і береться вона не з оптимізації, а з рівня, на якому виконується робота: множення двох стовпців іде одним циклом усередині C над суцільною памʼяттю, без створення пітонівських обʼєктів. Правило: якщо дію можна виразити арифметикою над стовпцями, np.where, map по словнику чи методами .str, — apply(axis=1) не потрібен. Він лишається для випадків, коли інакше ніяк, і тоді його вартість треба просто визнати.

query: умова як текст. Для довгих фільтрів є запис без дужок і без повторення імені таблиці:

>>> продажі.query("сума > 100 and філія == 'Вокзал'") філія напій кількість сума 2 Вокзал Еспресо 5 125 3 Вокзал Чай 4 120 7 Вокзал Капучино 3 135

Зверни увагу на and — тут він раптом дозволений. Суперечності немає: всередині лапок лежить не вираз Python, а рядок, який pandas розбирає власним аналізатором і перетворює на ті самі поелементні операції. Python цей текст не бачить взагалі. Зворотний бік — помилку в такому рядку редактор не підсвітить, а звертання до змінної ззовні вимагає позначки: query("сума > @поріг").

13 / ПідсумокЩо забрати з теми

Головна ідея одна: таблиця — це один обʼєкт, а не список рядків. Як тільки перестаєш писати цикл по рядках, зникає і більшість помилок, які в цьому циклі робились.

Тепер у нас є таблиця, з якої можна швидко дістати будь-яке число. Але звіт із трьох чисел ніхто не читає очима, а звіт із трьохсот — тим паче. Дані треба побачити: чи росте виторг, чи є викиди, як розподілені суми чеків. У темі 35 ми навчимося малювати — і почнемо саме з того місця, де зупинились: groupby віддасть числа, а matplotlib перетворить їх на картинку.

Далі в практиці. У practice.ipynb ти збереш DataFrame руками, запишеш його у справжній CSV у тимчасовій теці й прочитаєш назад через read_csv; переконаєшся assert-ом, що loc і iloc на відфільтрованій таблиці дають різні рядки; спіймаєш ValueError від and і KeyError від loc; порахуєш groupby двома способами — бібліотечним і руками через словник — і доведеш, що числа збігаються; подивишся, як dropna і fillna міняють середнє; і прибереш за собою тимчасову теку.

Далі в темі

Теорію прочитано. Тепер закріпи її на практиці.