Практика · Pandas: таблиці¶
Лекція: lecture.html · Тест: quiz.html · ДЗ: homework.md
Наскрізний приклад той самий, що в лекції, — журнал продажів кавʼярні: вісім чеків, три філії, три напої. Він маленький навмисне: усі числа можна перевірити в голові, і саме тому видно, що бібліотека не робить нічого чарівного.
Що зробимо:
- зберемо
DataFrameруками й роздивимось його три складові — індекс, стовпці, значення; - запишемо його у справжній CSV у тимчасовій теці й прочитаємо назад через
read_csv(мережа не потрібна — файл ми створюємо самі); - побачимо, що буває, коли забути про роздільник
;; - відфільтруємо таблицю й доведемо
assert-ом, щоlocіilocпісля цього дають різні рядки; - упіймаємо живцем
KeyErrorвідlocіValueErrorвідand; - порахуємо
groupbyдвічі — бібліотечно й руками через словник — і переконаємось, що числа збігаються до копійки; - подивимось, як
dropnaіfillnaміняють те саме середнє; - зшиємо дві таблиці через
mergeі прибиремо за собою тимчасову теку.
0 · Що нам знадобиться¶
Три бібліотеки зі стандартної поставки (pathlib, tempfile, shutil) і дві зовнішні.
numpy тут потрібен рівно для одного — щоб мати під рукою np.nan.
import shutil
import tempfile
from pathlib import Path
import numpy as np
import pandas as pd
print("pandas :", pd.__version__)
print("numpy :", np.__version__)
pandas : 2.3.3 numpy : 2.5.1
1 · DataFrame зі словника¶
Найпростіший спосіб зібрати таблицю руками: ключ словника — назва стовпця, значення — список. Усі списки мусять бути однакової довжини, інакше pandas відмовиться будувати таблицю.
продажі = pd.DataFrame({
"філія": ["Центр", "Центр", "Вокзал", "Вокзал", "Центр", "Парк", "Парк", "Вокзал"],
"напій": ["Еспресо", "Капучино", "Еспресо", "Чай", "Чай", "Капучино", "Еспресо", "Капучино"],
"кількість": [3, 2, 5, 4, 1, 6, 2, 3],
"сума": [75, 90, 125, 120, 30, 270, 50, 135],
})
print(продажі)
філія напій кількість сума 0 Центр Еспресо 3 75 1 Центр Капучино 2 90 2 Вокзал Еспресо 5 125 3 Вокзал Чай 4 120 4 Центр Чай 1 30 5 Парк Капучино 6 270 6 Парк Еспресо 2 50 7 Вокзал Капучино 3 135
2 · Три складові: індекс, назви стовпців, значення¶
Стовпчик 0…7 ліворуч — не дані, а індекс: окремий обʼєкт із міткою для кожного
рядка. Поки таблицю щойно створено, мітки збігаються з позиціями — і саме тому
різниці між loc та iloc тут ще не видно.
print("індекс :", продажі.index)
print("стовпці :", list(продажі.columns))
print("розмір :", продажі.shape, "— рядків × стовпців")
print()
print("типи стовпців:")
print(продажі.dtypes)
# shape — властивість, а не метод: дужок після неї немає
assert продажі.shape == (8, 4), "таблиця мала б мати 8 рядків і 4 стовпці"
print()
print("✅ вісім рядків, чотири стовпці")
індекс : RangeIndex(start=0, stop=8, step=1) стовпці : ['філія', 'напій', 'кількість', 'сума'] розмір : (8, 4) — рядків × стовпців типи стовпців: філія object напій object кількість int64 сума int64 dtype: object ✅ вісім рядків, чотири стовпці
Тепер візьмемо один стовпець. Це Series — послідовність значень разом із
індексом. Саме завдяки збереженому індексу результат порівняння згодом можна буде
підставити назад у таблицю.
суми = продажі["сума"]
print(суми)
print()
print("тип обʼєкта :", type(суми).__name__)
print("індекс той самий :", list(суми.index) == list(продажі.index))
print("голі значення :", суми.values, "— уже звичайний масив NumPy")
assert isinstance(суми, pd.Series), "один стовпець має бути Series"
assert list(суми.index) == list(продажі.index), "Series зберігає індекс таблиці"
print()
print("✅ Series = значення + індекс")
0 75 1 90 2 125 3 120 4 30 5 270 6 50 7 135 Name: сума, dtype: int64 тип обʼєкта : Series індекс той самий : True голі значення : [ 75 90 125 120 30 270 50 135] — уже звичайний масив NumPy ✅ Series = значення + індекс
3 · Записуємо справжній CSV у тимчасову теку¶
Щоб працювати з read_csv по-справжньому, потрібен файл. Створимо його самі —
у тимчасовій теці, яку наприкінці приберемо. Жодних завантажень із мережі.
тека = Path(tempfile.mkdtemp(prefix="pandas-практика-"))
файл_продажів = тека / "продажі.csv"
# index=False — індекс тут службовий, у файлі він зайвий стовпець
продажі.to_csv(файл_продажів, index=False, encoding="utf-8")
print("тека :", тека)
print()
print("що всередині файлу:")
print(файл_продажів.read_text(encoding="utf-8"))
тека : /tmp/pandas-практика-e56z5sv_ що всередині файлу: філія,напій,кількість,сума Центр,Еспресо,3,75 Центр,Капучино,2,90 Вокзал,Еспресо,5,125 Вокзал,Чай,4,120 Центр,Чай,1,30 Парк,Капучино,6,270 Парк,Еспресо,2,50 Вокзал,Капучино,3,135
Файл — звичайний текст із комами, точно такий, як у темі 21. Прочитаємо його назад і переконаємось, що таблиця відновилась байт у байт.
з_файлу = pd.read_csv(файл_продажів, encoding="utf-8")
print(з_файлу)
print()
print("типи після читання:")
print(з_файлу.dtypes)
# equals порівнює і значення, і типи, і індекс — суворіше за ==
assert з_файлу.equals(продажі), "таблиця з файлу мала б збігтися з початковою"
print()
print("✅ read_csv відновив таблицю повністю — разом із типами int64")
філія напій кількість сума 0 Центр Еспресо 3 75 1 Центр Капучино 2 90 2 Вокзал Еспресо 5 125 3 Вокзал Чай 4 120 4 Центр Чай 1 30 5 Парк Капучино 6 270 6 Парк Еспресо 2 50 7 Вокзал Капучино 3 135 типи після читання: філія object напій object кількість int64 сума int64 dtype: object ✅ read_csv відновив таблицю повністю — разом із типами int64
4 · Коли роздільник не кома¶
Український Excel зберігає CSV із крапкою з комою, бо кому він тримає для дробових чисел. Зробимо такий файл самі — і подивимось, що станеться, якщо про це не сказати.
з_ціною = продажі.copy()
з_ціною["ціна_за_шт"] = з_ціною["сума"] / з_ціною["кількість"]
файл_укр = тека / "продажі-укр.csv"
з_ціною.to_csv(файл_укр, sep=";", decimal=",", index=False, encoding="utf-8")
print(файл_укр.read_text(encoding="utf-8"))
філія;напій;кількість;сума;ціна_за_шт Центр;Еспресо;3;75;25,0 Центр;Капучино;2;90;45,0 Вокзал;Еспресо;5;125;25,0 Вокзал;Чай;4;120;30,0 Центр;Чай;1;30;30,0 Парк;Капучино;6;270;45,0 Парк;Еспресо;2;50;25,0 Вокзал;Капучино;3;135;45,0
# спершу так, як зробив би неуважний читач — без жодних аргументів
наївно = pd.read_csv(файл_укр, encoding="utf-8")
# а тепер із діалектом, який справді використано у файлі
правильно = pd.read_csv(файл_укр, sep=";", decimal=",", encoding="utf-8")
print("без аргументів :", наївно.shape, "— увесь рядок став ОДНИМ стовпцем")
print("із діалектом :", правильно.shape)
print()
print(правильно.head(3))
print()
print("тип «ціна_за_шт» :", правильно["ціна_за_шт"].dtype)
assert наївно.shape[1] == 1, "без sep=';' pandas не знайде жодного роздільника"
assert правильно.shape == (8, 5), "з правильним діалектом мало вийти 8×5"
assert правильно["ціна_за_шт"].dtype == "float64", "decimal=',' мав дати справжні дробові"
print()
print("✅ помилки не буде — буде тихо зіпсована таблиця, якщо не сказати про діалект")
без аргументів : (8, 1) — увесь рядок став ОДНИМ стовпцем
із діалектом : (8, 5)
філія напій кількість сума ціна_за_шт
0 Центр Еспресо 3 75 25.0
1 Центр Капучино 2 90 45.0
2 Вокзал Еспресо 5 125 25.0
тип «ціна_за_шт» : float64
✅ помилки не буде — буде тихо зіпсована таблиця, якщо не сказати про діалект
5 · Перший погляд на незнайому таблицю¶
Чотири виклики, з яких варто починати завжди: head, shape, info, describe.
Найкорисніший — info: він одразу показує і пропуски, і типи.
print("── head(3) ──")
print(з_файлу.head(3))
print()
print("── info() ──")
з_файлу.info()
── head(3) ──
філія напій кількість сума
0 Центр Еспресо 3 75
1 Центр Капучино 2 90
2 Вокзал Еспресо 5 125
── 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
зведення = з_файлу.describe()
print(зведення)
print()
середнє = зведення.loc["mean", "сума"]
медіана = зведення.loc["50%", "сума"]
print(f"середнє {середнє:.3f} проти медіани {медіана:.3f}")
print("середнє вище — його тягне вгору один чек на 270")
assert середнє > медіана, "у цих даних середнє мало б бути більшим за медіану"
print()
print("✅ розходження середнього й медіани — перший сигнал про викид")
кількість сума 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 середнє 111.875 проти медіани 105.000 середнє вище — його тягне вгору один чек на 270 ✅ розходження середнього й медіани — перший сигнал про викид
6 · Фільтр — це маска з True і False¶
Порівняння застосовується до всього стовпця одразу й дає стовпець відповідей тієї самої довжини. Цю маску й підставляють у квадратні дужки таблиці.
маска = продажі["сума"] > 100
print(маска)
print()
print("тип маски :", маска.dtype)
print("скільки True :", маска.sum(), "— True рахується як одиниця")
0 False 1 False 2 True 3 True 4 False 5 True 6 False 7 True Name: сума, dtype: bool тип маски : bool скільки True : 4 — True рахується як одиниця
дорогі = продажі[маска]
print(дорогі)
print()
print("індекс результату :", list(дорогі.index))
print("позиції ж лишились:", list(range(len(дорогі))))
# ось воно, головне: мітки поїхали разом із рядками з початкової таблиці
assert list(дорогі.index) == [2, 3, 5, 7], "фільтр зберігає початкові мітки рядків"
assert len(дорогі) == 4
print()
print("✅ у таблиці зʼявились дві різні системи координат")
філія напій кількість сума 2 Вокзал Еспресо 5 125 3 Вокзал Чай 4 120 5 Парк Капучино 6 270 7 Вокзал Капучино 3 135 індекс результату : [2, 3, 5, 7] позиції ж лишились: [0, 1, 2, 3] ✅ у таблиці зʼявились дві різні системи координат
7 · loc і iloc на одному ключі¶
Ключ 2 є і серед міток, і серед позицій. Тому обидва звертання спрацюють —
і повернуть різні рядки. Помилки не буде: у звіті просто виявиться не той продаж.
за_міткою = дорогі.loc[2] # рядок, у якого мітка дорівнює 2
за_позицією = дорогі.iloc[2] # третій рядок зверху, рахуючи з нуля
print("── дорогі.loc[2] ──")
print(за_міткою)
print()
print("── дорогі.iloc[2] ──")
print(за_позицією)
print()
print(f"loc дав суму {за_міткою['сума']}, iloc — {за_позицією['сума']}")
assert за_міткою["сума"] == 125, "мітка 2 — це продаж Еспресо на Вокзалі"
assert за_позицією["сума"] == 270, "позиція 2 — це третій рядок, Капучино в Парку"
assert за_міткою["сума"] != за_позицією["сума"], "на цій таблиці вони мусять розходитись"
print()
print("✅ той самий ключ, два різні рядки, жодного попередження")
── дорогі.loc[2] ── філія Вокзал напій Еспресо кількість 5 сума 125 Name: 2, dtype: object ── дорогі.iloc[2] ── філія Парк напій Капучино кількість 6 сума 270 Name: 5, dtype: object loc дав суму 125, iloc — 270 ✅ той самий ключ, два різні рядки, жодного попередження
А тепер зворотний бік. Мітки 0 в цій таблиці вже немає — вона лишилась серед
відкинутих рядків. loc про це скаже вголос:
дорогі.loc[0]
--------------------------------------------------------------------------- KeyError Traceback (most recent call last) File ~/.local/lib/python3.12/site-packages/pandas/core/indexes/base.py:3812, in Index.get_loc(self, key) 3811 try: -> 3812 return self._engine.get_loc(casted_key) 3813 except KeyError as err: File pandas/_libs/index.pyx:167, in pandas._libs.index.IndexEngine.get_loc() File pandas/_libs/index.pyx:196, in pandas._libs.index.IndexEngine.get_loc() File pandas/_libs/hashtable_class_helper.pxi:2606, in pandas._libs.hashtable.Int64HashTable.get_item() File pandas/_libs/hashtable_class_helper.pxi:2630, in pandas._libs.hashtable.Int64HashTable.get_item() KeyError: 0 The above exception was the direct cause of the following exception: KeyError Traceback (most recent call last) Cell In[14], line 1 ----> 1 дорогі.loc[0] File ~/.local/lib/python3.12/site-packages/pandas/core/indexing.py:1192, in _LocationIndexer.__getitem__(self, key) 1190 maybe_callable = com.apply_if_callable(key, self.obj) 1191 maybe_callable = self._check_deprecated_callable_usage(key, maybe_callable) -> 1192 return self._getitem_axis(maybe_callable, axis=axis) File ~/.local/lib/python3.12/site-packages/pandas/core/indexing.py:1432, in _LocIndexer._getitem_axis(self, key, axis) 1430 # fall thru to straight lookup 1431 self._validate_key(key, axis) -> 1432 return self._get_label(key, axis=axis) File ~/.local/lib/python3.12/site-packages/pandas/core/indexing.py:1382, in _LocIndexer._get_label(self, label, axis) 1380 def _get_label(self, label, axis: AxisInt): 1381 # GH#5567 this will fail if the label is not present in the axis. -> 1382 return self.obj.xs(label, axis=axis) File ~/.local/lib/python3.12/site-packages/pandas/core/generic.py:4323, in NDFrame.xs(self, key, axis, level, drop_level) 4321 new_index = index[loc] 4322 else: -> 4323 loc = index.get_loc(key) 4325 if isinstance(loc, np.ndarray): 4326 if loc.dtype == np.bool_: File ~/.local/lib/python3.12/site-packages/pandas/core/indexes/base.py:3819, in Index.get_loc(self, key) 3814 if isinstance(casted_key, slice) or ( 3815 isinstance(casted_key, abc.Iterable) 3816 and any(isinstance(x, slice) for x in casted_key) 3817 ): 3818 raise InvalidIndexError(key) -> 3819 raise KeyError(key) from err 3820 except TypeError: 3821 # If we have a listlike key, _check_indexing_error will raise 3822 # InvalidIndexError. Otherwise we fall through and re-raise 3823 # the TypeError. 3824 self._check_indexing_error(key) KeyError: 0
8 · Чому and не працює з умовами¶
Оператор and хоче спитати «чи лівий вираз правдивий» — а зліва вісім відповідей
одразу. Одну істину з них не зробити, тому pandas відмовляється вгадувати.
продажі[(продажі["сума"] > 100) and (продажі["філія"] == "Вокзал")]
--------------------------------------------------------------------------- ValueError Traceback (most recent call last) /tmp/ipykernel_2918375/3972995794.py in ?() ----> 1 продажі[(продажі["сума"] > 100) and (продажі["філія"] == "Вокзал")] ~/.local/lib/python3.12/site-packages/pandas/core/generic.py in ?(self) 1578 @final 1579 def __nonzero__(self) -> NoReturn: -> 1580 raise ValueError( 1581 f"The truth value of a {type(self).__name__} is ambiguous. " 1582 "Use a.empty, a.bool(), a.item(), a.any() or a.all()." 1583 ) ValueError: The truth value of a Series is ambiguous. Use a.empty, a.bool(), a.item(), a.any() or a.all().
Потрібні поелементні &, |, ~ — і кожна умова у власних дужках, бо &
звʼязує сильніше за > і ==.
вокзальні_дорогі = продажі[(продажі["сума"] > 100) & (продажі["філія"] == "Вокзал")]
хоч_щось = продажі[(продажі["сума"] > 100) | (продажі["напій"] == "Капучино")]
не_центр = продажі[~(продажі["філія"] == "Центр")]
print("── & : і дорого, і Вокзал ──")
print(вокзальні_дорогі)
print()
print("рядків під | :", len(хоч_щось))
print("рядків під ~ :", len(не_центр))
assert list(вокзальні_дорогі.index) == [2, 3, 7]
assert len(хоч_щось) == 5, "дорогих 4 плюс капучино за 90 — разом 5"
assert len(не_центр) == 5, "не-Центр: Вокзал 3 + Парк 2"
print()
print("✅ три оператори, три різні вибірки")
── & : і дорого, і Вокзал ──
філія напій кількість сума
2 Вокзал Еспресо 5 125
3 Вокзал Чай 4 120
7 Вокзал Капучино 3 135
рядків під | : 5
рядків під ~ : 5
✅ три оператори, три різні вибірки
9 · Нові стовпці без жодного циклу¶
Праворуч від знака рівності — вираз над цілими стовпцями. Ділення відбувається порядково: перший елемент на перший, другий на другий.
продажі["ціна_за_шт"] = продажі["сума"] / продажі["кількість"]
продажі["дорогий"] = продажі["сума"] > 100
print(продажі)
print()
# ціна за штуку мусить збігтися з прайсом кавʼярні
прайс = {"Еспресо": 25.0, "Капучино": 45.0, "Чай": 30.0}
очікувано = продажі["напій"].map(прайс)
assert (продажі["ціна_за_шт"] == очікувано).all(), "ділення стовпців дало не ті ціни"
print("✅ ціна за штуку збіглася з прайсом для всіх восьми чеків")
філія напій кількість сума ціна_за_шт дорогий 0 Центр Еспресо 3 75 25.0 False 1 Центр Капучино 2 90 45.0 False 2 Вокзал Еспресо 5 125 25.0 True 3 Вокзал Чай 4 120 30.0 True 4 Центр Чай 1 30 30.0 False 5 Парк Капучино 6 270 45.0 True 6 Парк Еспресо 2 50 25.0 False 7 Вокзал Капучино 3 135 45.0 True ✅ ціна за штуку збіглася з прайсом для всіх восьми чеків
10 · groupby: розділити → застосувати → зібрати¶
Ключова операція теми. Питання «скільки виторгу дала кожна філія» — один вираз.
виторг = продажі.groupby("філія")["сума"].sum()
середній_чек = продажі.groupby("філія")["сума"].mean()
print("── виторг по філіях ──")
print(виторг)
print()
print("── середній чек ──")
print(середній_чек.round(2))
print()
print("назви філій стали індексом:", list(виторг.index))
print("тому далі — .loc['Вокзал'] =", виторг.loc["Вокзал"])
── виторг по філіях ── філія Вокзал 380 Парк 320 Центр 195 Name: сума, dtype: int64 ── середній чек ── філія Вокзал 126.67 Парк 160.00 Центр 65.00 Name: сума, dtype: float64 назви філій стали індексом: ['Вокзал', 'Парк', 'Центр'] тому далі — .loc['Вокзал'] = 380
# рахуємо руками: ключ — філія, значення — накопичена сума
виторг_руками = {}
for філія, сума in zip(продажі["філія"], продажі["сума"]):
виторг_руками[філія] = виторг_руками.get(філія, 0) + сума
print("руками :", виторг_руками)
print("pandas :", виторг.to_dict())
print()
assert виторг.to_dict() == виторг_руками, "groupby має давати те саме, що й цикл"
assert sum(виторг_руками.values()) == 895, "увесь виторг кавʼярні — 895"
print("✅ збігається до копійки: 380 + 320 + 195 = 895")
руками : {'Центр': 195, 'Вокзал': 380, 'Парк': 320}
pandas : {'Вокзал': 380, 'Парк': 320, 'Центр': 195}
✅ збігається до копійки: 380 + 320 + 195 = 895
Коли потрібні різні згортки різних стовпців — є agg. Кожен рядок задає
одну колонку майбутнього звіту.
звіт = продажі.groupby("філія").agg(
чеків=("сума", "count"),
виторг=("сума", "sum"),
середній=("сума", "mean"),
напоїв=("напій", "nunique"),
)
print(звіт.round(2))
print()
assert звіт.loc["Вокзал", "чеків"] == 3
assert звіт.loc["Парк", "виторг"] == 320
print("✅ один виклик — цілий звіт")
чеків виторг середній напоїв філія Вокзал 3 380 126.67 3 Парк 2 320 160.00 2 Центр 3 195 65.00 3 ✅ один виклик — цілий звіт
11 · Пропуски: NaN, isna, dropna, fillna¶
Зробимо маленьку таблицю з дірками — рівно таку, як на схемі 3 в лекції.
Зверни увагу на типи: стовпець із пропуском не може лишитись int64,
бо NaN — це число з рухомою крапкою.
з_дірками = pd.DataFrame({
"напій": ["Еспресо", "Капучино", "Чай", "Еспресо", "Капучино"],
"кількість": [3, np.nan, 4, 2, np.nan],
"сума": [75, 90, np.nan, 50, np.nan],
})
print(з_дірками)
print()
print("типи:", dict(з_дірками.dtypes.astype(str)))
print()
print("── скільки дірок у кожному стовпці ──")
print(з_дірками.isna().sum())
напій кількість сума
0 Еспресо 3.0 75.0
1 Капучино NaN 90.0
2 Чай 4.0 NaN
3 Еспресо 2.0 50.0
4 Капучино NaN NaN
типи: {'напій': 'object', 'кількість': 'float64', 'сума': 'float64'}
── скільки дірок у кожному стовпці ──
напій 0
кількість 2
сума 2
dtype: int64
Чому не можна шукати пропуски порівнянням? Бо NaN не дорівнює нічому —
навіть самому собі. Це рішення стандарту чисел із рухомою крапкою:
«невідомо» не можна оголосити рівним «невідомо».
print("float('nan') == float('nan') :", float("nan") == float("nan"))
print()
через_порівняння = з_дірками[з_дірками["сума"] == np.nan]
через_isna = з_дірками[з_дірками["сума"].isna()]
print("знайдено порівнянням :", len(через_порівняння), "рядків — і жодної скарги")
print("знайдено через isna :", len(через_isna), "рядки")
print()
print(через_isna)
assert len(через_порівняння) == 0, "порівняння з NaN завжди дає порожню вибірку"
assert list(через_isna.index) == [2, 4]
print()
print("✅ пропуски шукають тільки через isna()")
float('nan') == float('nan') : False
знайдено порівнянням : 0 рядків — і жодної скарги
знайдено через isna : 2 рядки
напій кількість сума
2 Чай 4.0 NaN
4 Капучино NaN NaN
✅ пропуски шукають тільки через isna()
Тепер головне: те саме середнє, порахуване трьома способами. Числа різні — і кожне з них про щось своє.
як_є = з_дірками["сума"].mean() # згортка ігнорує дірки
після_dropna = з_дірками.dropna()["сума"].mean() # рядок геть, якщо порожньо будь-де
після_fillna = з_дірками["сума"].fillna(0).mean() # нулі на місце пропусків
print(f"як є : {як_є:.2f} (75 + 90 + 50) / 3")
print(f"після dropna(): {після_dropna:.2f} (75 + 50) / 2 — рядок 1 теж зник")
print(f"після fillna(0): {після_fillna:.2f} (75 + 90 + 0 + 50 + 0) / 5")
print()
print("рядків після dropna() :", len(з_дірками.dropna()))
print("рядків після dropna(subset=['сума']):", len(з_дірками.dropna(subset=["сума"])))
assert round(як_є, 2) == 71.67
assert round(після_dropna, 2) == 62.50
assert round(після_fillna, 2) == 43.00
assert round(з_дірками.dropna(subset=["сума"])["сума"].mean(), 2) == 71.67
print()
print("✅ ті самі дані дають 71.67, 62.50 і 43.00 — вибір рішення міняє відповідь")
як є : 71.67 (75 + 90 + 50) / 3 після dropna(): 62.50 (75 + 50) / 2 — рядок 1 теж зник після fillna(0): 43.00 (75 + 90 + 0 + 50 + 0) / 5 рядків після dropna() : 2 рядків після dropna(subset=['сума']): 3 ✅ ті самі дані дають 71.67, 62.50 і 43.00 — вибір рішення міняє відповідь
12 · Сортування й value_counts¶
Сортують за значенням стовпця, а не за індексом — тому мітки після сортування знову перестають збігатися з позиціями.
топ = продажі.sort_values("сума", ascending=False).head(3)
print(топ[["філія", "напій", "сума"]])
print()
print("індекс трьох найбільших :", list(топ.index))
print()
print("── скільки чеків на кожен напій ──")
print(продажі["напій"].value_counts())
print()
print("── те саме в частках ──")
print(продажі["напій"].value_counts(normalize=True))
assert list(топ.index) == [5, 7, 2], "після сортування мітки їдуть разом із рядками"
assert продажі["напій"].value_counts()["Еспресо"] == 3
print()
print("✅ value_counts одразу показує і склад даних, і брудні написання")
філія напій сума 5 Парк Капучино 270 7 Вокзал Капучино 135 2 Вокзал Еспресо 125 індекс трьох найбільших : [5, 7, 2] ── скільки чеків на кожен напій ── напій Еспресо 3 Капучино 3 Чай 2 Name: count, dtype: int64 ── те саме в частках ── напій Еспресо 0.375 Капучино 0.375 Чай 0.250 Name: proportion, dtype: float64 ✅ value_counts одразу показує і склад даних, і брудні написання
13 · Дві таблиці разом: merge¶
Поруч із журналом продажів є прайс — і в ньому є напій, якого ніхто не купив. Головне питання при зшиванні: що робити з тим, чому не знайшлося пари.
ціни = pd.DataFrame({
"напій": ["Еспресо", "Капучино", "Чай", "Какао"],
"ціна": [25, 45, 30, 55],
})
спільне = pd.merge(продажі, ціни, on="напій") # how="inner" за замовчуванням
усе = pd.merge(продажі, ціни, on="напій", how="outer")
print("inner :", спільне.shape, "— «Какао» зникло мовчки")
print("outer :", усе.shape, "— «Какао» лишилось із NaN замість продажу")
print()
print(усе[усе["філія"].isna()])
assert len(спільне) == 8, "inner лишає тільки напої, які справді продавали"
assert len(усе) == 9, "outer додає рядок для Какао"
assert усе["сума"].isna().sum() == 1, "у Какао немає суми — там NaN"
print()
print("✅ після кожного merge дивись на shape: рядки могли і зникнути, і розмножитись")
inner : (8, 7) — «Какао» зникло мовчки outer : (9, 7) — «Какао» лишилось із NaN замість продажу філія напій кількість сума ціна_за_шт дорогий ціна 3 NaN Какао NaN NaN NaN NaN 55 ✅ після кожного merge дивись на shape: рядки могли і зникнути, і розмножитись
14 · Ланцюжок замість проміжних змінних¶
Кожна операція повертає нову таблицю, тому природний стиль — ланцюжок. Перевіримо, що він дає рівно те саме, що й розбивка на кроки.
# спосіб перший — крок за кроком
крок1 = продажі[продажі["напій"] == "Капучино"]
крок2 = крок1.groupby("філія")["сума"]
крок3 = крок2.mean()
покроково = крок3.sort_values(ascending=False)
# спосіб другий — той самий шлях одним виразом
ланцюжком = (продажі
[продажі["напій"] == "Капучино"]
.groupby("філія")["сума"]
.mean()
.sort_values(ascending=False))
print(ланцюжком.round(2))
print()
print("проміжних змінних у першому способі :", 3)
print("проміжних змінних у другому :", 0)
assert покроково.equals(ланцюжком), "ланцюжок мав би дати те саме"
assert ланцюжком.index[0] == "Парк", "найбільший середній чек по капучино — у Парку"
print()
print("✅ результат однаковий, а сміття в памʼяті — ні")
філія Парк 270.0 Вокзал 135.0 Центр 90.0 Name: сума, dtype: float64 проміжних змінних у першому способі : 3 проміжних змінних у другому : 0 ✅ результат однаковий, а сміття в памʼяті — ні
15 · Прибираємо за собою¶
Тимчасова тека — не сміття на диску користувача. Видаляємо її разом із файлами.
print("було у теці :", sorted(файл.name for файл in тека.iterdir()))
shutil.rmtree(тека)
print("тека існує :", тека.exists())
assert not тека.exists(), "тимчасову теку треба прибрати за собою"
print()
print("✅ прибрано")
було у теці : ['продажі-укр.csv', 'продажі.csv'] тека існує : False ✅ прибрано
Що далі — три завдання¶
🟢 Рівень 1¶
Додай у продажі стовпець частка, який показує, який відсоток усього виторгу
(895) дав кожен чек. Доведи двома assert-ами: (1) сума всіх часток дорівнює 100
з точністю до 0.01, (2) найбільша частка належить рядку з міткою 5.
🟡 Рівень 2¶
Порахуй середній чек по парах «філія + напій» через
groupby(["філія", "напій"]), поверни результат у звичайні стовпці через
reset_index() і відсортуй за спаданням. Поясни словами, чому в результаті
менше рядків, ніж 3 × 3 = 9.
🔴 Рівень 3¶
Напиши функцію моє_groupby(таблиця, ключ, стовпець), яка повертає той самий
результат, що й таблиця.groupby(ключ)[стовпець].sum(), але користується лише
циклом і словником. Доведи assert-ом, що вона збігається з pandas і на
продажі, і на таблиці з_дірками (підказка: подумай, що робити з NaN).
Розгорнуті умови з критеріями «зроблено» — у homework.md.