До сих пор мы работали с таблицей shop_orders.csv, где в каждой строке уже записаны и город покупателя,
и название товара, и цена. Настоящие базы данных устроены иначе: сведения хранятся в разных таблицах
без повторов. Название и цена товара записаны один раз — в справочнике товаров, а в заказах стоит только
его код.
import pandas as pd
orders = pd.read_csv("data/orders.csv")
products = pd.read_csv("data/products.csv")
print(orders.head(3))
print(products.head(3))order_id date customer_id product_id quantity channel 0 10001 2025-01-01 C095 P18 2 маркетплейс 1 10001 2025-01-01 C095 P09 1 маркетплейс 2 10002 2025-01-01 C059 P10 1 приложение product_id name category price cost 0 P01 Эспрессо-смесь 1 кг Кофе 1450 870 1 P02 Колумбия 250 г Кофе 690 380 2 P03 Эфиопия 250 г Кофе 790 430
В заказах нет цены — есть product_id. В справочнике нет продаж — есть product_id, цена и себестоимость.
Чтобы посчитать выручку, нужно к каждой строке заказа подставить строку справочника с тем же кодом.
Общий столбец, по которому сопоставляются строки, называют ключом.
merge на маленьком примере
Две таблицы с общим столбцом id:
left = pd.DataFrame({"id": [1, 2, 3], "name": ["Аня", "Борис", "Вика"]})
right = pd.DataFrame({"id": [2, 3, 4], "city": ["Тула", "Сочи", "Омск"]})
left.merge(right, on="id")| id | name | city | |
|---|---|---|---|
| 0 | 2 | Борис | Тула |
| 1 | 3 | Вика | Сочи |
Метод merge взял каждую строку левой таблицы, нашёл в правой строку с тем же id и склеил их в одну.
В результате остались только ключи 2 и 3 — те, что есть в обеих таблицах. Ани (id 1) в правой таблице нет,
Омска (id 4) нет в левой — и обе строки пропали. Такое соединение называется внутренним (inner), и оно
используется по умолчанию.
Параметр how («как») меняет правило. how="left" сохраняет все строки левой таблицы; где пары
не нашлось, ставит пропуск:
left.merge(right, on="id", how="left")| id | name | city | |
|---|---|---|---|
| 0 | 1 | Аня | NaN |
| 1 | 2 | Борис | Тула |
| 2 | 3 | Вика | Сочи |
Остальные варианты — "right" (все строки правой) и "outer" (все строки обеих):
left.merge(right, on="id", how="outer")| id | name | city | |
|---|---|---|---|
| 0 | 1 | Аня | NaN |
| 1 | 2 | Борис | Тула |
| 2 | 3 | Вика | Сочи |
| 3 | 4 | NaN | Омск |
Сколько строк будет в left.merge(right, on="id", how="right")?
how="right" сохраняет все строки правой таблицы — их три: id 2, 3 и 4. У строки с id 4 в столбце name будет пропуск. Две строки дал бы inner, четыре — outer.Все четыре вида на одной схеме:
| id | name |
|---|---|
| 1 | Аня |
| 2 | Борис |
| 3 | Вика |
| id | city |
|---|---|
| 2 | Тула |
| 3 | Сочи |
| 4 | Омск |
how="inner" — только общие ключи| id | name | city |
|---|---|---|
| 2 | Борис | Тула |
| 3 | Вика | Сочи |
how="left" — все строки левой| id | name | city |
|---|---|---|
| 1 | Аня | NaN |
| 2 | Борис | Тула |
| 3 | Вика | Сочи |
how="right" — все строки правой| id | name | city |
|---|---|---|
| 2 | Борис | Тула |
| 3 | Вика | Сочи |
| 4 | NaN | Омск |
how="outer" — все ключи обеих| id | name | city |
|---|---|---|
| 1 | Аня | NaN |
| 2 | Борис | Тула |
| 3 | Вика | Сочи |
| 4 | NaN | Омск |
Заказы и товары
named = orders.merge(products[["product_id", "name"]], on="product_id")
print(len(orders), len(named))
named.head(3)2448 2448
| order_id | date | customer_id | product_id | quantity | channel | name | |
|---|---|---|---|---|---|---|---|
| 0 | 10001 | 2025-01-01 | C095 | P18 | 2 | маркетплейс | Кофемолка ручная |
| 1 | 10001 | 2025-01-01 | C095 | P09 | 1 | маркетплейс | Травяной сбор 50 г |
| 2 | 10002 | 2025-01-01 | C059 | P10 | 1 | приложение | Шоколад горький |
Из справочника мы взяли два столбца — ключ и название (зачем брать только нужные — ниже, в разделе «Брать только нужное»). В orders один и тот же товар встречается в сотнях
строк, в products — один раз. merge подставил название к каждой строке заказа с этим кодом. Такое соединение называют «многие к одному» —
самый частый случай.
Число строк не изменилось: было 2448 — и стало 2448. Это первое, что проверяют после соединения.
Соедините orders со справочником products по ключу product_id и сохраните в priced. Добавьте столбец
revenue — цена × количество. В total сохраните выручку за год.
orders.merge(products, on="product_id").priced["price"] * priced["quantity"].Заготовка упражнения обновилась с тех пор, как вы сохранили свой код.
Вернуть заготовку? Ваш код в этом упражнении будет удалён.
priced = ...
# добавьте в priced столбец revenue
total = ...Упражнение пока не решено, поэтому для ячеек ниже выполнено эталонное решение — ваш код не изменён.
priced = orders.merge(products, on="product_id")
priced["revenue"] = priced["price"] * priced["quantity"]
total = priced["revenue"].sum()Сумма сошлась с той, что мы считали по готовой таблице: 3 301 420 ₽. Но теперь у нас есть то, чего
в shop_orders.csv не было, — себестоимость.
Добавьте в priced столбец profit — прибыль строки: разность цены и себестоимости (cost), умноженная
на количество. Сохраните в category_profit прибыль по категориям по убыванию, в total_profit — прибыль
за год.
(priced["price"] - priced["cost"]) * priced["quantity"] — скобки обязательны.priced.groupby("category")["profit"].sum().sort_values(ascending=False).Заготовка упражнения обновилась с тех пор, как вы сохранили свой код.
Вернуть заготовку? Ваш код в этом упражнении будет удалён.
# добавьте в priced столбец profit
category_profit = ...
total_profit = ...Упражнение пока не решено, поэтому для ячеек ниже выполнено эталонное решение — ваш код не изменён.
priced["profit"] = (priced["price"] - priced["cost"]) * priced["quantity"]
category_profit = priced.groupby("category")["profit"].sum().sort_values(ascending=False)
total_profit = priced["profit"].sum()Строки без пары
Теперь покупатели. В справочнике customers.csv — 240 клиентов. Посчитаем, сколько заказов сделал каждый:
customers = pd.read_csv("data/customers.csv")
order_counts = orders.groupby("customer_id", as_index=False).agg(n_orders=("order_id", "nunique"))
print(len(customers), len(order_counts))
order_counts.head(3)240 213
| customer_id | n_orders | |
|---|---|---|
| 0 | C001 | 6 |
| 1 | C002 | 6 |
| 2 | C005 | 8 |
Клиентов 240, а в таблице с числом заказов только 213 строк: остальные ничего не покупали в 2025 году, и в заказах их просто нет. Что будет при соединении?
print(len(customers.merge(order_counts, on="customer_id")))
print(len(customers.merge(order_counts, on="customer_id", how="left")))213 240
Внутреннее соединение молча выбросило 27 клиентов. Ошибки нет, таблица выглядит нормально — и «среднее число заказов на клиента», посчитанное по ней, окажется завышенным.
С how="left" клиенты без заказов остаются, а в столбце n_orders у них пропуск:
activity = customers.merge(order_counts, on="customer_id", how="left")
activity.head(4)| customer_id | name | city | signup_date | segment | n_orders | |
|---|---|---|---|---|---|---|
| 0 | C001 | Сергей Ю. | Екатеринбург | 2025-06-12 | постоянный | 6.0 |
| 1 | C002 | Сергей В. | Екатеринбург | 2025-01-14 | новый | 6.0 |
| 2 | C003 | Максим С. | Екатеринбург | 2025-05-19 | новый | NaN |
| 3 | C004 | Андрей Д. | Москва | 2023-09-15 | новый | NaN |
Из-за пропусков столбец стал дробным. Здесь пропуск значит «ноль заказов» — его можно и нужно заполнить.
В таблице activity замените пропуски в столбце n_orders нулями и переведите столбец в целый тип. Сохраните
в sleepers клиентов без единого заказа, в n_sleepers — их число.
activity["n_orders"] = activity["n_orders"].fillna(0).astype("int64").activity[activity["n_orders"] == 0].Заготовка упражнения обновилась с тех пор, как вы сохранили свой код.
Вернуть заготовку? Ваш код в этом упражнении будет удалён.
# замените пропуски в activity["n_orders"] нулями и сделайте столбец целым
sleepers = ...
n_sleepers = ...Упражнение пока не решено, поэтому для ячеек ниже выполнено эталонное решение — ваш код не изменён.
activity["n_orders"] = activity["n_orders"].fillna(0).astype("int64")
sleepers = activity[activity["n_orders"] == 0]
n_sleepers = len(sleepers)Брать только нужное
Соединять можно и несколько раз подряд. Добавим к заказам с товарами сегмент покупателя. Из справочника клиентов возьмём только два столбца — ключ и то, что нужно:
segments = customers[["customer_id", "segment"]]
with_segment = priced.merge(segments, on="customer_id")
print(with_segment.shape)
with_segment[["order_id", "name", "revenue", "segment"]].head(3)(2448, 13)
| order_id | name | revenue | segment | |
|---|---|---|---|---|
| 0 | 10001 | Кофемолка ручная | 6400 | новый |
| 1 | 10001 | Травяной сбор 50 г | 260 | новый |
| 2 | 10002 | Шоколад горький | 180 | постоянный |
Так результат не засоряется лишними столбцами. Есть и вторая причина: в обеих таблицах есть столбец name
(название товара и имя клиента) — что происходит при совпадении названий, разберём в следующем уроке.
По таблице with_segment соберите segment_stats — таблицу по сегментам (сегмент в индексе): revenue —
выручка, buyers — число разных покупателей. Добавьте столбец per_buyer — выручка на одного покупателя,
округлённая до рублей. В best_segment сохраните сегмент с наибольшим per_buyer.
with_segment.groupby("segment").agg(revenue=("revenue", "sum"), buyers=("customer_id", "nunique")).(segment_stats["revenue"] / segment_stats["buyers"]).round() и idxmax().Заготовка упражнения обновилась с тех пор, как вы сохранили свой код.
Вернуть заготовку? Ваш код в этом упражнении будет удалён.
segment_stats = ...
# добавьте в segment_stats столбец per_buyer
best_segment = ...Упражнение пока не решено, поэтому для ячеек ниже выполнено эталонное решение — ваш код не изменён.
segment_stats = with_segment.groupby("segment").agg(revenue=("revenue", "sum"), buyers=("customer_id", "nunique"))
segment_stats["per_buyer"] = (segment_stats["revenue"] / segment_stats["buyers"]).round()
best_segment = segment_stats["per_buyer"].idxmax()Оптовых покупателей всего 25, но каждый в среднем приносит 30 тысяч рублей в год — вдвое больше остальных.
Итоги
левая.merge(правая, on="ключ")— к каждой строке левой таблицы подставить строку правой с тем же ключом.how="inner"(по умолчанию) — только совпавшие ключи;how="left"— все строки левой, без пары —NaN.- После соединения сравните число строк с исходным.
- Берите из правой таблицы только нужные столбцы:
правая[["ключ", "столбец"]].