Отто · операционный директор доставки «Гонец»
Стажёр пересчитал доставки в питоне, и у него опять заказы доставлены раньше, чем оформлены. Вы же это уже чинили в SQL? И пришли недельный отчёт «неделя к неделе», где Академический виден с первого дня работы, а не с первого заказа.
В прошлом уроке мы соединяли и разворачивали таблицы. С датами всё знакомо по блоку E: пояса из урока 32, DATE_TRUNC из урока 33, календарь из урока про generate_series и время между событиями из урока 35. Сегодня переносим всё это в pandas на выгрузке «Гонца»: 41 242 заказа с 1 марта по 30 июня, с названием зоны в столбце zone.
Строка ещё не дата
В CSV нет типов, поэтому время приезжает строками, object. В уроке 65 мы просили parse_dates при загрузке, а в уроке про чистку разбирали русские даты через pd.to_datetime(..., format=...). У этой функции есть ещё два аргумента, без которых в отчётах «Гонца» не обойтись. Первый errors:
raw = pd.Series(["15.04.2026 09:30", "22.04.2026 18:05", "не помню"])
pd.to_datetime(raw, format="%d.%m.%Y %H:%M", errors="coerce")
0 2026-04-15 09:30:00
1 2026-04-22 18:05:00
2 NaT
dtype: datetime64[ns]
Без errors="coerce" pandas упадёт на третьей строке с ValueError. С ним битая строка превращается в NaT, пустую дату. Удобно, но молча: после coerce всегда считай isna().sum(), чтобы знать, сколько дат потерялось.
Второй аргумент utc=True. Он нужен, когда в строках есть пояс:
pd.to_datetime(pd.Series(["2026-03-01 04:34:21+00", "2026-03-01 09:10:00+05"]), utc=True)
0 2026-03-01 04:34:21+00:00
1 2026-03-01 04:10:00+00:00
dtype: datetime64[ns, UTC]
Обе строки стали моментами в UTC: 09:10+05 это 04:10 по Гринвичу. Без utc=True строки с разными поясами pandas оставит текстом и предупредит, что в будущих версиях это станет ошибкой.
Ловушка «Гонца»: два пояса в одной таблице
Помнишь, откуда в уроке 32 взялась машина времени? Сайт пишет время заказа по часам Екатеринбурга и без пояса, а приложение курьера пишет доставку в UTC. В pandas это видно сразу по типам:
orders = pd.read_csv("orders.csv")
orders["created_at"] = pd.to_datetime(orders["created_at"])
orders["delivered_at"] = pd.to_datetime(orders["delivered_at"], utc=True)
orders[["created_at", "delivered_at"]].dtypes
created_at datetime64[ns]
delivered_at datetime64[ns, UTC]
У created_at наивное время, без пояса, как timestamp в PostgreSQL. У delivered_at время с поясом, как timestamptz. Первый заказ оформлен в 09:08:54, а доставлен в 04:34:21+00:00, то есть в 09:34 по Екатеринбургу.
Теперь вычтем одно из другого:
orders["delivered_at"] - orders["created_at"]
TypeError: Cannot subtract tz-naive and tz-aware datetime-like objects
pandas строже PostgreSQL. База молча сравнила такие столбцы и выдала 38 720 доставок раньше заказа, а pandas отказывается: он не знает, в каком поясе наивное время. Подскажем ему:
created = orders["created_at"].dt.tz_localize("Asia/Yekaterinburg")
minutes = (orders["delivered_at"] - created).dt.total_seconds() / 60
print((minutes < 0).sum(), round(minutes.mean(), 1))
13 52.5
Те же 13 заказов со сбоем часов и те же 52,5 минуты в среднем, что в уроках 32 и 33. Разница вычитания это Timedelta, промежуток времени, а .dt.total_seconds() переводит его в секунды, как EXTRACT(epoch FROM ...).
С поясами работают два метода, и их легко перепутать:
| pandas | SQL из урока 32 | что делает |
|---|---|---|
.dt.tz_localize("Asia/Yekaterinburg") | created_at AT TIME ZONE 'Asia/Yekaterinburg' для timestamp | приклеивает пояс к наивному времени, часы не меняются: 09:08 становится 09:08+05:00 |
.dt.tz_convert("Asia/Yekaterinburg") | delivered_at AT TIME ZONE 'Asia/Yekaterinburg' для timestamptz | переводит момент в другой пояс, часы меняются: 04:34+00:00 становится 09:34+05:00 |
tz_localize работает только с наивным временем, tz_convert только со временем с поясом. Перепутаешь, и pandas снова скажет TypeError.
Аксессор .dt: часы, дни и месяцы
Всё, что в SQL делали EXTRACT и DATE_TRUNC, в pandas живёт за аксессором .dt. Стажёр искал час пик доставок так: orders["delivered_at"].dt.hour, и получил 15 часов. Это час по UTC. Сначала переводим в местное время:
local = orders["delivered_at"].dt.tz_convert("Asia/Yekaterinburg")
local.dt.hour.value_counts().head(3)
delivered_at
20 3860
19 3794
13 3625
Пик в 20 часов, следом 19 и обед в 13: заказы в уроке 33 шли пиком в 19 часов, а доставка приезжает на час позже. По UTC те же доставки выходят на 15, 14 и 8 часов.
Шпаргалка по .dt:
| pandas | SQL | что получится |
|---|---|---|
.dt.hour | EXTRACT(hour FROM ...) | 20 |
.dt.dayofweek | EXTRACT(isodow FROM ...) - 1 | 0 для понедельника |
.dt.day_name() | to_char(..., 'Day') | Saturday |
.dt.floor("D") | DATE_TRUNC('day', ...) | 2026-03-01 00:00:00 |
.dt.to_period("M") | DATE_TRUNC('month', ...) | 2026-03 |
orders["created_at"].dt.day_name().value_counts() ставит наверх субботу и пятницу, 6622 и 6612 заказов. orders["created_at"].dt.to_period("M").value_counts().sort_index() даёт от 10 003 до 10 749 заказов в месяц.
Пояс важен и для дня. floor("D") по UTC относит доставку в 00:30 по Екатеринбургу к вчерашнему дню: в UTC это ещё 19:30. Так день меняют 1223 доставки из 39 360. Сначала tz_convert, потом floor.
урок из курса
Урок из курса «Аналитика данных с нуля». Курс покупается один раз и остаётся навсегда: 500 ₽.
Оплата пока не подключена: премиум и курсы открываются промокодом.