2025-05-02

Zeitliche Analyse mit Stream-Windowing-Funktionen in DuckDB

Petrica Leuca, Gábor Szárnyas

Einleitung

In Datenplattformen kategorisieren wir Daten üblicherweise in Dimensions- und Fakten-Daten. Während Dimensionen Informationen über Entitäten enthalten (Name, Adresse, Seriennummer usw.), enthalten Fakten Ereignisse zu solchen Entitäten (Klicks, Verkäufe, Banktransaktionen, Messwerte von IoT-Geräten usw.). Im Allgemeinen enthalten Fakten-Daten ein Zeitstempel-Attribut, das den Moment bezeichnet, in dem das Ereignis stattfand (oder beobachtet wurde).

Wenn zeitgestempelte Daten auf einer Streaming-Plattform verarbeitet werden, werden sie oft mit Stream-Windowing-Funktionen verarbeitet, um die Daten in Zeitfenster zu organisieren. In diesem Beitrag zeigen wir, wie sich Stream-Windows auf statische zeitgestempelte Fakten-Daten in DuckDB anwenden lassen, als Teil einer Datenanalyse, um Zugverkehrszusammenfassungen, Trends und Unterbrechungen am Bahnhof Amsterdam Centraal zu berechnen.

In einem zukünftigen Beitrag behandeln wir Streaming-Design-Muster mit DuckDB.

Für die aktuelle Umsetzung nutzen wir die DuckDB-Datenbank, die im dbt-Projekt aus dem Artikel „Fully Local Data Transformation with dbt and DuckDB“ entstanden ist, basierend auf den offenen Daten der Anwendung Rijden de Treinen (Fahren die Züge?). Wir beginnen damit, die Datenbank von unserem Storage-Ort anzuhängen (in jeder DuckDB-Sitzung).

ATTACH 'https://blobs.duckdb.org/data/dutch_railway_network.duckdb';
USE dutch_railway_network.main_main;

Warnung: Die Datenbank ist ziemlich groß (ca. 1,2 GB), stellen Sie also eine stabile Internetverbindung sicher. Statt die Datenbank anzuhängen, können Sie die Datenbankdatei auch herunterladen und von der Kommandozeile aus verbinden:

Terminal window
duckdb dutch_railway_network.duckdb -cmd 'USE main_main'

Tumbling Windows

Tumbling Windows sind festgroße [links-geschlossen, rechts-offen) Zeitintervalle, um Zusammenfassungen auf einer bestimmten Zeiteinheit (Jahr, Tag, Stunde usw.) zu berechnen. Tumbling Windows werden auch genutzt, um (unregelmäßige) Fakten-Daten in Zeitreihendaten zu verwandeln, indem sie in einem regelmäßigen Zeitintervall aggregiert werden.

Ein Weg, Tumbling Windows umzusetzen, ist die Funktion date_trunc, die den Zeitstempel auf die angegebene Genauigkeit abschneidet. Im Folgenden holen wir zum Beispiel die Zahl der Fahrten für jede Stunde und jeden Tag in 2024:

SELECT
date_trunc('hour', station_service_time) AS window_start,
window_start + INTERVAL 1 HOUR AS window_end,
count(*) AS number_of_services
FROM ams_traffic_v
WHERE year(station_service_time) = 2024
GROUP BY ALL
ORDER BY 1;
┌─────────────────────┬─────────────────────┬────────────────────┐
│ window_start │ window_end │ number_of_services │
│ timestamp │ timestamp │ int64 │
├─────────────────────┼─────────────────────┼────────────────────┤
│ 2024-01-01 01:00:00 │ 2024-01-01 02:00:00 │ 2 │
│ 2024-01-01 02:00:00 │ 2024-01-01 03:00:00 │ 3 │
│ 2024-01-01 03:00:00 │ 2024-01-01 04:00:00 │ 4 │
│ · │ · │ · │
│ · │ · │ · │
│ · │ · │ · │
│ 2024-12-31 20:00:00 │ 2024-12-31 21:00:00 │ 9 │
│ 2024-12-31 21:00:00 │ 2024-12-31 22:00:00 │ 1 │
│ 2024-12-31 23:00:00 │ 2025-01-01 00:00:00 │ 2 │
├─────────────────────┴─────────────────────┴────────────────────┤
│ 8781 rows (6 shown) 3 columns │
└────────────────────────────────────────────────────────────────┘
Hourly Number of Train Services, 2024

