Все материалы
Продуктовая аналитикагайдстарт

Даты и время в pandas: периоды, недели, таймзоны и скользящие окна

Даты в pandas: привести строки к datetime, задать границы периода, группировать по неделям, не сдвинуть день таймзоной и считать скользящее окно по календарю, а не по строкам.

КейсПрактика1 августа 2026 г.обновлено 12 сентября 2026 г.12 мин

Строка «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.

pythonРазобрать дату и найти невалидные строки
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 или явные границы лучше подходят для фильтров.

pythonПолный месяц и календарная неделя
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 для этого удобно привести обе даты к началу дня и посчитать разницу в днях.

pythonРассчитать возраст события относительно регистрации
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 или в наивную колонкудальше зону уже не восстановить
pythonСутки по Москве для события, записанного в UTC, и наоборот
# источник пишет момент с офсетом: сначала 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 дней не равны календарному месяцу: состав дней меняется ежедневно, и сравнение с августом будет методологически нечестным.

pythonНадёжный фильтр полного месяца
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 daysdate 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, и спасает от второго сглаживания поверх первого.

Наконец, у семидневного окна есть встроенный побочный эффект: оно полностью убирает недельную сезонность. Это его работа, если вы смотрите на тренд, и прямой вред, если вопрос был про пятничные пики или про провал выходных. Окно сглаживает форму сигнала, а не объясняет её, и подменять им разбор сезонности не стоит — как не стоит объяснять скользящим средним и разовый выброс от рассылки.

Сырой DAU и его семидневное среднее по одному каналу

Сглаженная линия начинается на седьмой день: раньше полного окна не существует. Числа условные, важна разница амплитуд.

DAU, сыройDAU, среднее за 7 дней
pythonПолный календарь внутри сегмента, потом окно
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.
Продолжить чтение
Вся библиотека