Baza wiedzy / Python

Pandas resample: przedziały czasu, braki i przykłady w pandas 3

Na małym zbiorze sprawdzam sumy dzienne i miesięczne, granice przedziałów oraz prawdziwy upsampling. Pokazuję, dlaczego pusty dzień, brak kwoty i zero wymagają różnych decyzji.

Kajo RudzińskiOpublikowano Aktualizacja 11 min czytania

Pikselowe obserwacje trafiają do przedziałów czasu, a jeden z pojemników pozostaje pusty.

Zestawienie sprzedaży za dzień wygląda prosto, dopóki w danych nie pojawi się brakująca kwota, transakcja dokładnie o północy i dzień bez żadnego wiersza. Wtedy samo wywołanie resample('D').sum() przestaje wystarczać. Trzeba jeszcze ustalić, co oznaczają przedziały i jak ma wyglądać informacja o brakach.

W tym poradniku przechodzę od pięciu sztucznych obserwacji do agregacji dziennej i miesięcznej. Następnie sprawdzam granice godzin, interpolację oraz zmianę czasu. Wszystkie bloki Pythona tworzą jeden przykład i wykonuję je kolejno. Kod oraz pokazane wyniki sprawdzono w Pythonie 3.14.4, pandas 3.0.6 i NumPy 2.5.4. To dane edukacyjne, więc liczby mogę zweryfikować ręcznie.

Co robi resample i kiedy go używam

resample() jest metodą obiektów DataFrame i Series. Zapis pandas.resample() bywa skrótem w nazwie poradnika, ale w pokazanym kodzie wywołuję df.resample(...). Najpierw powstaje obiekt Resampler, który opisuje podział osi czasu. Dopiero kolejne wywołanie, na przykład sum() albo mean(), oblicza wynik.

Nie przekazuję do resample() argumentu method='sum'. Takiego parametru tu nie ma. Wybór operacji następuje po zdefiniowaniu przedziałów. To ważne również przy czytaniu błędów: problem z częstotliwością dotyczy podziału czasu, a problem z sumowaniem tekstu może dotyczyć wybranej kolumny.

Najczęściej używam tej metody do zamiany wielu obserwacji na rzadsze podsumowania, czyli downsamplingu. Drugi kierunek to upsampling: przygotowanie gęstszej siatki czasu. Gęstsza siatka może zawierać puste miejsca i sama w sobie nie dostarcza nowych pomiarów. Uzupełnienie takich miejsc wymaga osobnej, uzasadnionej reguły.

Podział czasu warto ustalić przed przygotowaniem wykresu. Pytanie „ile sprzedaliśmy w lutym” dotyczy sumy zdarzeń. Pytanie „jaka była temperatura co godzinę” może dotyczyć średniej z odczytów. Ta sama metoda techniczna obsłuży oba przypadki, ale nie wybierze za mnie znaczenia wyniku.

Dane: pięć obserwacji i jeden pusty dzień

Kolumna time zawiera daty przekształcone przez pd.to_datetime(). Zwykły napis przypominający datę nie wystarcza do wykonania tych przykładów. Kolumna sales jest liczbowa, a ostatnia kwota jest nieznana. Kanał sprzedaży zachowuję, aby później pokazać różnicę między osią czasu a dodatkowym wymiarem danych.

# example: data
import pandas as pd

df = pd.DataFrame({
    'time': pd.to_datetime([
        '2026-01-31 23:30', '2026-02-01 00:00',
        '2026-02-01 00:30', '2026-02-03 06:00', '2026-02-03 07:00'
    ]),
    'sales': [10, 20, 30, 40, None],
    'channel': ['web', 'web', 'store', 'web', 'web']
})

Suma czterech znanych kwot wynosi 100. W styczniu mamy 10, a w lutym 90. Drugiego lutego nie ma żadnego wiersza. Trzeciego lutego są dwa wiersze, lecz tylko jeden zawiera kwotę. Te sytuacje celowo występują obok siebie: brak transakcji w pliku i brak kwoty w istniejącej transakcji wymagają różnych pytań do właściciela danych.

W danych produkcyjnych zacząłbym od ustalenia, czy czas oznacza utworzenie zamówienia, płatność czy zapis do systemu raportowego. Zmiana tego pola może przesunąć sprzedaż między dniami bez zmiany samych kwot. W przykładzie przyjmuję po prostu czas zdarzenia i nie dopisuję do niego dodatkowych znaczeń biznesowych.