Ein anderer Ansatz ist die Funktion time_bucket, die den Zeitstempel auf die angegebene Bucket-Breite abschneidet, beginnend beim angegebenen Offset. Zum Beispiel berechnen wir die Zahl der Fahrten jedes Viertelstunde, beginnend mit 00:

SELECT
time_bucket(
INTERVAL 15 MINUTE, -- bucket width
station_service_time,
INTERVAL 0 MINUTE -- offset
) AS window_start,
window_start + INTERVAL 15 MINUTE as window_end,
count(*) AS number_of_services
FROM ams_traffic_v
WHERE year(station_service_time) = 2024
GROUP BY ALL
ORDER BY 1;
┌─────────────────────┬─────────────────────┬────────────────────┐
│ window_start │ window_end │ number_of_services │
│ timestamp │ timestamp │ int64 │
├─────────────────────┼─────────────────────┼────────────────────┤
│ 2024-01-01 01:30:00 │ 2024-01-01 01:45:00 │ 1 │
│ 2024-01-01 01:45:00 │ 2024-01-01 02:00:00 │ 1 │
│ 2024-01-01 02:15:00 │ 2024-01-01 02:30:00 │ 2 │
│ · │ · │ · │
│ · │ · │ · │
│ · │ · │ · │
│ 2024-12-31 20:45:00 │ 2024-12-31 21:00:00 │ 2 │
│ 2024-12-31 21:00:00 │ 2024-12-31 21:15:00 │ 1 │
│ 2024-12-31 23:45:00 │ 2025-01-01 00:00:00 │ 2 │
├─────────────────────┴─────────────────────┴────────────────────┤
│ 32932 rows (6 shown) 3 columns │
└────────────────────────────────────────────────────────────────┘

Die Time-Bucket-Funktion erzeugt die Buckets aus der Zeitstempel-Spalte selbst, in den Zeitreihendaten können Lücken sein. Wie im Ergebnis oben zu sehen, ist der erste Datensatz 2024-01-01 01:30:00, weil es vor diesem Zeitstempel keine Datensätze gibt.

Da Tumbling Windows nicht überlappende Intervalle sind, können wir Zusammenfassungen berechnen, etwa die durchschnittliche Zahl der Fahrten in einem 15-Minuten-Intervall. Interessant: Die Zahl der Zugfahrten ist tagsüber ziemlich stabil, nachts aber deutlich niedriger – sogar in Amsterdam.

Average Number of Train Services, per 15 minutes, 2024

Hopping Windows

Hopping Windows sind festgroße Zeitintervalle, überlappen im Gegensatz zu Tumbling Windows aber. Ein Hopping Window ist definiert durch:

Ein Use Case für Hopping Windows ist, die fünf geschäftigsten 15-Minuten-Perioden (Window Size) in 2024 zu identifizieren, beginnend alle 5 Minuten (Hopping Size). Wir beginnen damit, künstliche Hopping Windows für alle interessierenden Daten zu erzeugen:

