Раздел 42 · Аналитика данных с нуля

Даты в pandas: часовые пояса, resample и rolling

middle~25 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

Отто · операционный директор доставки «Гонец»

Стажёр пересчитал доставки в питоне, и у него опять заказы доставлены раньше, чем оформлены. Вы же это уже чинили в 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 ...).

С поясами работают два метода, и их легко перепутать:

pandasSQL из урока 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:

pandasSQLчто получится
.dt.hourEXTRACT(hour FROM ...)20
.dt.dayofweekEXTRACT(isodow FROM ...) - 10 для понедельника
.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 ₽.

Оплата пока не подключена: премиум и курсы открываются промокодом.