Agregacja dzienna oraz pułapka zera

Parametr on='time' wskazuje kolumnę czasu bez zmieniania indeksu tabeli. Następnie wybieram tylko sales, żeby nie próbować sumować nazw kanałów. Ustawiam min_count=1: do policzenia sumy potrzebna jest przynajmniej jedna dostępna wartość.

# example: daily
daily = df.resample('D', on='time')['sales'].sum(min_count=1)
print(daily.to_string())
time
2026-01-31    10.0
2026-02-01    50.0
2026-02-02     NaN
2026-02-03    40.0
Freq: D

Dla pierwszego lutego otrzymuję 50, ponieważ 20 + 30 = 50. Dla drugiego lutego pojawia się NaN. To zachowuje widoczną informację, że nie ma dostępnej kwoty do zsumowania. Wartość 40 dla trzeciego lutego oznacza sumę znanych kwot, mimo że jedna transakcja ma brak.

Bez min_count=1 domyślne sumowanie daje dla pustego przedziału zero. Taki wynik może być poprawny, jeśli system gwarantuje kompletność danych i brak wierszy oznacza brak sprzedaży. Jeżeli plik mógł nie dotrzeć, automatyczna zamiana braku na zero ukrywa problem. Regułę określam na podstawie procesu, a nie wyglądu wykresu. Zachowanie parametru opisuje dokumentacja Resampler.sum.

min_count=1 nie jest testem kompletności dnia. Dzień z jedną kwotą i setką braków nadal dostanie sumę tej jednej kwoty. Dlatego do kontroli raportu dokładam licznik obserwacji oraz brakujących wartości. W innym procesie mogę wymagać konkretnej liczby odczytów, ale wtedy próg powinien wynikać z częstotliwości pomiaru.

Count i size odpowiadają na różne pytania

Na wybranej kolumnie count() liczy niepuste wartości, a size() liczy wiersze grupy. Zobacz różnicę na ostatnim dniu naszego przykładu.

# example: counts
print(df.resample('D', on='time')['sales'].count().tolist())
print(df.resample('D', on='time')['sales'].size().tolist())
[1, 2, 0, 1]
[1, 2, 0, 2]

Obie listy mają tę samą kolejność dni co daily. Dla trzeciego lutego pierwsza zawiera 1, a druga 2. To od razu pokazuje, że jeden zapis nie ma kwoty. Dla drugiego lutego obie zawierają zero. Tutaj w ogóle nie ma zapisów.

Nie utożsamiam liczby wierszy z liczbą zamówień, dopóki nie znam poziomu szczegółowości tabeli. Jeżeli jeden wiersz oznacza pozycję koszyka, size() policzy pozycje. Do liczby zamówień potrzebny będzie identyfikator i odpowiednia agregacja. Resampling nie usuwa też automatycznie duplikatów z importu.

Podobnie średnia z transakcji odpowiada na inne pytanie niż średnia dzienna sprzedaż w miesiącu. Dni o różnej liczbie transakcji mają w pierwszej średniej różny udział. Przy wieloetapowym liczeniu zachowuję liczniki i sumy, zamiast bez namysłu obliczać średnią ze średnich.

Aktualne aliasy i podsumowanie miesiąca

W pandas 3 stosuję aktualne aliasy częstotliwości. Starsze przykłady z M, Q, A, Y, H, T lub S mogą zakończyć się błędem. W tym środowisku każdy z tych siedmiu zapisów odrzucono w resample(). To dotyczy aliasów przesunięć użytych tutaj; reguły częstotliwości dla PeriodIndex warto sprawdzać osobno.

Cel Alias używany tutaj
Koniec miesiąca ME
Początek miesiąca MS
Koniec kwartału kalendarzowego QE
Koniec roku kalendarzowego YE
Tydzień kończący się w niedzielę W-SUN
Dzień D
Godzina h
Minuta min
Sekunda s
# example: monthly
monthly = df.resample('ME', on='time')['sales'].sum(min_count=1)
print(monthly.to_string())
time
2026-01-31    10.0
2026-02-28    90.0
Freq: ME

Etykieta 28 lutego opisuje miesięczny przedział. Nie oznacza, że sprzedaż wystąpiła właśnie tego dnia. Tak samo liczba 90 nie mówi, że cały luty jest już kompletny: nasz zbiór kończy się trzeciego lutego. Przy publikowaniu wyników oznaczyłbym taki miesiąc jako częściowy.