WITH time_range AS (
SELECT
range AS window_start,
window_start + INTERVAL 15 MINUTE AS window_end
FROM range(
'2024-01-01 00:00:00'::TIMESTAMP,
'2025-01-01 00:00:00'::TIMESTAMP,
INTERVAL 5 MINUTE -- hopping size
)
)
┌─────────────────────┬─────────────────────┐
│ window_start │ window_end │
│ timestamp │ timestamp │
├─────────────────────┼─────────────────────┤
│ 2024-01-01 00:00:00 │ 2024-01-01 00:15:00 │
│ 2024-01-01 00:05:00 │ 2024-01-01 00:20:00 │
│ 2024-01-01 00:10:00 │ 2024-01-01 00:25:00 │
│ · │ · │
│ · │ · │
│ · │ · │
│ 2024-12-31 23:45:00 │ 2025-01-01 00:00:00 │
│ 2024-12-31 23:50:00 │ 2025-01-01 00:05:00 │
│ 2024-12-31 23:55:00 │ 2025-01-01 00:10:00 │
├─────────────────────┴─────────────────────┤
│ 105408 rows (6 shown) 2 columns │
└───────────────────────────────────────────┘

Dann joinen wir die obigen Intervalle mit den Zugverkehrsdaten, um die Zahl der Fahrten für jedes [links-geschlossen, rechts-offen) Intervall zu berechnen:

SELECT
window_start,
window_end,
count(service_sk) AS number_of_services
FROM ams_traffic_v
INNER JOIN time_range AS ts
ON station_service_time >= ts.window_start
AND station_service_time < ts.window_end
GROUP BY ALL
ORDER BY 3 DESC, 1 ASC
LIMIT 5;

mit dem Ergebnis:

┌─────────────────────┬─────────────────────┬────────────────────┐
│ window_start │ window_end │ number_of_services │
│ timestamp │ timestamp │ int64 │
├─────────────────────┼─────────────────────┼────────────────────┤
│ 2024-02-17 10:25:00 │ 2024-02-17 10:40:00 │ 28 │
│ 2024-02-17 11:25:00 │ 2024-02-17 11:40:00 │ 28 │
│ 2024-02-17 16:25:00 │ 2024-02-17 16:40:00 │ 28 │
│ 2024-02-17 09:25:00 │ 2024-02-17 09:40:00 │ 27 │
│ 2024-02-17 12:25:00 │ 2024-02-17 12:40:00 │ 27 │
└─────────────────────┴─────────────────────┴────────────────────┘

Können Sie sich vorstellen, wie es im Stellwerk gewesen sein muss, als innerhalb von 15 Minuten 28 Züge in einem Bahnhof mit 15 Gleisen ankamen oder abfuhren?

Mit einem RIGHT OUTER JOIN in der obigen Abfrage werden Lücken mit 0 Fahrten gefüllt.

Sliding Windows

Sliding Windows sind überlappende Intervalle, werden im Vergleich zu Hopping Windows aber dynamisch aus der analysierten Zeitspalte erzeugt und ändern sich daher, wenn neue Datensätze eingefügt werden. Sliding Windows lassen sich mit RANGE-Window-Framing umsetzen:

SELECT
station_service_time - INTERVAL 15 MINUTE AS window_start, -- window size
station_service_time AS window_end,
count(service_sk) OVER (
ORDER BY station_service_time
RANGE
BETWEEN INTERVAL 15 MINUTE PRECEDING -- window size
AND CURRENT ROW
) AS number_of_services
FROM ams_traffic_v
ORDER BY 3 DESC, 1
LIMIT 5;
┌─────────────────────┬─────────────────────┬────────────────────┐
│ window_start │ window_end │ number_of_services │
│ timestamp │ timestamp │ int64 │
├─────────────────────┼─────────────────────┼────────────────────┤
│ 2024-02-17 11:25:00 │ 2024-02-17 11:40:00 │ 29 │
│ 2024-02-17 10:24:00 │ 2024-02-17 10:39:00 │ 28 │
│ 2024-02-17 11:18:00 │ 2024-02-17 11:33:00 │ 28 │
│ 2024-02-17 11:18:00 │ 2024-02-17 11:33:00 │ 28 │
│ 2024-02-17 11:23:00 │ 2024-02-17 11:38:00 │ 28 │
└─────────────────────┴─────────────────────┴────────────────────┘

