First Look: Die Daten der Radzählstellen in Bremen

Radzähler_first_look

First Look: Die Daten der Radzählstellen in Bremen

Vorbemerkungen

In Bremen gibt es eine Reihe von Dauerzählstellen für den Radverkehr. Die Bekannteste ist wohl die auf der Wilhelm-Kaisen-Brücke, auch weil hier eine blaue Säule die Zahl der vorbeigefahrenen Fahrradfahrer*innen anzeigt. Doch es gibt weitere, eher unscheinbare Zählstellen. Auf der Webseite der Verkehrsmanagementzentrale (https://vmz.bremen.de/radzaehlstationen) kann man sich die insgesamt 12 Zählstellen ansehen.

Ein Blick auf die Webseite verrät, dass bereits seit 2012 Dauerzählstellen Daten über den Radverkehr erheben. Die Daten werden auf der Webseite nur in einfacher Form dargestellt: Es werden die Werte der letzten 7 und 30 Tage sowie der letzten 25 Monate und die Jahreswerte seit 2012 anzeigt. Eine detaillierte Auswertung und Analyse der Daten liegen bisher nicht vor oder falls doch, sind sie der Öffentlichkeit nicht zugänglich (wenn Google es nicht findet, existiert es nicht). Andere Städte sind hier weiter, zum Beispiel Berlin (https://www.berlin.de/sen/uvk/_assets/verkehr/verkehrsplanung/radverkehr/weitere-radinfrastruktur/zaehlstellen-und-fahrradbarometer/bericht_radverkehr_2018.pdf).

In diesem Artikel werde ich einen ersten Blick auf die Daten der Dauerzählstellen werfen. Dabei werde ich die Daten nur soweit aufbereiten, wie es für diesen ersten Einblick nötig ist. Daher gilt: Alle Angaben hier wurden von mir mit bestem Wissen und Gewissen erstellt, aber ohne Gewähr auf Richtigkeit oder statistische Signifikanz.

Auch werde ich den Weg, wie ich die Daten von der VMZ Webseite heruntergeladen habe, nicht beschreiben. Nur so viel: Ein Browser reicht aus. Ich habe zunächst nur eine kleine „Probe“ der Daten heruntergeladen und die erhaltene Datenmenge hochgerechnet. So konnte ich grob den Umfang des gesamten Datensatzes schätzen, schließlich hatte ich nicht vor, den Server der VMZ mit meiner Anfrage zu crashen oder den Betrieb negativ zu beeinflussen.

Der Datensatz, den ich hier verwende, enthält die stündlichen Messwerte aller 12 Dauerzählstationen vom 1. Januar 2012 bis einschließlich 31. August 2020. Die Daten sind im JSON Format pro Zählstelle circa 2.1 Megabyte groß, für alle Zählstellen umfassen sie circa 25 Megabyte. Damit sind sie überschaubar, zumindest was den verursachten Traffic und den Festplattenplatz betrifft.

Und los geht’s

Zu Beginn werden die nötigen Programmbibliotheken importiert. Während pandas, matplotlib, numpy und seaborn eine Reihe von Bibliotheken für die Datenanalyse darstellen, enthält moinstefko von mir entworfene Funktionen.

In [1]:
from moinstefko import load_files

import pandas as pd
import matplotlib.pyplot as plt
import numpy as np
import seaborn as sns

Für jede Dauerzählstelle liegt eine JSON Datei vor. load_files liest diese ein und legt die Daten als pandas dataframe in einer Liste (data[]) ab.

In [2]:
data = load_files()

Mit data[x] (x = Position in der Liste) lassen sich nun bereits die einzelnen dataframes aufrufen. Von den insgesamt fünf Spalten im dataframe ist jedoch nur eine (values) wirklich interessant. Sie enhält Datum, Uhrzeit und den gemessenen Wert.

In [3]:
data[0].head(3)
Out[3]:
fromDateTime toDateTime resolution stationId values
0 2011-09-05 00:00:00 2020-09-01 23:59:00 hourly 100000575 [2012-01-01 00:00:00, 14]
1 2011-09-05 00:00:00 2020-09-01 23:59:00 hourly 100000575 [2012-01-01 01:00:00, 45]
2 2011-09-05 00:00:00 2020-09-01 23:59:00 hourly 100000575 [2012-01-01 02:00:00, 31]

An dieser Stelle kommt prepare ins Spiel. Die Funktion dient dazu:

  • Datum/Uhrzeit und den Wert aus der Spalte values in separate Spalten zu schreiben
  • die Zeitangaben als Index des dataframes zu setzen
  • unnötige Spalten zu löschen
  • die Spalte values mit einem an prepare übergebenen Namen umzubenennen
In [4]:
def prepare(data, name):
    df = data.join(pd.DataFrame(data['values'].tolist(),index=data.index).add_prefix('B_'))
    df = df.rename(columns={'B_0':'DateTime', 'B_1':'Value'})
    df['Value'] = df['Value'].astype('float64', copy=True)
    pd.to_datetime(df.iloc[:, 5], format='%Y-%m-%d %H:%M:%S').copy()
    df.set_index(pd.DatetimeIndex(df.iloc[:, 5]), inplace=True)
    df = df.drop(columns={'fromDateTime', 'toDateTime', 'resolution', 'stationId', 'values', 'DateTime'})
    df = df.rename(columns={'Value': name})
    return df

Damit einzelne dataframes bei Bedarf gezielt aufgerufen werden können, ohne sich die Position in der Liste merken zu müssen, habe ich zusätzlich neue Namen in Form von Abkürzungen für die dataframes vergeben. Diese werden in einer Liste df_list zusammengefasst, um spätere Berechnungen und Operationen zu vereinfachen.

In [5]:
wkb_o = prepare(data[9],'Wilhelm-Kaisen-Brücke (Ost)')
wkb_w = prepare(data[0], 'Wilhelm-Kaisen-Brücke (West)')
lng_o = prepare(data[1], 'Langemarckstraße (Ostseite)')
lng_w = prepare(data[2], 'Langemarckstraße (Westseite)')
rkw = prepare(data[3],  'Radweg Kleine Weser')
gms_o = prepare(data[4], 'Graf-Moltke-Straße (Ostseite)')
gms_w = prepare(data[5], 'Graf-Moltke-Straße (Westseite)')
shr = prepare(data[6], 'Schwachhauser Ring')
wms_s = prepare(data[7], 'Wachmannstraße auswärts (Süd)')
wms_n = prepare(data[8], 'Wachmannstraße einwärts (Nord)')
ode = prepare(data[10], 'Osterdeich')
hbs =  prepare(data[11], 'Hastedter Brückenstraße')

df_list = [wkb_w, wkb_o, lng_o, lng_w, rkw, gms_o, gms_w, hbs, shr, wms_s, wms_n, ode]

wkb_w.head()zeigt als Ergebnis im Vergleich zu data[0].head() (siehe oben) einen vereinfachten und auf das Nötigste reduzierten dataframe für die Wilhelm-Kaisen-Brücke (West).

In [6]:
wkb_w.head(3)
Out[6]:
Wilhelm-Kaisen-Brücke (West)
DateTime
2012-01-01 00:00:00 14.0
2012-01-01 01:00:00 45.0
2012-01-01 02:00:00 31.0

Summe aller erfassten Werte der Radzählstellen

Mit der Frage, wie viele Radfahrer*innen insgesamt seit 2012 von den Dauerzählstellen gezählt wurden, möchte ich in die Datenauswertung einsteigen. Die Summe aller erfassten Werte berechne ich, indem die Spaltensummen miteinander addiert werden.

Zwischen dem 1.1.2012 und dem 31.8.2020 wurden demnach 97.496.172 Radfahrer*innen gezählt.

In [7]:
sum = 0
for df in df_list:
    dfsum = df.iloc[:, 0].sum()
    sum = sum + dfsum

sum
Out[7]:
97496172.0

Allgemeine deskriptive Werte

Pandas bietet mit pandas.DataFrame.describe eine komfortable Möglichkeit statistische Grundwerte anzuzeigen. Für eine kompakte Darstellung lege ich die Ergebnisse von .describe() in einer Liste (describe_list) ab. Anschließend fasse ich sie in einem dataframe (df_describe) zusammen.

In [8]:
describe_list = []
for df in df_list:
    describe_list.append(df.dropna().describe().T)
df_describe = pd.concat(describe_list)

df_describe
Out[8]:
count mean std min 25% 50% 75% max
Wilhelm-Kaisen-Brücke (West) 75972.0 142.190689 123.316318 0.0 34.0 113.0 221.0 1318.0
Wilhelm-Kaisen-Brücke (Ost) 75951.0 255.485155 208.424853 0.0 69.0 217.0 391.0 1155.0
Langemarckstraße (Ostseite) 75592.0 85.765438 78.530016 0.0 18.0 65.0 137.0 570.0
Langemarckstraße (Westseite) 75828.0 80.659268 95.500776 0.0 18.0 63.0 123.0 4090.0
Radweg Kleine Weser 74127.0 135.769369 138.327091 0.0 18.0 92.0 215.0 866.0
Graf-Moltke-Straße (Ostseite) 75951.0 43.735777 36.089938 0.0 9.0 39.0 70.0 233.0
Graf-Moltke-Straße (Westseite) 75951.0 48.727114 44.342366 0.0 7.0 40.0 81.0 385.0
Hastedter Brückenstraße 75927.0 102.577397 107.301351 0.0 15.0 70.0 155.0 913.0
Schwachhauser Ring 75952.0 66.321032 64.119990 0.0 10.0 49.0 105.0 444.0
Wachmannstraße auswärts (Süd) 73768.0 112.756480 111.823728 0.0 18.0 84.0 170.0 817.0
Wachmannstraße einwärts (Nord) 72399.0 87.527935 103.944977 0.0 8.0 53.0 134.0 823.0
Osterdeich 75927.0 133.321572 115.095261 0.0 35.0 114.0 202.0 4168.0

Spitzenwerte der einzelnen Zählstellen mit Datum/Uhrzeit

Die Spalte max zeigt den höchsten Wert an, der zwischen dem 1.1.2012 und 31.8.2020 von dieser Dauerzählstelle zu einer bestimmten Stunde gemessen wurde und es interessiert mich, zu welchem Zeitpunkt dieser Wert genau auftrat.

Um die zu den jeweiligen Spitzenwerten zugehörigen Zeitangaben zu ermitteln, nutze ich die Funktionen .max() und .idxmax().

In [9]:
max_values = []
max_date = []
for df in df_list:
    max_values.append(df.max())
    max_date.append(df.idxmax())
df_max_values = pd.concat(max_values)
df_max_date = pd.concat(max_date)    
df_max = pd.concat([df_max_values, df_max_date],axis=1)
df_max.rename(columns={0 : 'max', 1 : 'datetime'})
Out[9]:
max datetime
Wilhelm-Kaisen-Brücke (West) 1318.0 2019-02-06 14:00:00
Wilhelm-Kaisen-Brücke (Ost) 1155.0 2015-06-30 08:00:00
Langemarckstraße (Ostseite) 570.0 2018-10-16 08:00:00
Langemarckstraße (Westseite) 4090.0 2018-01-09 07:00:00
Radweg Kleine Weser 866.0 2015-07-01 17:00:00
Graf-Moltke-Straße (Ostseite) 233.0 2018-09-16 17:00:00
Graf-Moltke-Straße (Westseite) 385.0 2014-12-13 14:00:00
Hastedter Brückenstraße 913.0 2014-06-09 16:00:00
Schwachhauser Ring 444.0 2017-08-22 08:00:00
Wachmannstraße auswärts (Süd) 817.0 2017-06-20 17:00:00
Wachmannstraße einwärts (Nord) 823.0 2012-07-23 17:00:00
Osterdeich 4168.0 2018-07-15 18:00:00

An dieser Stelle gucke ich etwas ungläubig auf die Werte. Auf der Langemarckstraße wurden morgens um 7:00 Uhr 4.090 Radfahrer*innen gezählt? Im Januar? Ernsthaft?

Exkurs: Ausreißer

Da mir der Wert von 4.090 gezählten Radfahrer*innen in der Langemarckstraße am 9. Januar 2018 unplausibel erscheint, gehe ich der Sache einmal nach. Die Darstellung der stündlichen Werte in einem Box-Plot vermittelt mir einen schnellen Überblick über die Verteilung der Daten.

In [10]:
lng_w.boxplot()
Out[10]:
<matplotlib.axes._subplots.AxesSubplot at 0x2b894693670>

Wie das Box-Plot vermuten lässt, liegen im Datensatz der Langemarckstraße (West) eine Reihe von Ausreißern vor. Um die zeitliche Position der potentiellen Ausreißer zu bestimmen, stelle ich die stündlichen Werte in einem zeitlichen Verlauf dar.

In [11]:
fig, ax = plt.subplots()
fig.set_size_inches(18.5, 8.5, forward=True)
ax.plot(lng_w, linewidth=0.5, label='stündliche Werte')
ax.set_xlabel('Jahr')
ax.set_ylabel('Anzahl')
ax.set_title('Langemarckstraße (Westseite)')
ax.legend()
Out[11]:
<matplotlib.legend.Legend at 0x2b894867af0>

Die Darstellung des Box-Plots und des zeitlichen Verlaufs zeigt: Die stündlichen Werte bewegen sich größtenteils in einem Bereich unter der 500er Marke und der „Spitzenwert“ im Januar 2018 war nicht der einzige dieser Art. Auch in 2016 und 2017 traten einige sehr große Werte auf.

Da es sich hierbei vermutlich um unplausible Werte handelt, stelle ich eine einfache Berechnung auf. Dazu errechne ich die Summe der stündlichen Werte des 8.1., 9.1 und 10.1.2018.

In [12]:
freaky = lng_w.loc['2018-01-08':'2018-01-10'].sum()
freaky
Out[12]:
Langemarckstraße (Westseite)    34916.0
dtype: float64

Zum Vergleich ziehe ich den Mittelwert (.mean()) der wöchentlichen Summen (.resample('W').sum()) der gesamten Zeitreihe heran.

In [13]:
lng_w.resample('W').sum().mean()
Out[13]:
Langemarckstraße (Westseite)    13471.874449
dtype: float64

Es zeigt sich ein deutlicher Unterschied: In den drei Tagen (8.1-11.1.2018) wurden zweieinhalb mal so viele Radfahrer*innen in der Langemarckstraße (West) gezählt wie im wöchentlichen Durchschnitt über alle Jahre hinweg.

Es kann natürlich vorkommen, dass zu bestimmten Zeiten ein hohes, überdurchschnittliches Maß an Radverkehr auftritt, doch meine Vermutung ist, dass es sich hierbei um unplausible und fehlerhafte Werte handelt.

Zum Abschluss dieses Exkurses bilde ich die Jahressummen für die Langemarckstraße und vergleiche diese mit den Angaben auf der VMZ Webseite.

In [14]:
lng_w.resample('Y').sum()
Out[14]:
Langemarckstraße (Westseite)
DateTime
2012-12-31 500019.0
2013-12-31 622039.0
2014-12-31 704333.0
2015-12-31 666708.0
2016-12-31 866907.0
2017-12-31 782171.0
2018-12-31 790612.0
2019-12-31 735630.0
2020-12-31 447812.0

VMZ Bremen Werte der letzen Jahre -Jahressumme Langemarckstraße (West) (Screenshot vmz.bremen.de (abgerufen 26.8.2020))

Die täglichen Summen und der gleitende Mittelwert über 365 Tage

Die Daten liegen mit stündlichen Werten sehr hochaufgelöst vor und sind damit für eine Darstellung der gesamten Zeitreihe eher ungeeignet. Für die folgenden Diagramme nutze ich daher .resample('D').sum(), um die täglichen Summen zu bilden. Sie werden als blaue Linie im Graph dargestellt.

Um die Zeitreihe zu glätten und so möglicherweise einen Trend erkennen zu können, habe ich mit .rolling().mean() den gleitenden Mittelwert über ein Fenster von 365 Tagen gebildet window=365. Er wird als organge Linie dargestellt.

In [15]:
for df in df_list:
    fig, ax = plt.subplots()
    fig.set_size_inches(18.5, 8.5, forward=True)
    ax.plot(df.iloc[:, 0].resample('D').sum(), linewidth=0.5, label='Täglich')
    ax.plot(df.iloc[:, 0].resample('D').sum().rolling(window=365, center=True, min_periods=360).mean(), linewidth=2, label='365 Tage (gleitender Mittelwert)')
    ax.set_xlabel('Jahr')
    ax.set_ylabel('Anzahl')
    ax.set_title(df.columns.values[0])
    ax.legend()

Gut zu erkennen sind die jahreszeitlichen Unterschiede. In den wärmeren Jahreszeiten wird mehr Fahrrad gefahren als in den kälteren. Überall,aber nicht überall gleich. So sind die Unterschiede zwischen Sommer und Winter an der Hastedter Brückenstraße und dem Radweg Kleine Weser größer als beispielsweise an der Graf-Moltke-Straße.

Interessanterweise zeigen alle Diagramme, mit Ausnahme der Wilhelm-Kaisen-Brücke, zu Beginn des Jahres 2012 eine Nulllinie. Ich vermute, dass nicht alle Dauerzählstellen gleichzeitig mit den Messungen begonnen haben, sondern erst nach und nach in Betrieb genommen wurden. Das erste Auftreten eines Wertes über Null sollte demnach den Beginn der Messungen dokumentieren. Gut zu erkennen sind auch die „Ausfälle“ der Zählstellen (z.B. Wachmannstraße Nord) und deren Einfluss auf die Darstellung des gleitenden Mittelwertes.

Gehen wir noch einen Schritt weiter und schauen uns für die einzelnen Dauerzählstellen an, wie sich das Radverkehrsaufkommen über den Tag verändert.

Durchschnittlicher täglicher Verlauf

Für die folgenden Diagramme habe ich anhand der Tageszeit (df.index.time) die dataframes gruppiert (df.groupby()) und den Mittelwert der Gruppierungen (.mean()) berechnet. Die Diagramme geben wieder, wie viele Radfahrer*innen zu den jeweiligen Tageszeiten im Durchschnitt gezählt wurden.

In [16]:
for df in df_list:
    by_time= df.groupby(df.index.time).mean()
    hourly_ticks = 4 * 60 * 60 * np.arange(6)
    by_time.plot(xticks=hourly_ticks, style='-')

Hohe Werte in den Morgen- und/oder Abendstunden sind ein Indiz dafür, dass zu diesen Zeiten viele Berufstätige oder Schüler mit ihren Fahrrädern unterwegs sind. Besonders gut zu erkennen ist dies im Vergleich der Nord- und Südseite der Wachmannstraße. Morgens geht’s in die Stadt und abends wieder heraus.

Durchschnittlicher Verlauf Werktage (links) und Wochenende (rechts)

Ich möchte noch einen Schritt weiter gehen und den Radverkehr der Werktage dem am Wochenende gegenüberstellen. Dazu werden die Daten anhand des Index‘ in Werktage (df.index < 5) und Wochenende aufgeteilt. Anschließend erfolgt wieder eine zeitliche Gruppierung (.groupby()) und Bestimmung des Mittelwerts (.mean()). Der Unterschied zwischen Wochentagen und Wochenendtagen wird in zwei Graphen nebeneinander dargestellt.

In [18]:
for df in df_list:
    weekend = np.where(df.index.weekday < 5, 'Weekday', 'Weekend')
    by_time = df.groupby([weekend, df.index.time]).mean()
    fig, ax = plt.subplots(1,2)
    fig.set_size_inches(18.5, 6, forward=True)
    by_time.xs('Weekday').plot(ax=ax[0], title='Weekday', xticks=hourly_ticks)
    by_time.xs('Weekend').plot(ax=ax[1], title='Weekend', xticks=hourly_ticks)

Wie sich zeigt, ändert sich das Radverkehrsaufkommen an den Wochenenden im Vergleich zu den Wochentagen. Es gibt keinen Berufs-/ und Schulvekehr, also entallen die morgendlichen Spitzen. An den Wochenenden entwickelt sich das Radverkehrsaufkommen allmählich im Tagesverlauf. Dafür gibt es in den Nachtstunden am Wochenende an einigen Zählstationen mehr Radverkehr als an den Werktagen, z.B. am Osterdeich.

Der Einbruch des Graphen im Diagramm Wilhelm-Kaisen-Bücke (Ost) an den Werktagen wirkt irritierend. Daher nutze ich .between_time(), um mir die Werte, die möglicherweise zwischen 14:01 Uhr und 14:59 Uhr liegen anzugucken. Das Ergebnis zeigt am 27.3.2018 einen Indexeintrag um 14:30 Uhr an.

In [19]:
df_test = wkb_o.between_time('14:01:00', '14:59:00')
df_test
Out[19]:
Wilhelm-Kaisen-Brücke (Ost)
DateTime
2018-03-27 14:30:00 78.0

Anschließend bereinige ich den dataframe und entferne den Wert mittels .drop().

In [20]:
df_test = wkb_o.drop(pd.Timestamp('2018-03-27 14:30:00'))
df_test = df_test.groupby(df_test.index.time).mean()
hourly_ticks = 4 * 60 * 60 * np.arange(6)
df_test.plot(xticks=hourly_ticks, style='-')
Out[20]:
<matplotlib.axes._subplots.AxesSubplot at 0x2b8a68405e0>

Fazit und Ausblick

Ich habe versucht zu zeigen, dass in diesen Daten mehr steckt als ein paar absolute Werte. Die Daten der Bremer Radverkehr-Dauerzählstellen sind sehr umfangreich und in vielerlei Hinsicht interessant und spannend. Wie sich zeigt, sind die Daten jedoch nicht frei von Fehlern. Mir stellt sich dabei vorrangig die Frage, wie man mit unplausiblen Ausreißern und den Nullwerten umgeht. Besonders die Trennung der Nullwerte in plausible Nullwerte und Nullwerte, die möglicherweise einen Ausfall der Zählstelle darstellen, kann sich als schwierig erweisen.

In diesem Sinne muss ich sagen: further research is needed. Ich denke, in naher Zukunft werde ich hierzu ein Update veröffentlichen. Ich hoffe, dann auch etwas belastbarere Zahlen liefern zu können.

Danke

Ich beschäftige mich erst seit ein paar Monaten mit dem Thema Data Science und der Auswertung von Daten mittels Python. Und auch wenn ich denke, dass ich in den letzten Wochen vieles gelernt habe und sich meine Kenntnisse auf diesem Gebiet vervielfältigt haben, gebe ich gerne zu: Nicht alle in diesem Artikel verwendeten Methoden und Workarounds sind von allein meinem Hirn entsprungen.

Ich danke allen Menschen, die sich die Zeit nehmen und die Mühe machen, Tutorials und Codebeispiele ins Netz zu stellen und anderen auf Plattformen wie stackoverflow oder github ihre Unterstützung zukommen lassen.