Aktualne nazwy można sprawdzić w tabeli aliasów pandas, a usunięte warianty w zmianach pandas 3.0. Nie kopiuję pełnej sygnatury ze starych poradników. Parametry takie jak loffset, base, axis i kind nie należą do używanego tutaj API DataFrame.resample w pandas 3.

Kolumna on, indeks czasu i level

Ten sam wynik dzienny mogę uzyskać po ustawieniu indeksu. Tworzę nowy obiekt indexed, żeby oryginalne df nadal miało kolumnę time. Wariant z level wybiera nazwany poziom indeksu wielopoziomowego.

# example: index-level
indexed = df.set_index('time')
from_index = indexed.resample('D')['sales'].sum(min_count=1)
multi = df.set_index(['channel', 'time'])
from_level = multi.resample('D', level='time')['sales'].sum(min_count=1)
print(from_index.equals(daily), from_level.equals(daily))
True True

Oba porównania dają True. Po set_index('time') kolumna czasu domyślnie znika z kolumn nowej tabeli, dlatego na indexed nie dopisuję ponownie on='time'. To częsty powód błędu KeyError w przykładach składanych z kilku fragmentów.

Zwróć też uwagę na kanały. Samo level='time' agreguje tu wszystkie kanały razem. Nie tworzy osobnego raportu dla każdego kanału tylko dlatego, że channel znajduje się w indeksie. Osobne szeregi wymagają dodatkowego grupowania po kanale. Najpierw określam oczekiwaną strukturę wyniku, a dopiero potem łączę grupowanie z resamplingiem.

Do tego poradnika wybieram DatetimeIndex lub kolumnę datetime. Metoda obsługuje również inne określone typy osi czasu, ale zwykły indeks kolejnych liczb nie dostarcza informacji o datach. Dostępne warianty i parametry dokumentuje DataFrame.resample.

Closed i label: granica nie jest etykietą

closed wskazuje, która strona przedziału jest domknięta. label wskazuje stronę używaną do podpisania wyniku. Żeby zobaczyć różnicę, potrzebuję obserwacji leżącej dokładnie na granicy. W poniższym szeregu są takie obserwacje o 00:00 i 01:00.

# example: boundaries
s = pd.Series([1, 2, 4], index=pd.to_datetime([
    '2026-02-01 00:00', '2026-02-01 00:30', '2026-02-01 01:00'
]))
for closed, label in [('left', 'left'), ('left', 'right'), ('right', 'right')]:
    result = s.resample('1h', closed=closed, label=label).sum()
    print(closed, label, result.index.strftime('%H:%M').tolist(), result.tolist())
left left ['00:00', '01:00'] [3, 4]
left right ['01:00', '02:00'] [3, 4]
right right ['00:00', '01:00'] [1, 6]

W pierwszym wariancie przedział od 00:00 włącznie do 01:00 wyłącznie zawiera wartości 1 i 2. Suma wynosi 3. W drugim wariancie skład grup jest identyczny, ale podpis przesuwa się na prawą krawędź. Zmiana samego label nie przenosi obserwacji między grupami.

Dopiero trzeci wariant zmienia domknięcie. Obserwacja dokładnie o 01:00 trafia teraz razem z obserwacją o 00:30, więc suma wynosi 6. Wartość o 00:00 znajduje się we wcześniejszym przedziale, podpisanym 00:00. Przy uzgadnianiu raportów porównuję te reguły, zanim zacznę szukać brakujących transakcji.

Domyślne ustawienia zależą od częstotliwości. Dla godzin i dni typowe jest lewe domknięcie i lewa etykieta, a między innymi dla ME, QE, YE i tygodni prawe. W raporcie wymagającym konkretnego przypisania wpisuję oba parametry jawnie i sprawdzam obserwację graniczną.

Pikselowa lupa nad granicą dwóch przedziałów czasu

Origin i offset: przesunięcie siatki godzinowej

Załóżmy, że godzina raportowa zaczyna się trzydzieści minut po pełnej godzinie. Ustalam punkt odniesienia jako początek dnia i przesuwam siatkę o trzydzieści minut.

# example: origin
shifted = s.resample(
    '1h', origin='start_day', offset='30min', closed='left', label='left'
).sum()
print(shifted.to_string())
2026-01-31 23:30:00    1
2026-02-01 00:30:00    6
Freq: h

Pierwszy przedział zaczyna się jeszcze poprzedniego dnia o 23:30. Zawiera obserwację o północy, dlatego ma wartość 1. Następny zaczyna się o 00:30 i zawiera wartości 2 oraz 4. Etykieta spoza zakresu pierwszej obserwacji jest tutaj poprawnym skutkiem przyjętej siatki.

