сеньорчикОткрыть в Telegram
← вся теориятеория к собесу · pandas и numpy

Временные ряды в pandas

Зачем это спрашивают

Временные ряды в pandas - ежедневная работа аналитика: агрегаты по дням, скользящие средние, лаги, недельная динамика. Валят стабильно на трёх вещах: даты остались строками, пустые дни бесшумно выпали из отчёта, фича заглядывает в будущее.

Типовые формулировки: «посчитай метрику за каждый день, включая пустые», «чем resample отличается от groupby по дате?», «как строишь лаговые фичи?».

// Заглядывание в будущее - самый дорогой баг темы: на валидации метрика прекрасна, в проде фичи просто не существует. И заметить это по коду тяжело, там разница в одном знаке.

Datetime-индекс - фундамент всего остального

Всё начинается с pd.to_datetime и set_index. По datetime-индексу оживают вещи, которых иначе нет: срез строкой (df['2026-01'] отдаст весь январь), resample, оконные функции по календарю.

Даты, оставшиеся строками, опаснее, чем ошибка, потому что они выглядят работающими. Сортировка и сравнение идут по алфавиту: '2026-9-01' окажется больше, чем '2026-11-01', ведь символ «9» больше символа «1». Отчёт соберётся, порядок будет неверным, никто не заметит.

resample - это groupby по времени. Алиасы: 'D' - день, 'W' - неделя, 'ME' - конец месяца, 'MS' - начало. Голая 'M' в свежих версиях pandas больше не принимается и падает с ValueError, хотя во всех старых ноутбуках написана именно она. Понижение частоты (день из часов) требует агрегата, повышение (часы из дня) - решения, чем заполнять новые точки: ffill или interpolate.

// Таймзоны разводятся двумя методами: tz_localize приклеивает зону к наивному времени, tz_convert переводит между зонами. Наивное время с зонированным не сравнивается - pandas бросит TypeError «Cannot compare tz-naive and tz-aware». Приводи всё к UTC на входе, и половина вечерних отладок отменяется.

наивное и зонированное время
наивное не знает, в каком часовом поясе оно записано, зонированное знает. Смешивать их нельзя, и это к лучшему: молчаливое смешение сдвигает данные на часы

rolling, shift и направление времени

rolling - окно, которое едет по ряду: среднее за 7 дней, стандартное отклонение за 30. expanding - окно от начала ряда, которое растёт вместе с ним. min_periods решает, сколько первых значений останутся пустыми, пока окно не набралось.

Окно едет по порядку строк, а не по календарю. По несортированному индексу оно честно посчитает статистику по случайным соседям и ни на что не пожалуется - сортировка индекса это не гигиена, а условие корректности.

shift(k) сдвигает значения вниз: shift(1) кладёт в строку значение предыдущего дня, это и есть лаг. diff(1) - разность с предыдущим значением.

// А теперь дорогая часть. shift(-1) сдвигает в другую сторону и кладёт в строку значение из будущего. Модель, обученная на такой фиче, показывает фантастическую метрику - она буквально видит ответ, - а в проде этой колонки не существует, её нечем заполнить. Знак каждого shift в фиче-пайплайне проверяй отдельно и вслух.

лаг
значение той же величины k шагов назад. Лаговые фичи - основной способ дать модели прошлое, не давая ей будущего

Дыры в календаре: кто их заполняет, а кто нет

Тут путаются даже опытные, потому что разные способы агрегации ведут себя по-разному, а выглядят одинаково.

Данные за 1, 3 и 4 января, второе пропущено. groupby по дате с суммой вернёт три строки: второго января в результате нет вообще, дыра просто исчезла. resample('D') с суммой вернёт четыре строки, и второе января получит ноль - resample строит непрерывную сетку внутри диапазона данных.

Но и resample не выходит за края. Нужен полный месяц, а первая запись датирована третьим числом? Первые два дня не появятся ниоткуда. Полная сетка строится явно: reindex на pd.date_range от нужного начала до нужного конца.

Дальше решение, которое библиотека за тебя не примет: что означает пустой день. Для счётчиков (заказы, клики) это честный ноль, fillna(0). Для уровней (остаток на складе, курс валюты) - ffill, потому что остаток никуда не делся. Для измерений - interpolate. Три разных бизнес-смысла, и выбирать между ними должен ты.

// merge_asof - джойн «ближайшее не позже». Котировка к моменту сделки, последнее известное событие к таймстемпу. Обычный merge требует точного совпадения меток времени, а время почти никогда не совпадает точно.

Как отвечать: «Посчитай метрику за каждый день - включая дни без данных»

Сначала привожу колонку к datetime и ставлю индексом, иначе всё дальнейшее не работает. Дальше resample('D') с нужным агрегатом - он уже строит непрерывную сетку внутри диапазона, и пустые дни внутри него получат ноль, в отличие от groupby по дате, который их просто выкинет. Если отчёт должен покрывать фиксированный период, а данные начинаются позже, добиваю reindex на pd.date_range с нужными границами. И отдельно решаю, что значит пустой день: для счётчиков это ноль, для уровней вроде остатка на складе - протяжка последнего значения. Без полной сетки скользящие средние и графики тихо врут, потому что окно едет по существующим строкам, а не по календарю.

Разведены resample и groupby, показано, где каждый останавливается, и заполнение выбрано осознанно. Последняя фраза объясняет, почему это вообще имеет значение, вместо дежурного «так правильно».

