Временные ряды: datetime-индекс, resample и rolling

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

DatetimeIndex — индекс из меток времени. С ним работают resample (агрегация по периодам), rolling (скользящие окна) и shift (сдвиг во времени).

Время как индекс

Анализ временных рядов начинается с того, что метки времени становятся индексом. Тогда выборка по датам, ресемплинг и окна работают «из коробки»:

import pandas as pd
df["дата"] = pd.to_datetime(df["дата"])
ts = df.set_index("дата").sort_index()

ts.loc["2024"]              # все строки 2024 года
ts.loc["2024-05"]           # весь май 2024
ts.loc["2024-05-01":"2024-05-31"]  # срез по диапазону дат

Частичный доступ по строке («2024», «2024-05») — это удобство DatetimeIndex: pandas сам понимает, что вы имеете в виду весь год или месяц. Для этого индекс должен быть отсортирован.

Аксессор .dt: компоненты даты

У datetime-столбца (не индекса) есть аксессор .dt — аналог .str для строк, дающий доступ к частям даты:

df["дата"].dt.year          # год
df["дата"].dt.month         # месяц
df["дата"].dt.dayofweek     # день недели (0 = понедельник)
df["дата"].dt.day_name()    # название дня
df["дата"].dt.quarter       # квартал

Это основа создания признаков: «выходной ли день», «номер недели», «час суток» — всё вытаскивается через .dt и используется в группировках.

resample: groupby по времени

resample — это группировка по временным периодам. «Сумма продаж по неделям», «среднее по часам» — вы задаёте частоту, и pandas бьёт ось времени на интервалы и агрегирует:

ts.resample("D").sum()      # по дням
ts.resample("W").mean()     # по неделям
ts.resample("ME").sum()     # по месяцам (Month End)
ts.resample("h").count()    # по часам

Частые коды частот: D (день), W (неделя), ME (конец месяца), QE (квартал), YE (год), h (час), min (минута). Смоделируем downsampling — суммирование по 3-минутным корзинам — на чистом Python:

# (минута от начала, значение)
data = [(0, 5), (1, 3), (2, 4), (3, 10), (4, 2), (5, 6), (6, 8), (7, 1)]
bucket = 3  # размер корзины в минутах

bins = {}
for minute, value in data:
    start = (minute // bucket) * bucket   # к какой корзине относится точка
    bins[start] = bins.get(start, 0) + value

for start in sorted(bins):
    print(f"{start:02d}:00 -> {bins[start]}")

Вывод:

00:00 -> 12
03:00 -> 18
06:00 -> 9

Восемь точек схлопнулись в три корзины по 3 минуты с суммой в каждой — ровно то, что делает ts.resample("3min").sum(). resample — это «groupby, где ключ группы вычисляется из времени».

resample работает в обе стороны. Downsampling (понижение частоты: секунды → минуты) агрегирует — много точек в одну, нужен агрегат (sum, mean). Upsampling (повышение частоты: дни → часы) наоборот создаёт пустые промежутки, которые надо заполнить — через ffill (протянуть последнее значение), asfreq (оставить NaN) или интерполяцию. Ещё две тонкости: параметры closed и label управляют тем, какая граница интервала включается и какой меткой подписывается корзина — для большинства частот по умолчанию интервал закрыт слева, а у месячных/недельных — справа. На стыках периодов это влияет, в какую корзину попадёт пограничная точка, поэтому при точных расчётах их стоит указывать явно.

rolling: скользящие окна

rolling считает агрегат в скользящем окне фиксированного размера — классика для сглаживания шума (скользящее среднее) и трендов:

data = [10, 12, 8, 14, 16, 9, 11]
window = 3

# скользящее среднее по окну из 3 точек
for i in range(len(data)):
    if i + 1 < window:
        print(i, data[i], "-> NaN")   # окно ещё не заполнено
    else:
        chunk = data[i - window + 1 : i + 1]
        print(i, data[i], "->", round(sum(chunk) / window, 2))

Вывод:

0 10 -> NaN
1 12 -> NaN
2 8 -> 10.0
3 14 -> 11.33
4 16 -> 12.67
5 9 -> 13.0
6 11 -> 12.0

Первые две точки дают NaN — окно из трёх ещё не набралось. Дальше каждое значение — среднее трёх последних. В pandas это ts.rolling(3).mean(). Так сглаживают дневной шум, оставляя тренд.

shift: сравнение с прошлым

shift сдвигает данные на N позиций — нужен, чтобы сравнить значение с предыдущим (прирост, разность день-к-дню):

ts["вчера"] = ts["продажи"].shift(1)            # значение предыдущей строки
ts["прирост"] = ts["продажи"] - ts["продажи"].shift(1)
ts["прирост_%"] = ts["продажи"].pct_change()    # относительный прирост

Таймзоны

По умолчанию метки времени «наивны» — без зоны. Для корректной работы с разными регионами их локализуют и конвертируют:

ts = ts.tz_localize("UTC")            # пометить как UTC
ts = ts.tz_convert("Europe/Moscow")  # перевести в МСК

Смешивать наивные и зональные метки нельзя — pandas выдаст ошибку. Если данные из разных зон, приведите всё к одной (обычно UTC) на входе.

Подводные камни

  • Несортированный datetime-индекс. Частичные срезы по дате и resample требуют отсортированного индекса; иначе ошибки или странный результат.
  • Строки вместо дат. Если «дата» осталась object, ни resample, ни .dt не заработают — сперва to_datetime.
  • NaN в начале rolling и shift. Первые значения окна/сдвига всегда пустые — учитывайте это при последующих расчётах.
  • Путаница частот. M устарел в пользу ME (конец месяца); min — минута, не M. Сверяйтесь с актуальными кодами.

Лучшие практики

  • Сразу приводите даты к datetime и ставьте их индексом, отсортировав его.
  • Агрегация по времени — resample; сглаживание и тренды — rolling; сравнение с прошлым — shift/pct_change.
  • Извлекайте признаки времени через .dt (день недели, час, квартал).
  • Работаете с несколькими зонами — приводите всё к UTC на входе.

Итог

  • DatetimeIndex включает частичные срезы по дате и временные операции.
  • resample — это groupby по временным периодам.
  • rolling — скользящие окна для сглаживания; shift — сравнение с прошлым.
  • .dt даёт компоненты даты; таймзоны приводите к единой.
Проверьте себя
1. Что делает ts.resample('ME').sum() для временного ряда?
AСортирует ряд по убыванию
BГруппирует данные по месяцам и суммирует значения внутри каждого месяца
CУдаляет пропуски
DСдвигает данные на месяц
2. Почему первые значения ts.rolling(3).mean() равны NaN?
AЭто ошибка вычисления
BОкно из 3 точек ещё не заполнено для первых двух позиций
Crolling всегда начинается с NaN навсегда
DДанные повреждены
3. Что нужно сделать, прежде чем применять resample или частичные срезы по дате?
AУдалить все NaN
BПривести столбец дат к datetime, сделать его индексом и отсортировать
CПеревести данные в long-формат
DПрименить groupby
Поддержать проект