Практика · Pandas: таблиці¶

Лекція: lecture.html · Тест: quiz.html · ДЗ: homework.md

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

Що зробимо:

  1. зберемо DataFrame руками й роздивимось його три складові — індекс, стовпці, значення;
  2. запишемо його у справжній CSV у тимчасовій теці й прочитаємо назад через read_csv (мережа не потрібна — файл ми створюємо самі);
  3. побачимо, що буває, коли забути про роздільник ;;
  4. відфільтруємо таблицю й доведемо assert-ом, що loc і iloc після цього дають різні рядки;
  5. упіймаємо живцем KeyError від loc і ValueError від and;
  6. порахуємо groupby двічі — бібліотечно й руками через словник — і переконаємось, що числа збігаються до копійки;
  7. подивимось, як dropna і fillna міняють те саме середнє;
  8. зшиємо дві таблиці через merge і прибиремо за собою тимчасову теку.

0 · Що нам знадобиться¶

Три бібліотеки зі стандартної поставки (pathlib, tempfile, shutil) і дві зовнішні. numpy тут потрібен рівно для одного — щоб мати під рукою np.nan.

In [1]:
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 відмовиться будувати таблицю.

In [2]:
продажі = 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 тут ще не видно.

In [3]:
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 — послідовність значень разом із індексом. Саме завдяки збереженому індексу результат порівняння згодом можна буде підставити назад у таблицю.

In [4]:
суми = продажі["сума"]

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 по-справжньому, потрібен файл. Створимо його самі — у тимчасовій теці, яку наприкінці приберемо. Жодних завантажень із мережі.

In [5]:
тека = 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. Прочитаємо його назад і переконаємось, що таблиця відновилась байт у байт.

In [6]:
з_файлу = 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 із крапкою з комою, бо кому він тримає для дробових чисел. Зробимо такий файл самі — і подивимось, що станеться, якщо про це не сказати.

In [7]:
з_ціною = продажі.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

In [8]:
# спершу так, як зробив би неуважний читач — без жодних аргументів
наївно = 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: він одразу показує і пропуски, і типи.

In [9]:
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
In [10]:
зведення = з_файлу.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¶

Порівняння застосовується до всього стовпця одразу й дає стовпець відповідей тієї самої довжини. Цю маску й підставляють у квадратні дужки таблиці.

In [11]:
маска = продажі["сума"] > 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 рахується як одиниця
In [12]:
дорогі = продажі[маска]
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 є і серед міток, і серед позицій. Тому обидва звертання спрацюють — і повернуть різні рядки. Помилки не буде: у звіті просто виявиться не той продаж.

In [13]:
за_міткою   = дорогі.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 про це скаже вголос:

In [14]:
дорогі.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 відмовляється вгадувати.

In [15]:
продажі[(продажі["сума"] > 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().

Потрібні поелементні &, |, ~ — і кожна умова у власних дужках, бо & звʼязує сильніше за > і ==.

In [16]:
вокзальні_дорогі = продажі[(продажі["сума"] > 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 · Нові стовпці без жодного циклу¶

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

In [17]:
продажі["ціна_за_шт"] = продажі["сума"] / продажі["кількість"]
продажі["дорогий"]    = продажі["сума"] > 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: розділити → застосувати → зібрати¶

Ключова операція теми. Питання «скільки виторгу дала кожна філія» — один вираз.

In [18]:
виторг = продажі.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

Перевірка: те саме руками¶

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

In [19]:
# рахуємо руками: ключ — філія, значення — накопичена сума
виторг_руками = {}
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. Кожен рядок задає одну колонку майбутнього звіту.

In [20]:
звіт = продажі.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 — це число з рухомою крапкою.

In [21]:
з_дірками = 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 не дорівнює нічому — навіть самому собі. Це рішення стандарту чисел із рухомою крапкою: «невідомо» не можна оголосити рівним «невідомо».

In [22]:
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()

Тепер головне: те саме середнє, порахуване трьома способами. Числа різні — і кожне з них про щось своє.

In [23]:
як_є        = з_дірками["сума"].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¶

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

In [24]:
топ = продажі.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¶

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

In [25]:
ціни = 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 · Ланцюжок замість проміжних змінних¶

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

In [26]:
# спосіб перший — крок за кроком
крок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 · Прибираємо за собою¶

Тимчасова тека — не сміття на диску користувача. Видаляємо її разом із файлами.

In [27]:
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.