На чём валят

  • Оставить даты строками: сортировка и срезы «работают», но по алфавиту.
  • Написать resample('M') по привычке - в свежем pandas это ValueError, нужно 'ME' или 'MS'.
  • Считать rolling по несортированному индексу - окно берёт случайных соседей и молчит.
  • shift(-1) в фиче: идеальная метрика на валидации, отсутствующая колонка в проде.
  • Агрегировать через groupby по дате и потерять пустые дни: они не стали нулями, они исчезли.
  • Сравнить наивное время с зонированным и получить TypeError - или, что хуже, сдвиг на часы после неаккуратного localize.

Проверьте себя

Пять вопросов из банка по этой подтеме. Всего их 10, остальные разбираются в тренажёре.

  1. #pandas_timeseries1 / 5
    Для фичи нужно «значение предыдущего дня» и «прирост ко вчера» в отсортированном ряду. Чем это считают?
    A)shift(1) даёт предыдущее значение, diff() — разность с предыдущим
    B)Df['v'] - 1 для прироста
    C)Взять rolling(1).mean() — окно из одного элемента как раз и сдвигает ряд на один день назад
    D)Отсортировать по убыванию и взять первую строку
    показать ответ и разбор
    +A)shift(1) даёт предыдущее значение, diff() — разность с предыдущим

    // разбор: shift(k) сдвигает значения на k позиций (появляется NaN в начале), давая доступ к прошлому: shift(1) — вчера. diff() = текущее минус shift(1) — прирост. В группированных рядах эти операции делают внутри группы: df.groupby('user')['v'].shift(1), иначе значения «перетекут» между пользователями. Обязательна корректная сортировка по времени до сдвига.

  2. #pandas_timeseries2 / 5
    Скользящее среднее за 7 дней как фичу для прогноза считаешь через rolling. Где здесь легко словить утечку из будущего?
    A)Rolling безопасен и утечки сам по себе не даёт
    B)Утечка появится, только если забыть предварительно отсортировать данные по дате перед rolling
    C)Центрированное или включающее текущую точку окно подмешивает будущее; берут прошлое (shift/closed='left')
    D)Проблема только в производительности rolling на больших данных
    показать ответ и разбор
    +C)Центрированное или включающее текущую точку окно подмешивает будущее; берут прошлое (shift/closed='left')

    // разбор: rolling(7).mean() по умолчанию берёт окно, ЗАКАНЧИВАЮЩЕЕСЯ на текущей строке — она сама входит в среднее. Для признака, предсказывающего эту же строку, это уже подглядывание, а center=True тянет и будущие точки — прямая утечка. Чтобы фича опиралась только на прошлое, окно сдвигают (shift(1)) или используют closed='left'. Плюс rolling всегда применяют внутри группы и по возрастанию времени.

  3. #pandas_timeseries3 / 5
    Непрерывную колонку «возраст» нужно разбить на группы. Чем отличаются pd.cut и pd.qcut?
    A)Оба дают ровно 10 корзин и настройке не поддаются
    B)Это синонимы, разница только в имени
    C)Cut режет на корзины с равным числом наблюдений, а qcut — на интервалы равной ширины значений
    D)cut — интервалы равной ШИРИНЫ; qcut — квантили с равным ЧИСЛОМ наблюдений
    показать ответ и разбор
    +D)cut — интервалы равной ШИРИНЫ; qcut — квантили с равным ЧИСЛОМ наблюдений

    // разбор: cut(x, bins) делит диапазон на интервалы равной ширины (по значению) — корзины могут быть очень неравны по наполнению при перекошенном распределении. qcut(x, q) режет по квантилям, чтобы в каждой корзине было примерно поровну наблюдений — границы подстраиваются под данные. Выбор зависит от цели: равные диапазоны значений (cut) или сбалансированные по размеру группы (qcut).

  4. #pandas_timeseries4 / 5
    Колонка с датами прочиталась из CSV как object. Что сделать, прежде чем брать .dt.month?
    A)Позвать df['d'].astype(str) — после этого .dt заработает
    B)Отсортировать таблицу по дате: .dt требует упорядоченного столбца
    C)Преобразовать в даты: pd.to_datetime(df['d'])
    D)Сделать колонку индексом: .dt живёт у DatetimeIndex
    показать ответ и разбор
    +C)Преобразовать в даты: pd.to_datetime(df['d'])

    // разбор: Аксессор .dt есть у Series с dtype datetime64. Пока колонка — object со строками, .dt бросит AttributeError. pd.to_datetime разбирает строки в даты (явный format ускоряет разбор и защищает от сюрпризов), после чего доступны .dt.month, .dt.dayofweek и ресемплинг. Ещё вариант — parse_dates прямо в read_csv.

  5. #pandas_timeseries5 / 5
    В df колонка date типа datetime64. Как отобрать строки за июль 2026?
    A)df[df.date >= '2026-07-01' and df.date < '2026-08-01'] — через обычный and
    B)df[df.date.dt.month == 7] — этого достаточно
    C)df[(df.date >= '2026-07-01') & (df.date < '2026-08-01')]
    D)df[df.date == '2026-07']
    показать ответ и разбор
    +C)df[(df.date >= '2026-07-01') & (df.date < '2026-08-01')]

    // разбор: Условия объединяют оператором & — поэлементным «и», а каждое условие обязано стоять в скобках: приоритет & выше сравнения. Питоновский and здесь бросит ValueError, потому что не знает, как привести целый массив булевых значений к одному True или False. Фильтр по .dt.month == 7 поймает июль всех годов сразу — на данных за несколько лет это тихая ошибка.

дальше

Теорию прочитали. Навык ставится повторением

В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.