Даты и время в pandas: периоды, недели, таймзоны и скользящие окна
Даты в pandas: привести строки к datetime, задать границы периода, группировать по неделям, не сдвинуть день таймзоной и считать скользящее окно по календарю, а не по строкам.
Содержание статьи
Строка «2026-08-01 00:30» становится датой только вместе с таймзоной: без неё непонятно, попадает событие в июльский отчёт или в августовский. Ошибки времени в pandas почти никогда не ломают код — расчёт молча отвечает за другой период. Поэтому тип задают явно, границы периода берут полуинтервалом, зону называют до первого groupby, а скользящее окно считают по календарю, а не по числу строк.
Коротко
Для аналитического периода используйте левую включённую и правую исключённую границу: >= start и < next_start. Строки превращайте в datetime через pd.to_datetime, недели и месяцы группируйте после приведения к единой таймзоне, а неполный текущий период не сравнивайте с полным прошлым.
- Сначала проверьте dtype и диапазон дат.
- Не смешивайте naive datetime и timezone-aware datetime.
- Для недель зафиксируйте, с какого дня начинается неделя.
- Возраст когорты считайте относительно даты старта пользователя, а не календарной даты отчёта.
Преобразовать строки в datetime
Не полагайтесь на автоматическое угадывание формата, если источник нестабилен. errors="coerce" помогает найти нераспарсенные значения через isna, но не должен незаметно превращать ошибки в пропуски. После преобразования проверьте минимум, максимум и число новых NaT.
orders['created_at'] = pd.to_datetime(
orders['created_at'],
errors='coerce',
utc=True,
)
invalid_dates = orders['created_at'].isna().sum()
print('invalid dates:', invalid_dates)
print(orders['created_at'].min(), orders['created_at'].max())Период: месяц, неделя и день
Период лучше задавать границей следующего периода. Так июль включает все события до 1 августа, включая последний день с любым временем. dt.to_period удобен для подписи и группировки, а date_range или явные границы лучше подходят для фильтров.
start = pd.Timestamp('2026-07-01', tz='UTC')
next_start = pd.Timestamp('2026-08-01', tz='UTC')
july = orders.loc[
(orders['created_at'] >= start)
& (orders['created_at'] < next_start)
].copy()
july['week'] = july['created_at'].dt.to_period('W-MON').astype(str)
weekly = july.groupby('week', as_index=False).agg(
orders=('order_id', 'nunique'),
)Когорты и возраст пользователя
Для retention важен не только день события, но и расстояние от регистрации. Пользователь, который сделал действие через семь дней после signup, относится к D7 независимо от календарного месяца. В pandas для этого удобно привести обе даты к началу дня и посчитать разницу в днях.
events['event_date'] = events['occurred_at'].dt.floor('D')
users['signup_date'] = users['signup_at'].dt.floor('D')
events = events.merge(users[['user_id', 'signup_date']], on='user_id', how='left')
events['day_age'] = (events['event_date'] - events['signup_date']).dt.days
d7 = events.loc[events['day_age'].eq(7)]
d7_users = d7['user_id'].nunique()Часовой пояс — часть определения метрики
Если продукт работает в нескольких странах, «день» может означать UTC, локальный день пользователя или бизнесовую таймзону компании. Для событий около полуночи эти варианты дадут разные DAU и выручку. Выберите правило до расчёта и запишите его в названии или описании отчёта.
- Храните исходное время с timezone, если источник его передаёт.
- Не локализуйте naive datetime, пока не знаете, в какой зоне он записан.
- Проверьте несколько событий около полуночи после конвертации.
- Сравните период с SQL-версией расчёта на контрольной выборке.
tz_localize приклеивает зону, tz_convert сдвигает часы — путаница стоит 23 события в сутки
Возьмём один день. За 1 июля по UTC в логе 420 событий. Переведите их в Europe/Moscow, и 23 из них — те, что записаны после 21:00 UTC, — уедут на 2 июля; на 1 июля останется 397 плюс события, помеченные в UTC вечером 30 июня. Число активных за «1 июля» получает два разных значения, и оба верные. Неверно только одно: не сказать, какое из них показано в отчёте.
Механика двух методов различается сильнее, чем похожи их названия. tz_localize("Europe/Moscow") утверждает, что числа в колонке и так были московским временем: на циферблате ничего не меняется, меняется смысл — 00:30 становится 00:30 MSK, то есть 21:30 UTC предыдущего дня. tz_convert("Europe/Moscow") берёт момент, уже известный точно, и показывает его московскими часами: момент тот же, циферблат другой. Первый метод даёт времени систему отсчёта, второй переводит между системами.
Перепутать их на нормально размеченных данных не страшно: tz_localize на timezone-aware ряде падает с TypeError: Already tz-aware, use tz_convert, а tz_convert на наивном — с Cannot convert tz-naive timestamps, use tz_localize to localize. Такая ошибка — подарок, она приходит сразу. Настоящая беда начинается там, где оба варианта проходят молча: наивная колонка выглядит одинаково и в случае «источник писал UTC», и в случае «источник писал местное время», а разница между ними — те же три часа и те же 23 события.
Единственный случай, когда utc=True в pd.to_datetime обязателен, — смешанные офсеты в строках. Если часть значений оканчивается на Z, а часть на +03:00, без utc=True pandas либо откажется собирать ряд, либо вернёт колонку с dtype object из Timestamp разных зон. На такой колонке .dt работает не так, как на нормальном ряде, а сравнение с одним pd.Timestamp может упасть посреди фильтра. Обратная сторона: utc=True на строках вообще без офсета молча объявит их UTC — если источник писал местное время, ошибка просто переедет на шаг назад.
Порядок операций важнее выбора метода. .dt.floor("D") режет сутки в той зоне, в которой ряд лежит прямо сейчас, поэтому на UTC-ряде он даст UTC-сутки, даже если в отчёте написано «по Москве». Сначала tz_convert в рабочую зону, потом floor или .dt.date — иначе те 23 события и останутся в чужом дне. Для источника, который отдал местное время без офсета, порядок обратный: сначала tz_localize с nonexistent="shift_forward" и ambiguous="NaT", потом tz_convert("UTC") для хранения.
Параметры ambiguous и nonexistent нужны не для красоты. В зонах с переходом на летнее время — Europe/Berlin, America/New_York — один час в году не существует, а другой случается дважды, и tz_localize по умолчанию падает на таких значениях. Проще жить иначе: храните события в UTC, а локальную зону применяйте только в момент нарезки суток и показа. Тогда переход на летнее время влияет ровно на один отчётный день в году, а не на весь пайплайн.
| Операция | Что меняется | Когда нужна | Типичная ошибка |
|---|---|---|---|
| tz_localize | появляется зона, часы те же | источник отдал местное время без офсета | применили к уже aware ряду |
| tz_convert | часы другие, момент тот же | нужен бизнес-день в своей зоне | применили к наивному ряду |
| to_datetime(utc=True) | всё приводится к UTC | в строках смешаны офсеты | строки без зоны объявлены UTC |
| dt.tz_localize(None) | зона отброшена, часы остались | выгрузка в Excel или в наивную колонку | дальше зону уже не восстановить |
# источник пишет момент с офсетом: сначала UTC, потом рабочая зона, потом сутки
raw = pd.to_datetime(events['event_at'], utc=True)
moscow = raw.dt.tz_convert('Europe/Moscow')
events['business_day'] = moscow.dt.floor('D')
# источник отдал местное время без офсета: здесь нужен localize, не convert
local = pd.to_datetime(dump['event_at']) # наивный ряд
dump['event_at_utc'] = (
local.dt.tz_localize('Europe/Moscow', nonexistent='shift_forward', ambiguous='NaT')
.dt.tz_convert('UTC')
)Полуинтервалы защищают границы периода
Для дней, недель и месяцев используйте правило [start, end): начало включается, правая граница не включается. Так соседние периоды не пересекаются, а timestamp ровно в полночь попадает только в один срез. Запись created_at >= start и created_at < next_start надёжнее, чем ручное вычисление последней секунды месяца.
Не сравнивайте даты как строки, пока не проверил формат. Строки вида 2026-08-10 сортируются удачно, но смешение 10.08.2026 и ISO ломает порядок. Приведите источник к datetime один раз, сохраните timezone и только затем группируйте.
Для отчётов за полный месяц отличай календарное окно от rolling window. Последние 30 дней не равны календарному месяцу: состав дней меняется ежедневно, и сравнение с августом будет методологически нечестным.
events['occurred_at'] = pd.to_datetime(
events['occurred_at'], utc=True, errors='coerce'
)
start = pd.Timestamp('2026-08-01', tz='UTC')
next_start = pd.Timestamp('2026-09-01', tz='UTC')
august = events.loc[
events['occurred_at'].ge(start)
& events['occurred_at'].lt(next_start)
]Группировка по неделям должна иметь договорённость
Неделя может начинаться в понедельник или воскресенье, а label периода может означать начало, конец или номер недели. Зафиксируйте это до сравнения. Для бизнеса часто удобнее использовать начало недели как ключ и показывать рядом диапазон дат. Иначе две команды построят разные «недельные» графики из одного источника.
resample удобен для временного индекса и регулярного ряда, Grouper — когда дата остаётся обычной колонкой и нужно добавить канал или платформу. Оба подхода могут дать пустые периоды. Решите, должны ли они отображаться как ноль, NA или отсутствующая дата.
После агрегации отсортируйте даты и проверьте число периодов. Если в августе четыре строки вместо пяти, это может быть корректно для выбранного правила недели, а может означать потерянный день. Контроль диапазона помогает отличить эти случаи.
| Задача | Подход | Что зафиксировать |
|---|---|---|
| календарные месяцы | Grouper(freq="MS") | начало месяца как label |
| rolling 30 days | date window | день расчёта и правая граница |
| неделя по понедельникам | Grouper(freq="W-MON") | смысл label и timezone |
| когортный возраст | разность дат | календарные или полные дни |
rolling(7) усредняет семь строк, а не семь дней
Пусть в daily одна строка — канал за день: organic держится около 100 активных в сутки, paid около 40. Таблица отсортирована по channel и date, и кто-то пишет daily["dau"].rolling(7).mean() одной строкой, без groupby. На стыке каналов окно захватит шесть строк organic и одну paid: (6 × 100 + 40) / 7 ≈ 91,4. У paid первые шесть дней месяца выйдут как 91, 83, 74, 66, 57, 49 и только к седьмому придут к своим 40. Кривая гладкая, и именно поэтому по ней ничего не заметно.
Вторая ловушка встроена в само слово rolling(7): окно измеряется в строках. Если 14 июля загрузка не дошла и строки за этот день нет — не ноль, а отсутствие, — то семь строк с 9 по 16 июля растянутся на восемь календарных дней. Подпись «7-day average» перестаёт быть правдой, хотя в коде по-прежнему стоит семёрка. rolling("7D") на DatetimeIndex считает по календарному окну и эту растяжку исключает.
Но календарное окно не решает вопрос пропуска, а только переводит его в другую форму. Семь дней по 100 активных дают среднее 100. Если один день реально был нулевым, а строки за него нет, rolling("7D") усреднит шесть значений и снова выдаст 100, тогда как правда — 600 / 7 ≈ 85,7. Завышение на 16,7% — и его не увидит ни одна проверка размерности: строк столько, сколько есть, среднее считается честно, отсутствует только факт отсутствия.
Поэтому перед окном календарь достраивают. Внутри каждого сегмента сетка дней должна быть полной: date_range от первой до последней даты, reindex, и дальше решение — 0 или NaN. Ноль означает «активности не было», NaN — «данные не пришли», и выбор между ними меняет метрику. Под NaN обязательно ставьте min_periods равным размеру окна, иначе среднее посчитается по огрызку и провал в данных превратится в ровную линию.
min_periods по умолчанию равен размеру окна, и это правильное поведение: первые шесть дней месяца остаются NaN, потому что семидневного окна для них ещё нет. min_periods=1 заполняет это начало средними по одному, двум, трём дням, и график стартует там, где данных для него не существует. Такая левая часть кривой выглядит как настоящая, но отвечает на другой вопрос, и на сравнении сегментов с разной датой старта это видно особенно хорошо.
Технически окно внутри сегмента делается двумя способами. groupby("channel").rolling(7) возвращает результат с MultiIndex channel × исходный индекс, и присвоить его обратно в колонку без сброса уровня не получится. groupby("channel")["dau"].transform(...) выравнивает результат по исходному индексу сам — для новой колонки в той же таблице это короче и безопаснее. Сглаженную метрику держите рядом с сырой и указывайте размер окна в названии: dau_7d честнее, чем dau, и спасает от второго сглаживания поверх первого.
Наконец, у семидневного окна есть встроенный побочный эффект: оно полностью убирает недельную сезонность. Это его работа, если вы смотрите на тренд, и прямой вред, если вопрос был про пятничные пики или про провал выходных. Окно сглаживает форму сигнала, а не объясняет её, и подменять им разбор сезонности не стоит — как не стоит объяснять скользящим средним и разовый выброс от рассылки.
Сглаженная линия начинается на седьмой день: раньше полного окна не существует. Числа условные, важна разница амплитуд.
grid = pd.date_range(daily['date'].min(), daily['date'].max(), freq='D')
full_index = pd.MultiIndex.from_product(
[daily['channel'].unique(), grid], names=['channel', 'date'],
)
daily = (
daily.set_index(['channel', 'date'])
.reindex(full_index)
.reset_index()
)
daily['dau'] = daily['dau'].fillna(0) # решение: пропуск в логе равен нулю активности
daily['dau_7d'] = (
daily.sort_values(['channel', 'date'])
.groupby('channel')['dau']
.transform(lambda s: s.rolling(7, min_periods=7).mean())
)Когорты: дата регистрации и дата активности — разные поля
Для когорты возьмите дату первого значимого события: регистрации, первой покупки или первой ценности. Для возврата используйте дату последующего события. Если перепутать эти поля, retention начнёт отвечать на вопрос о календарном объёме, а не о поведении пользователей после старта.
Считайте возраст в понятных единицах. D1 может означать следующий календарный день или 24 часа после регистрации — это разные методологии. В статье и отчёте назовите правило, а в тесте добавьте события в 23:59 и 00:01, чтобы увидеть эффект часового пояса.
Не сравнивайте свежую когорту по D30 с исторической, если у первой ещё нет полного окна наблюдения. Неполная когорта должна быть помечена или исключена из сравнения. Иначе retention будет выглядеть хуже только потому, что данные ещё не дозрели.
Назовите timezone, границы периода, начало недели и смысл D1/D7. Без этих договорённостей одинаковый код может дать разные бизнес-ответы.
Практика: сверить pandas и SQL на одной выборке
Возьмите небольшой срез событий за два дня и посчитайте DAU в pandas и SQL. Сначала приведите timestamp к одному часовому поясу, затем используйте одинаковые полуинтервалы и одинаковое определение active action. Сравните не только итог, но и список пользователей на границе периода.
Если ответы отличаются, не округляйте числа и не меняйте фильтр на глаз. Выгрузите промежуточную таблицу с user_id, исходным timestamp и локальной датой. В большинстве случаев проблема находится в timezone, включённой правой границе или разном событии, которое считается активностью.
После сверки закрепите правило в функции и тестовой фикстуре. Тогда будущий рефакторинг pandas или SQL не будет незаметно менять календарь продукта.
- Привести timestamp к явной timezone.
- Использовать полуинтервал для каждого периода.
- Разделять календарные периоды и rolling windows.
- Не считать незрелые когорты полными.
- Сверять граничные события в pandas и SQL.
Материалы по теме

Как тестировать аналитические расчёты на Python и pandas
Практический гайд по тестам для аналитика: проверить метрики на маленьком датасете, поймать регрессию и защитить расчёт от тихих изменений.

Как ускорить pandas: память, типы и обработка больших файлов
Что делать, если pandas медленно работает или не помещает файл в память: категории, downcast, chunksize, Parquet и контроль размера данных.

Pandas apply и векторизация: как писать быстрее и понятнее
Когда использовать apply, почему векторные операции быстрее и как переписать медленный построчный расчёт в pandas.