origin wpływa na stałe częstotliwości, takie jak godziny czy minuty. Nie traktuję go jako uniwersalnego sposobu przesuwania miesięcy kalendarzowych. Jeśli raport wymaga miesiąca rozliczeniowego od nietypowej daty, najpierw zapisuję regułę biznesową i dobieram osobne rozwiązanie. Miesiące mają różne długości, więc stała liczba godzin nie zastępuje ich znaczenia.

Upsampling, który rzeczywiście tworzy lukę

Teraz zmieniam rodzaj danych. levels oznacza dwa pomiary poziomu, a nie sumy transakcji. Pomiary są oddalone o dwie godziny. Siatka godzinowa musi więc dodać punkt pomiędzy nimi.

# example: upsample
levels = pd.Series([10.0, 16.0], index=pd.to_datetime([
    '2026-02-01 00:00', '2026-02-01 02:00'
]))
grid = levels.resample('1h').asfreq()
estimated = grid.interpolate(method='time')
print(grid.tolist())
print(estimated.tolist())
[10.0, nan, 16.0]
[10.0, 13.0, 16.0]

asfreq() daje trzy punkty, z pustym środkiem. Następnie interpolacja czasowa wylicza 13, ponieważ nowy punkt leży dokładnie w połowie czasu między wartościami 10 i 16. W naszym równomiernym przykładzie interpolacja liniowa dałaby ten sam wynik, ale jawny wybór metody pokazuje, że interesuje mnie upływ czasu.

Wartość 13 jest oszacowaniem. W tabeli wynikowej dobrze zachować znacznik pozwalający odróżnić obserwację od interpolacji. Nie użyłbym tej procedury do rozdzielenia dziennej sprzedaży na godziny bez dodatkowych założeń. Skopiowanie albo interpolowanie sum może stworzyć kwoty, których nie ma w danych źródłowych.

Samo ponowne próbkowanie szeregu dziennego z częstotliwością dzienną nie pokazuje upsamplingu. Dlatego w przykładzie są dwugodzinne odstępy i godzinowa siatka. Zachowanie opisują osobno asfreq oraz Series.interpolate.

Forward fill i braki obecne w źródle

Przeniesienie ostatniej wartości do kolejnego punktu może mieć sens dla stanu obowiązującego do następnej zmiany. Warto jednak zobaczyć, co się dzieje, gdy źródłowy odczyt sam jest pusty.

# example: forward-fill
source = pd.Series(
    [10.0, float('nan'), 16.0],
    index=pd.date_range('2026-02-01', periods=3, freq='2h')
)
carried = source.resample('1h').ffill()
print(carried.tolist())
[10.0, 10.0, nan, nan, 16.0]

Po wartości 10 zostaje uzupełniona nowa godzina. Oryginalny brak o 02:00 pozostaje brakiem i kolejny punkt również jest pusty. Resampler.ffill() nie naprawia w ten sposób źródłowego braku. Łańcuch z dodatkowym Series.ffill() byłby inną operacją i mógłby dać inny rezultat.

Zanim zastosuję tę regułę do pomiarów, ustalam maksymalny dopuszczalny wiek ostatniej obserwacji. Stan sprzed tygodnia może być formalnie ostatnią znaną wartością, ale mało użyteczną informacją o obecnej sytuacji. Parametr limit może ograniczyć liczbę nowo uzupełnianych punktów; znaczenie takiego ograniczenia zależy od gęstości siatki. Szczegóły pokazuje Resampler.ffill.

Strefa czasowa i doba o innej liczbie godzin

Pierwsze przykłady mają daty bez strefy czasowej. W raporcie obejmującym zmianę czasu potrzebuję jawnej decyzji, czy dzień oznacza dzień lokalny, czy dobę w UTC. Poniżej tworzę rzeczywiste znaczniki czasu w strefie Europe/Warsaw i liczę godzinowe obserwacje w dwóch lokalnych dniach.

# example: dst
for start, end in [('2026-03-29', '2026-03-30'), ('2026-10-25', '2026-10-26')]:
    hours = pd.date_range(start, end, freq='h', inclusive='left', tz='Europe/Warsaw')
    observations = pd.Series(1, index=hours)
    print(start, observations.resample('D').sum().tolist())
2026-03-29 [23]
2026-10-25 [25]

