Shanraq.org Shanraq.org
Время в таблице: ресемплинг, скользящее среднее и честность
IT

Python: от данных до своей сводки Урок 32 из 56

Время в таблице: ресемплинг, скользящее среднее и честность

Тридцать первый урок курса по Python. Индекс из дат умеет то, чего не умеет список: `asfreq` показывает дни, которых нет, `resample` складывает дни в недели, `rolling` сглаживает. И измеренная цена сглаживания: размах падает вдвое, а день настоящего пика исчезает из ряда.

Зачем это нужно

Ряды по времени приходят почти в любой работе: курс по дням, продажи по часам, показания счётчика по минутам. С ними делают три вещи — укрупняют, сглаживают и сравнивают с прошлым периодом, — и все три в pandas есть готовыми.

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

Сразу целиком

Файл vremya.py. Сорок календарных дней курса, из них тридцать рабочих, и один день с новостями.

"""Урок 31: время в таблице.

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

import pandas as pd

# Ряд построен по счётчику: у всех одинаковый. Выходных в нём нет — как в
# настоящей выписке с биржи.
days, rates = [], []
start = pd.Timestamp("2026-01-05")
for i in range(40):
    day = start + pd.Timedelta(days=i)
    if day.weekday() >= 5:
        continue
    value = 512.0 + i * 0.35 + ((i * 7) % 11 - 5) * 0.6
    if i == 21:
        value += 14.0          # день новостей: один скачок посреди ряда
    days.append(day)
    rates.append(value)

rate = pd.Series(rates, index=pd.DatetimeIndex(days, name="day"), name="rate").round(2)
print(rate.head(3))
print("точек:", len(rate), "| с", rate.index.min().date(), "по", rate.index.max().date())

print()
print("== индекс из дат умеет то, чего не умеет список")
print("январь:", len(rate.loc["2026-01"]), "точек | одна дата:", rate.loc["2026-01-19"])
print("дни недели:", sorted(set(rate.index.day_name())))

print()
print("== asfreq('D') показывает дыры")
daily = rate.asfreq("D")
print("стало точек:", len(daily), "| из них пустых:", int(daily.isna().sum()))
print(daily.loc["2026-01-09":"2026-01-12"])

print()
print("== resample: неделя вместо дня")
weekly = rate.resample("W").agg(["mean", "min", "max", "count"]).round(2)
print(weekly.head(3))

print()
print("== rolling: скользящее среднее по пяти дням")
smooth = rate.rolling(5).mean().round(2)
print("первые четыре пусты:", int(smooth.head(4).isna().sum()))
print(smooth.head(6))

print()
print("== чем сглаживание платит")
print("настоящий размах:", round(rate.max() - rate.min(), 2))
print("размах сглаженного:", round(smooth.max() - smooth.min(), 2))
print("день максимума: настоящий", rate.idxmax().date(), "| сглаженный", smooth.idxmax().date())
print("скачок 26 января: в ряду", rate.loc["2026-01-26"], "| в сглаженном", smooth.loc["2026-01-26"])

print()
print("== изменение к предыдущему дню")
print((rate.diff().round(2)).head(3))
print("дней роста:", int((rate.diff() > 0).sum()), "| падения:", int((rate.diff() < 0).sum()))

Выводит:

day
2026-01-05    509.00
2026-01-06    513.55
2026-01-07    511.50
Name: rate, dtype: float64
точек: 30 | с 2026-01-05 по 2026-02-13

== индекс из дат умеет то, чего не умеет список
январь: 20 точек | одна дата: 519.9
дни недели: ['Friday', 'Monday', 'Thursday', 'Tuesday', 'Wednesday']

== asfreq('D') показывает дыры
стало точек: 40 | из них пустых: 10
day
2026-01-09    514.00
2026-01-10       NaN
2026-01-11       NaN
2026-01-12    514.45
Freq: D, Name: rate, dtype: float64

== resample: неделя вместо дня
              mean    min     max  count
day                                     
2026-01-11  512.82  509.0  516.05      5
2026-01-18  514.31  512.4  516.95      5
2026-01-25  518.44  515.8  520.35      5

== rolling: скользящее среднее по пяти дням
первые четыре пусты: 4
day
2026-01-05       NaN
2026-01-06       NaN
2026-01-07       NaN
2026-01-08       NaN
2026-01-09    512.82
2026-01-12    513.91
Name: rate, dtype: float64

== чем сглаживание платит
настоящий размах: 23.75
размах сглаженного: 12.73
день максимума: настоящий 2026-01-26 | сглаженный 2026-02-13
скачок 26 января: в ряду 532.75 | в сглаженном 521.01

== изменение к предыдущему дню
day
2026-01-05     NaN
2026-01-06    4.55
2026-01-07   -2.05
Name: rate, dtype: float64
дней роста: 14 | падения: 15

Разбор

Индекс из дат — это не подписи-строки

pd.DatetimeIndex — тот же индекс из двадцать пятого урока, но он понимает, что подписи — время. Отсюда всё остальное:

  • rate.loc["2026-01"] — весь январь, хотя такой подписи в индексе нет: pandas читает это как отрезок;
  • rate.index.day_name(), .month, .quarter — у каждой подписи есть свои части;
  • ряд можно спросить о частоте, сдвинуть на неделю, разложить по кварталам.

Со строками "2026-01-05" ничего этого не работает: для resample они просто текст, и он отвечает TypeError. Первое, что делают с чужим файлом, — pd.to_datetime для столбца с датой (двадцать шестой урок, аргумент parse_dates), и только потом set_index.

asfreq: дни, которых нет

В ряду тридцать точек, а календарных дней сорок. Десять дней — выходные, наблюдений в эти дни нет, и в списке это никак не видно: список не знает, что между пятницей и понедельником есть пропуск.

rate.asfreq("D") перестраивает ряд на календарную сетку: дни без наблюдения появляются, и в них NaN. Это первое, что стоит сделать с любым рядом по времени, — не чтобы заполнить дыры, а чтобы узнать, что они есть. Дальше уже решают: выходные — это нормально, а пропавший вторник посреди недели — повод спросить у источника.

resample: неделя вместо дня

resample("W") режет ряд по неделям и считает в каждой то, что попросили. Это groupby из двадцать восьмого урока, только ключ — календарный отрезок, и его не надо строить самому.

Частоты пишутся коротко: D — день, W — неделя, ME — конец месяца, QE — квартал, YE — год, h — час. Подпись у недели по умолчанию — её последний день (в выводе 2026-01-11 — это неделя с 5 по 11 января). Это стоит проверять каждый раз: недельный отчёт, подписанный последним днём, легко прочитать как «на 11 января», хотя это «за неделю до 11 января».

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

rolling: скользящее среднее

rate.rolling(5).mean() — среднее по последним пяти наблюдениям, шагающее по ряду. В этом примере это пять рабочих дней, а не пять календарных дней. Первые четыре значения пусты, и это правильно: пяти точек ещё не набралось, а считать по трём и называть это средним по пяти — уже неправда. Если пустое начало мешает, есть min_periods=3, но тогда первые значения посчитаны по другому числу точек, и об этом надо помнить.

Окно по умолчанию смотрит назад: значение на 9 января — это среднее за 5–9 января. Поэтому сглаженный ряд отстаёт от настоящего. center=True ставит окно симметрично, и тогда ряд не отстаёт, но в начале и в конце пустых значений становится вдвое больше.

Чем сглаживание платит

Вот ради чего урок. В ряду есть один день с новостями — 26 января, курс подскочил на 14 тенге. Что с ним стало:

настоящий ряд сглаженный
размах 23.75 12.73
день пика 26 января 13 февраля
значение 26 января 532.75 521.01

Сглаживание сделало ровно то, о чём его просили: убрало выбросы. Но выброс здесь — не шум, а событие. На сглаженном графике 26 января ничем не выделяется, размах вдвое меньше настоящего, а пик уехал на две с половиной недели вперёд — туда, где ряд просто выше в среднем.

Отсюда три правила, которые стоит принять как есть:

  1. Сглаженный ряд — вывод, а не данные. Рядом с ним всегда должен быть настоящий: хотя бы бледной линией на том же графике.
  2. Размах, максимум и минимум считают по настоящему ряду, а не по сглаженному. Иначе в отчёте окажется, что курс не превышал 526, хотя он был 533.
  3. У сглаживания есть параметр — ширина окна, — и его выбирают до того, как посмотрели на результат. Иначе окно подбирается под нужную форму кривой, и это уже не анализ.

Сравнение с прошлым периодом

diff() — разница с предыдущей точкой, pct_change() — она же в долях, shift(1) — сам предыдущий ряд, сдвинутый на шаг. Все три считают по соседям в том порядке, в каком лежит ряд, поэтому перед ними его сортируют по индексу; несортированный ряд даст числа, которые выглядят правдоподобно и ничего не значат.

Для таблицы с несколькими рядами — как в проекте, где стран три, — diff берут внутри группы: table.groupby("country")["value"].diff(). Без группировки первая строка каждой следующей страны вычтет последний год предыдущей.

Карта урока

Карта урока: дни, недели и сглаживание

Скажите своими словами

Не подглядывая, ответьте вслух или на бумаге. Ответы — в конце урока.

  1. Зачем делать asfreq("D"), если заполнять пропуски вы не собираетесь?
  2. Почему первые четыре значения скользящего среднего по пяти точкам пусты?
  3. Что теряет отчёт, в котором показан только сглаженный ряд?

Разминка

Три коротких шага перед заданием: предсказать, дописать, починить. Ответы — в конце урока, но сначала ответьте сами.

1. Предскажите. Что напечатает эта программа?

import pandas as pd

days = pd.to_datetime(["2026-01-05", "2026-01-06", "2026-01-07", "2026-01-12", "2026-01-13"])
rate = pd.Series([512.0, 514.0, 513.0, 520.0, 522.0], index=days)
print(rate.resample("W").mean().round(2))
print("недель:", len(rate.resample("W").mean()))

2. Заполните пропуск. Вместо ... поставьте ширину окна так, чтобы пустых значений в начале было ровно два.

# сколько точек в окне — столько минус один пустых значений в начале
import pandas as pd

days = pd.date_range("2026-01-05", periods=6, freq="D")
rate = pd.Series([512.0, 514.0, 513.0, 520.0, 522.0, 519.0], index=days)
smooth = rate.rolling(...).mean().round(2)
print(smooth.tolist())
print("пустых в начале:", int(smooth.isna().sum()))

3. Почините. Программа падает: Only valid with DatetimeIndex, TimedeltaIndex or PeriodIndex. Даты в индексе есть, но это строки.

# индекс выглядит датами, а pandas видит текст
import pandas as pd

rate = pd.Series([512.0, 514.0, 520.0], index=["2026-01-05", "2026-01-06", "2026-01-12"])
print(rate.resample("W").mean().round(2).tolist())

Задание

Обязательное. Возьмите тот же ряд из урока — сорок календарных дней, тридцать рабочих, скачок 26 января. Постройте недельный отчёт (count, mean, min, max) и скользящее среднее по пяти дням. Напечатайте: сколько календарных дней осталось без наблюдения; недельную таблицу; размах настоящего ряда и сглаженного; день и величину пика в обоих; самый большой дневной скачок и то, во что он превратился в сглаженном ряду.

Ожидаемый вывод:

дней в ряду: 30 | календарных дней: 40 | без наблюдения: 10

по неделям:
            count    mean     min     max
day                                      
2026-01-11      5  512.82  509.00  516.05
2026-01-18      5  514.31  512.40  516.95
2026-01-25      5  518.44  515.80  520.35
2026-02-01      5  522.73  516.70  532.75
2026-02-08      5  522.74  520.10  524.65
2026-02-15      5  525.55  523.05  528.05

скользящее среднее по пяти дням:
  пустых в начале: 4
  размах ряда: 23.75 | размах сглаженного: 12.73
  пик ряда: 2026-01-26 532.75 | пик сглаженного: 2026-02-13 525.55

самый большой скачок за день: 2026-01-26 + 14.45
в сглаженном ряду этот день: 521.01 — меньше настоящего на 11.74

Готово, когда: вывод совпадает построчно; пропущенные дни найдены через asfreq, а не вычитанием длин; недели получены resample, а не группировкой по номеру недели из строки; размах и пик настоящего ряда считаются по настоящему ряду; разница в день скачка посчитана, а не оценена на глаз.

На своих данных. Возьмите любой свой ряд по времени — расходы по дням, шаги, вес, что угодно. Сделайте asfreq и посмотрите, сколько дней в нём на самом деле пропущено. Потом постройте скользящее среднее двух разных окон и честно скажите себе, какое из них вы выбрали бы для отчёта и почему.

По желанию.

  • Сравните rolling(5).mean() и rolling(5, center=True).mean(): где ряд отстаёт, а где обрезан с двух сторон.
  • Сделайте resample("ME") и посмотрите, какой подписью pandas называет месяц.
  • Посчитайте pct_change() и найдите день с наибольшим ростом в процентах — совпадёт ли он с днём наибольшего роста в тенге.

Куда это встанет в проекте

Двенадцатый шаг, и в нём меньше, чем хотелось бы, — по честной причине.

Год в сводке перестаёт быть числом и становится датой: pd.to_datetime(year, format="%Y"). Число сортируется и вычитается по случайности, дата — по смыслу, и в тот день, когда сводка прочитает месячный ряд, ничего выше этой строки менять не придётся.

Отчёт получает вопрос, на который годовой ряд действительно может ответить: насколько последний год отличается от предыдущего. Это diff() внутри группировки — одна строка и никакого сдвига на единицу, который так любит цикл по отсортированным годам.

А resample и rolling в проект не попали, и это записано в шаге: пять годовых точек — не ряд, который сглаживают. Скользящее среднее по пяти точкам было бы картинкой, за которой ничего нет. Они появятся, когда сводка начнёт читать что-то плотнее одной цифры в год.

Долги. Индекс у сводки теперь дата, но частота у ряда не объявлена: годовой он или месячный, программа не знает — узнаёт по тому, что пришло. Это нормально, пока источник один.

Ответы

Показать ответы

На вопросы

  1. Чтобы увидеть, чего нет. Пропущенный день в списке никак не проявляется — ряд просто короче, чем календарь. asfreq("D") ставит эти дни на место с NaN, и дальше уже решают: выходные — это ожидаемо, а пропавший вторник — повод спросить у источника, что случилось.
  2. Потому что пяти точек ещё не набралось. Посчитать по трём и назвать это средним по пяти — неправда; NaN здесь означает «ответа пока нет», и это честный ответ.
  3. Он теряет события. Сглаживание убирает выбросы, а выброс — это часто самое важное, что было: день новостей, сбой, аврал. В примере размах падает с 23.75 до 12.73, а день пика уезжает на две с половиной недели. Поэтому сглаженный ряд показывают рядом с настоящим, а не вместо него.

К разминке

  1. resample("W") считает по календарным неделям и подписывает каждую её последним днём — воскресеньем. Две недели: 5–11 января и 12–18 января.
2026-01-11    513.0
2026-01-18    521.0
Freq: W-SUN, dtype: float64
недель: 2
  1. rolling(3). Пустых значений в начале всегда на одно меньше, чем точек в окне: окно из трёх даёт два пустых.
import pandas as pd

days = pd.date_range("2026-01-05", periods=6, freq="D")
rate = pd.Series([512.0, 514.0, 513.0, 520.0, 522.0, 519.0], index=days)
smooth = rate.rolling(3).mean().round(2)
print(smooth.tolist())
print("пустых в начале:", int(smooth.isna().sum()))
[nan, nan, 513.0, 515.67, 518.33, 520.33]
пустых в начале: 2
  1. rate.index = pd.to_datetime(rate.index). Строки, похожие на даты, для pandas остаются строками, пока их не превратили в даты; resample работает только с временным индексом.
import pandas as pd

rate = pd.Series([512.0, 514.0, 520.0], index=["2026-01-05", "2026-01-06", "2026-01-12"])
rate.index = pd.to_datetime(rate.index)
print(rate.resample("W").mean().round(2).tolist())
[513.0, 520.0]

К заданию

Пропущенные дни считают через asfreq("D"), а не вычитанием 40 − 30: длина календаря известна только потому, что мы сами её построили, а у настоящего ряда её пришлось бы откуда-то взять.

Размах и пик настоящего ряда считаются до сглаживания и по настоящему ряду. Это главное, что проверяется в задании: как только в отчёте появляются max и min сглаженного ряда, отчёт начинает утверждать то, чего не было.

Источники

Если вы нашли ошибку или опечатку в тексте статьи, то сообщите нам об этом

Проверить задание

Сначала решите и запустите в VS Code — редактор покажет ошибку на месте. Готовое решение вставьте сюда. Проверяет модель: она укажет на ошибку, но не даст готовый ответ.

Чтобы проверить, нужно войти. Войти

Комментарии (0)

Пока нет комментариев. Будьте первым.