Weil die aktuelle Zeile in die Berechnung einbezogen wird, sind Sliding Windows [links-geschlossen, rechts-geschlossen].

Session Windows

Ein Session Window gruppiert Ereignisse, die zeitlich nah beieinanderliegen, getrennt durch Inaktivitätslücken. Eine neue Session beginnt, wenn die Zeit zwischen zwei Ereignissen ein definiertes Timeout überschreitet. Der häufigste Use Case von Session Windows ist, Lücken in den zeitgestempelten Daten zu erkennen.

Wir setzen die Datenanalyse fort, indem wir die Tage identifizieren, an denen es Zeiträume von mehr als 10 Minuten gab, in denen kein Zug am Amsterdam Centraal ankam oder abfuhr. In diesem Kontext ist ein Session Window der Zeitraum, in dem Zugfahrten ohne eine Inaktivitätslücke länger als 10 Minuten laufen.

Wir beginnen damit, für jeden Datensatz die vorherige Fahrtzeit mit der Window-Funktion lag zu berechnen. Oben haben wir gesehen, dass nachts fast kein Verkehr ist, daher nehmen wir nur Fahrten zwischen 6 und 23 Uhr:

SELECT
service_sk,
station_service_time,
lag(station_service_time) OVER (
PARTITION BY station_service_time::DATE
ORDER BY station_service_time
) AS previous_service_time,
date_diff('minute', previous_service_time, station_service_time) AS gap_minutes
FROM ams_traffic_v
WHERE hour(station_service_time) BETWEEN 6 AND 23

In der obigen Abfrage berechnen wir auch die Lücke in Minuten zwischen der aktuellen und der vorherigen Fahrt mit date_diff. Gibt es keine vorherige Fahrt, ist die Spalte NULL und bezeichnet die erste Fahrt-Session des Tages:

┌──────────────────────┬───────────────────────┬─────────────┐
│ station_service_time │ previous_service_time │ gap_minutes │
│ timestamp │ timestamp │ int64 │
├──────────────────────┼───────────────────────┼─────────────┤
│ 2024-01-09 06:00:00 │ NULL │ NULL │
│ 2024-01-16 06:00:00 │ NULL │ NULL │
│ 2024-01-22 06:00:00 │ NULL │ NULL │
│ · │ · │ · │
│ · │ · │ · │
│ · │ · │ · │
│ 2024-11-28 06:01:00 │ NULL │ NULL │
│ 2024-12-05 06:01:00 │ NULL │ NULL │
│ 2024-12-23 06:00:00 │ NULL │ NULL │
├──────────────────────┴───────────────────────┴─────────────┤
│ 366 rows (6 shown) 3 columns │
└────────────────────────────────────────────────────────────┘

Tipp: Weil gap_minutes auf einer Window-Funktion basiert, können wir mit QUALIFY darauf filtern, z. B.: QUALIFY gap_minutes IS NULL

Dann markieren wir, ob der aktuelle Datensatz in derselben Session wie der vorherige ist, indem wir die verstrichenen Minuten mit einem Timeout vergleichen, in unserem Fall 10 Minuten:

IF(gap_minutes >= 10 OR gap_minutes IS NULL, 1, 0) AS new_session

Mit einer gleitenden Summe auf Tagesebene über das Attribut new_session weisen wir der Session eine Kennung zu:

sum(new_session) OVER (
PARTITION BY station_service_date
ORDER BY station_service_time ROWS UNBOUNDED PRECEDING
) AS session_id_in_day

Alles zusammen können wir jetzt die Daten holen, die mindestens eine 10-Minuten-Inaktivitätslücke während der 18 Stunden Tagesverkehr (die Stunden zwischen 6 und 23 Uhr) hatten:

WITH ams_daily_traffic AS (
SELECT
service_sk,
station_service_time,
lag(station_service_time) OVER (
PARTITION BY station_service_time::DATE
ORDER BY station_service_time
) AS previous_service_time,
date_diff('minute', previous_service_time, station_service_time) AS gap_minutes
FROM ams_traffic_v
WHERE hour(station_service_time) BETWEEN 6 AND 23
), window_calculation AS (
SELECT
service_sk,
station_service_time,
station_service_time::DATE AS station_service_date,
gap_minutes,
IF(gap_minutes >= 10 OR gap_minutes IS NULL, 1, 0) new_session,
sum(new_session) OVER (
PARTITION BY station_service_date
ORDER BY station_service_time ROWS UNBOUNDED PRECEDING
) AS session_id_in_day
FROM ams_daily_traffic
), session_window AS (
SELECT
station_service_date,
session_id_in_day,
max(gap_minutes) AS gap_minutes,
min(station_service_time) AS window_start,
max(station_service_time) AS window_end,
count(service_sk) AS number_of_services
FROM window_calculation
GROUP BY ALL
)
SELECT
station_service_date,
max(ceil(date_diff('minute', window_start, window_end) / 60)) AS number_of_hours_without_gap,
count(*) AS number_of_sessions,
sum(number_of_services) as number_of_services,
FROM session_window
GROUP BY ALL
HAVING number_of_hours_without_gap < 18
ORDER BY 2, 1;
┌──────────────────────┬─────────────────────────────┬────────────────────┬────────────────────┐
│ station_service_date │ number_of_hours_without_gap │ number_of_sessions │ number_of_services │
│ date │ double │ int64 │ int128 │
├──────────────────────┼─────────────────────────────┼────────────────────┼────────────────────┤
│ 2024-04-29 │ 7.0 │ 12 │ 521 │
│ 2024-12-31 │ 14.0 │ 6 │ 946 │
│ 2024-01-01 │ 16.0 │ 6 │ 847 │
│ 2024-04-30 │ 16.0 │ 7 │ 645 │
│ 2024-04-14 │ 17.0 │ 3 │ 1289 │
│ 2024-05-01 │ 17.0 │ 5 │ 788 │
│ 2024-05-02 │ 17.0 │ 3 │ 729 │
│ 2024-05-03 │ 17.0 │ 5 │ 699 │
│ 2024-05-04 │ 17.0 │ 3 │ 907 │
│ 2024-05-19 │ 17.0 │ 3 │ 837 │
│ 2024-10-28 │ 17.0 │ 2 │ 748 │
│ 2024-10-29 │ 17.0 │ 2 │ 785 │
│ 2024-10-30 │ 17.0 │ 2 │ 783 │
│ 2024-11-02 │ 17.0 │ 2 │ 654 │
├──────────────────────┴─────────────────────────────┴────────────────────┴────────────────────┤
│ 14 rows 4 columns │
└──────────────────────────────────────────────────────────────────────────────────────────────┘

Am 29. April 2024 muss etwas passiert sein! Wir sehen, dass es während der 18 Betriebsstunden 12 Session Windows gab, das heißt mindestens zehnmal kam oder fuhr 10 Minuten lang kein Zug. Ein Grund könnte sein, dass an dem Tag ein regulärer Zugverkehr nicht lief. Und tatsächlich begannen Wartungsarbeiten zwischen Amsterdam und Utrecht.

Train Service Windows on 29 April 2024

Tipp: Zeitfenster werden mit Plotly-Timeline-Charts visualisiert, einer Art Gantt-Diagramm.

Fazit

In diesem Beitrag haben wir gezeigt, wie sich Stream-Windowing-Funktionen auf historische zeitgestempelte Daten in DuckDB umsetzen lassen – ein Ausgangspunkt für Zeit-(reihen-)Datenanalyse. Wir empfehlen auch „Catching up with Windowing“, einen Beitrag zu DuckDBs Windowing-Features, die in den hier vorgestellten Funktionen übernommen werden können.