Dzień marcowej zmiany zawiera 23 obserwacje, a październikowej 25. Żaden z tych wyników nie świadczy sam w sobie o awarii importu. Kontrola oczekująca zawsze 24 rekordów zgłosiłaby tutaj fałszywy problem. Z kolei usunięcie informacji o strefie mogłoby ukryć powtórzoną lokalną godzinę jesienią.

Przy wczytywaniu dat lokalnych trzeba dodatkowo rozstrzygnąć godziny nieistniejące i niejednoznaczne. W tym przykładzie nie parsuję takich napisów: generuję świadomy strefy zakres. Nie przedstawiam więc tego kodu jako kompletnej procedury czyszczenia pliku z dowolnego systemu.

Jak sprawdzić własny raport przed użyciem

Przed wyborem metody rozważam też zwykłe grupowanie po dacie. Takie grupowanie utworzy grupy dla dat obecnych w danych. Resampling buduje regularne przedziały w objętym zakresie, dzięki czemu w naszym wyniku widać również drugi lutego. Ta różnica ma znaczenie przy liczeniu średniej dziennej: mianownik złożony tylko z dni obecnych w pliku może odpowiadać na inne pytanie niż liczba wszystkich dni raportu.

Resampling nie gwarantuje jednak pełnego kalendarza żądanego przez odbiorcę. Nasz wynik nie zaczyna się pierwszego stycznia i nie kończy ostatniego lutego, ponieważ takie granice nie wynikają z obserwacji. Jeśli raport ma obejmować zadany okres, przygotowuję oddzielny kalendarz i dopasowuję do niego wynik. Dopiero wtedy rozstrzygam, które luki oznaczają zero, które brak dostawy, a które dni poza zakresem działalności. Nie przedłużam automatycznie ostatniego wyniku na kolejne dni. Taka pozornie wygodna operacja mogłaby sprawić, że częściowy eksport wyglądałby jak kompletne zestawienie.

Zaczynam od małej tabeli, w której znam wynik ręczny. Dodaję obserwację na granicy, pusty przedział, brak wartości oraz powtórzony zapis. Dla każdej sytuacji zapisuję oczekiwane zachowanie. Dopiero po przejściu takich prób zwiększam zbiór danych.

W naszym przykładzie kontrola sumy daje 100 zarówno przed agregacją, jak i po zsumowaniu znanych wyników dziennych lub miesięcznych. To potrzebna kontrola, ale nie wystarcza: błędne przesunięcie transakcji między dniami może zachować sumę całkowitą. Dlatego sprawdzam też przypisanie konkretnej obserwacji i liczność grup.

Jako ćwiczenie dopisz transakcję o wartości 7 dokładnie o 01:00 do przykładu godzinowego. Przy lewym domknięciu zwiększy drugi przedział, a przy prawym godzinny przedział kończący się o 01:00. Potem zmień tylko label i sprawdź, czy kwoty pozostają takie same. To krótki test rozumienia, który łatwo przenieść do własnego raportu.

Gdy wynik ma być publikowany regularnie, zapisuję obok kodu strefę czasową, definicję dnia, sposób traktowania braków i informację o niepełnym ostatnim okresie. Dzięki temu następna osoba może odtworzyć znaczenie tabeli. Jeśli potrzebujesz obliczenia narastającego zamiast podziału na rozłączne przedziały, zobacz również pandas expanding.

Najważniejsze w pigułce

Dlaczego resample z M nie działa w pandas 3?

Dla częstotliwości końca miesiąca użyj ME. Starszy alias M został usunięty z używanych tutaj częstotliwości przesunięć; dla początku miesiąca stosuj MS.

Czym różnią się closed i label?

closed określa domkniętą stronę przedziału, czyli przypisanie obserwacji na granicy. label wybiera stronę używaną do podpisania grupy. Zmiana samej etykiety nie zmienia jej składu.

Czy resample automatycznie uzupełnia brakujące pomiary?

Sama gęstsza siatka nie tworzy pomiarów. asfreq może wprowadzić braki, a interpolacja lub ffill wymagają osobnej decyzji. Wartości wyliczone należy odróżniać od obserwowanych.

Kajo Rudziński

Newsletter KajoData

Zostańmy w kontakcie.

Piszę o analizie danych, uczeniu się i zmianie pracy. Dzielę się tym, co pomaga mi ogarniać technologię i zawodowe decyzje. Jeśli lubisz ten sposób myślenia, zapraszam do mojego newslettera.