Временные ряды в 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, остальные разбираются в тренажёре.
- Для фичи нужно «значение предыдущего дня» и «прирост ко вчера» в отсортированном ряду. Чем это считают?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), иначе значения «перетекут» между пользователями. Обязательна корректная сортировка по времени до сдвига.
- Скользящее среднее за 7 дней как фичу для прогноза считаешь через rolling. Где здесь легко словить утечку из будущего?A)Rolling безопасен и утечки сам по себе не даётB)Утечка появится, только если забыть предварительно отсортировать данные по дате перед rollingC)Центрированное или включающее текущую точку окно подмешивает будущее; берут прошлое (shift/closed='left')D)Проблема только в производительности rolling на больших данных
показать ответ и разбор
+C)Центрированное или включающее текущую точку окно подмешивает будущее; берут прошлое (shift/closed='left')// разбор: rolling(7).mean() по умолчанию берёт окно, ЗАКАНЧИВАЮЩЕЕСЯ на текущей строке — она сама входит в среднее. Для признака, предсказывающего эту же строку, это уже подглядывание, а center=True тянет и будущие точки — прямая утечка. Чтобы фича опиралась только на прошлое, окно сдвигают (shift(1)) или используют closed='left'. Плюс rolling всегда применяют внутри группы и по возрастанию времени.
- Непрерывную колонку «возраст» нужно разбить на группы. Чем отличаются pd.cut и pd.qcut?A)Оба дают ровно 10 корзин и настройке не поддаютсяB)Это синонимы, разница только в имениC)Cut режет на корзины с равным числом наблюдений, а qcut — на интервалы равной ширины значенийD)cut — интервалы равной ШИРИНЫ; qcut — квантили с равным ЧИСЛОМ наблюдений
показать ответ и разбор
+D)cut — интервалы равной ШИРИНЫ; qcut — квантили с равным ЧИСЛОМ наблюдений// разбор: cut(x, bins) делит диапазон на интервалы равной ширины (по значению) — корзины могут быть очень неравны по наполнению при перекошенном распределении. qcut(x, q) режет по квантилям, чтобы в каждой корзине было примерно поровну наблюдений — границы подстраиваются под данные. Выбор зависит от цели: равные диапазоны значений (cut) или сбалансированные по размеру группы (qcut).
- Колонка с датами прочиталась из 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.
- В df колонка date типа datetime64. Как отобрать строки за июль 2026?A)df[df.date >= '2026-07-01' and df.date < '2026-08-01'] — через обычный andB)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 поймает июль всех годов сразу — на данных за несколько лет это тихая ошибка.
дальше
Теорию прочитали. Навык ставится повторением
В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.