Hvor mange av sykkelstativene er tomme?

Denne notisblokken fungerer som grunnlaget for saken TKTK, som belyser at en stor andel av bysykkelstativene i Oslo stort sett står tomme.

Data

Vi laget et Python-script som hentet ned data fra Oslo bysykkels API hvert 15. minutt 16. til 26. august 2018. Vi satte en timestamp for hver gang vi hentet ned dataene, og lagret disse dataene til en stor JSON-fil.

I JSON-filene ligger dataene lagret som objekter, hvor nøkkelen er en timestamp, og verdien er en array med rådataene fra Oslo bysykkels API.

Oppsett

Vi bruker pakkene shapely, shapely_geojson, matplotlib, pandas og seaborn utover standardbibliotekene til Python 3.

In [1]:
%%javascript
IPython.OutputArea.auto_scroll_threshold = 1000;
In [2]:
import matplotlib.pyplot as plt
%matplotlib inline

from IPython.display import display, Markdown

import seaborn; seaborn.set()

import json, datetime

import pandas as pd
pd.set_option("display.max_rows",240)

from shapely.geometry import Point
from shapely_geojson import dumps, Feature, FeatureCollection

def convert_ts(ts):
    dt = datetime.datetime.fromtimestamp(ts)
    return dt.isoformat() + 'Z'

Datainnhenting og strukturering

Vi laster inn dataene fra skrapejobben, og lager tomme objekter og arrays som skal inneholde de ulike masserte utgavene av dataene

In [3]:
j = json.load(open('../data/bysykkel.json'))

Vi traverserer over rådataene og strukturerer dem i ulike bøtter. stativer brukes for å restrukturere rådataene til et format som passer bedre for GeoJSON, et format tilpasset analyse i kartverktøy som programmet QGIS. features er en array av GeoJSON-features som kan brukes til QGIS-analyse. structured er en struktur som passer fint for å få dataene inn i pandas.

In [4]:
stativer = {}
features = []
structured = []

for key, items in j.items():
    for item in items:
        if not 'lat' in item.keys():
            continue
        structured.append([float(key), item['title'], item['id'], item['lat'], item['lng'], item['availability']['bikes'], item['availability']['locks']])
        if not item['id'] in stativer.keys():
            stativer[item['id']] = [{'time': key, 'data': item}]
            continue
        stativer[item['id']].append({'time': key, 'data': item})
In [5]:
for bike_rack, items in stativer.items():
    for item in items:
        if not 'lat' in item['data']:
            continue
        tmp = Feature(Point(item['data']['lng'], item['data']['lat']), {"name": item['data']['title'], "timestamp": convert_ts(float(item['time'])), "id": bike_rack, "bikes": item['data']['availability']['bikes'], "locks": item['data']['availability']['locks']})
        features.append(tmp)
        
In [6]:
feature_collection = FeatureCollection(features)
In [7]:
with open('bysykkel_total.geojson', 'w') as fp:
    fp.write(dumps(feature_collection))

Lemp dataene inn i pandas for videre analyse. pandas er fint, og gjør det enkelt å lage noen hjelpsomme kolonner fra dataene. Vi lager følgende kolonner:

  • time restruktureres til en datetime-kolonne
  • date lager en datokolonne fra `time``
  • hour er timen på døgnet for den aktuelle raden
  • day_of_week er et tall mellom 0-6 for hvilken ukedag det er snakk om (mandag = 0)
  • hour_minute er HH:MM som en tekst-streng
In [8]:
df = pd.DataFrame(structured, columns="time title id lat lng bikes locks".split())

df['time'] = pd.to_datetime(df['time'], unit="s")
df['date'] = df['time'].dt.date
df['hour'] = df['time'].dt.hour
df['day_of_week'] = df['time'].dt.dayofweek
df['hour_minute'] = df.time.apply(lambda x: "%s:%s" % (x.hour, x.minute))

pandas lager vanligvis en indeks på dataene som lempes inn, men vi setter time-kolonnen til indeks for å enkelt kunne skrelle vekk data som vi ikke ønsker.

Vi startet innhøstingen onsdag 15. august, midt på dag, så vi dropper alt som er før 16. august, og alt som er etter 26. august.

In [9]:
df = df.set_index('time')

df = df['2018-08-16':]
df = df[:'2018-08-26']

Bysykkel-systemet er stengt mellom midnatt og 06:00, så vi skreller bort alle rader i de tidsrommene fra dataframen vår. Etter at det er gjort resetter vi indeksen tilbake til ID, siden pandas sliter med en del spørringer der indeksene ikke er unike.

In [10]:
# Dropp rader mellom midnatt og 06:00. Da er bysykkel-tjenesten stengt
df = df.between_time('06:00', '00:01')
df = df.reset_index()

Vi lager to subframes som inneholder henholdsvis ukedager og helger (weekdays og weekends), for å kunne analysere hvorvidt det er store forskjeller i tilgjengelighet mellom disse.

In [11]:
weekdays = df.loc[df.day_of_week < 5]
weekends = df.loc[df.day_of_week >= 5]

Hjelpefunksjoner

lookup_day og random_sample er to hjelpefunksjoner som lager hhv. dags- og fulldatagrafer over antall ledige sykler og låser på et tilfeldig utvalg av stativer.

sample_size avgjør hvor mange stativer som plukkes ut av datasettet.

Funksjonene show_day og show_total viser tilgjengelige sykkellåser og sykler i grafer. 'lookup_day' gir en graf per dag.

In [12]:
def lookup_day(day="Sunday", sample_size=10):
    for i, sample in df.sample(n=sample_size).iterrows():
        display(Markdown("# %s" % sample['title']))
        grouped_by_date = df.loc[df['id'] == sample['id']].groupby(df['date'])
        for name, group in grouped_by_date:
            if not list(group['time'].dt.weekday_name)[0] == day:
                continue
            #display(Markdown("## %s (%s)" % (name, list(group['time'].dt.weekday_name)[0])))
            plot = group[['hour', 'bikes', 'locks']].plot(kind="line", x='hour', title="%s (%s)" % (name, list(group['time'].dt.weekday_name)[0]), ylim=(-10,max(df.bikes.max(), df.locks.max())), figsize=(15,6))
            plt.show()    

def random_sample(sample_size=3):
    for i, sample in df.sample(n=sample_size).iterrows():
        display(Markdown("# %s" % sample['title']))
        grouped_by_date = df.loc[df['id'] == sample['id']].groupby(df['date'])
        for name, group in grouped_by_date:
            #display(Markdown("## %s (%s)" % (name, list(group['time'].dt.weekday_name)[0])))
            plot = group[['hour', 'bikes', 'locks']].plot(kind="line", x='hour', title="%s (%s)" % (name, list(group['time'].dt.weekday_name)[0]), xlim=(0,23), ylim=(-10,max(df.bikes.max(), df.locks.max())), figsize=(15,6))
            plt.show()    
            
def show_day(stativnavn="Grenseveien"):
    """
    Generate daily plots of number of bikes and locks on a location.
    """
    display(Markdown("# %s" % stativnavn))
    tmp = df.loc[df['title'] == stativnavn]
    grouped_by_date = tmp.groupby(df['date'])
    for name, group in grouped_by_date:
        g = group
        group[['hour', 'bikes', 'locks']].plot(x='hour', xlim=(6,24), title="%s (%s)" % (name, list(g['time'].dt.weekday_name)[0]), figsize=(15,7))
        plt.show()
        
def show_total(stativ):
    """
    Display a total plot of the bikes and locks on a location.
    """
    tmp = df.loc[df['title'] == stativ][['time', 'bikes', 'locks']].plot(x='time', title=stativ, figsize=(15,7))
    plt.show()

Analyse av datasettet

Hvor ofte er stativene tomme?

Det er altså en rekke stativer som er ofte tomme, men hvor stor andel av tiden er de tomme og hvilke stativer er verst?

Vi grupperer dataene etter stativets ID, og sjekker hvor stor del av tiden det aktuelle stativet har sykler.

In [13]:
tomme_stativer = []

# Grupperer datasettet etter id. 
for i, group in df.groupby('id'):
    tmp = group['bikes'].value_counts(normalize=True).sort_index()
    tomme_stativer.append([int(group['id'].values[0]), group['title'].values[0], group['lat'].values[0], group['lng'].values[0], tmp.values[0]*100])

tomme_stativer_df = pd.DataFrame(tomme_stativer, columns="id title lat lng empty".split())

tomme_stativer_df = tomme_stativer_df.sort_values('empty', ascending=False)

Antall stativer som er tomme 50% av tiden eller mer:

In [14]:
tomme_stativer_df.loc[tomme_stativer_df['empty'] >= 50]['id'].count()
Out[14]:
101

Antall stativer som er tomme under 50% av tiden:

In [15]:
tomme_stativer_df.loc[tomme_stativer_df['empty'] < 50]['id'].count()
Out[15]:
130

Liste over de ti stativene som er oftest tomme i Oslo:

In [16]:
tomme_stativer_df.sort_values('empty', ascending=False).head(10)
Out[16]:
id title lat lng empty
173 388 Thereses gate 59.926147 10.731059 85.951469
210 429 Grenseveien 59.924586 10.781721 85.568327
103 269 Ullevålsalléen 59.937928 10.730480 85.185185
147 349 Majorstuen t-bane 59.929663 10.714832 84.674330
177 393 Gaustad T-bane 59.945994 10.710444 83.014049
99 265 Carl Berners plass nord 59.926931 10.777049 82.247765
62 224 Lindern 59.935912 10.735061 81.098340
76 239 Hasle 59.924335 10.792799 80.970626
211 431 Økernveien 59.916732 10.779951 80.204342
59 221 Tannlegehøyskolen 59.932357 10.741542 79.310345

Liste over de stativene som er sjeldnest tomme i Oslo

In [17]:
tomme_stativer_df.sort_values('empty', ascending=True).head(10)
Out[17]:
id title lat lng empty
3 160 Sjøsiden vest 59.910215 10.751687 0.255428
6 163 Vaterlandsparken 59.913485 10.757406 0.383142
196 412 Skøyen stasjon 59.922224 10.679452 0.383142
150 353 Olav Vs gate 59.912966 10.732183 0.921659
91 256 Huk 59.898121 10.675141 1.149425
154 359 Tøyenbekken 59.911609 10.761874 1.915709
83 247 Sjøsiden øst 59.910091 10.752138 1.915709
43 204 Paléhaven 59.910287 10.750012 2.171137
122 294 Torggata 59.915936 10.751573 2.554278
195 411 Hoff terrasse holdeplass 59.927281 10.675572 2.937420

tomme_stativer_df inneholder antall prosent hvor de ulike stativene har 0 tilgjengelige sykler. Vi lager en ny GeoJSON-fil som vi kan bruke for å visualisere hvor ofte stativene er tomme 50% av tiden eller mer.

In [18]:
tomme_stativer_features = []

for id, title, lat, lng, empty in tomme_stativer:
    tmp = Feature(Point(lng, lat), {"name": item['data']['title'],"id": id, "empty": empty})
    tomme_stativer_features.append(tmp)


tomme_stativer_featurecollection = FeatureCollection(tomme_stativer_features)

with open('tomme_stativer.geojson', 'w') as fp:
    fp.write(dumps(tomme_stativer_featurecollection))

Kart eksportert fra QGIS

Det kommer her fram at sentrum av Oslo, Frogner, og Grunerløkka er de stedene hvor syklene er mest tilgjengelige.

Hvordan ser tilgjengeligheten ut for et sykkelstativ over tid?

Thereses gate, som ligger ved St. Hanshaugen, er det stativet som oftest er tomt i Oslo etter vår toppliste. Hvordan ser bruken ut i en graf?

In [19]:
show_total('Thereses gate')

Hva skjer om vi lager en graf for hver dag, får vi vite noe mer da?

Svar: Som oftest er det ingen sykler, men når det kommer noen til stativet forsvinner de ofte innen det har gått én time.

In [20]:
show_day("Thereses gate")

Thereses gate

Hvordan ser det ut på Oslos minst tomme stativ?

Svar: Lars Vaular ville sagt "opp og ned som en sprettball". Stoppet er tomt for sykler en gang, og er noen ganger ingen ledige sykkellåser i helgene.

In [21]:
show_total("Sjøsiden vest")
show_day("Sjøsiden vest")

Sjøsiden vest

I hvilken grad er det en forskjell mellom helg og ukedager?

Svar: Antall stativer med tilgjengelige sykler er høyere i ukedagene enn i helgene.

In [22]:
tomme_stativer_weekends = []

# Grupperer datasettet etter id. 
for i, group in weekends.groupby('id'):
    tmp = group['bikes'].value_counts(normalize=True).sort_index()
    tomme_stativer_weekends.append([int(group['id'].values[0]), group['title'].values[0], group['lat'].values[0], group['lng'].values[0], tmp.values[0]*100])

tomme_stativer_weekends_df = pd.DataFrame(tomme_stativer_weekends, columns="id title lat lng empty".split())
tomme_stativer_weekends_df = tomme_stativer_weekends_df.sort_values('empty', ascending=False)
In [23]:
tomme_stativer_weekdays = []

# Grupperer datasettet etter id. 
for i, group in weekdays.groupby('id'):
    tmp = group['bikes'].value_counts(normalize=True).sort_index()
    tomme_stativer_weekdays.append([int(group['id'].values[0]), group['title'].values[0], group['lat'].values[0], group['lng'].values[0], tmp.values[0]*100])

tomme_stativer_weekdays_df = pd.DataFrame(tomme_stativer_weekdays, columns="id title lat lng empty".split())
tomme_stativer_weekdays_df = tomme_stativer_weekdays_df.sort_values('empty', ascending=False)
In [24]:
tomme_stativer_status = {
    'helger': {
        'tomme mer enn 50% av tiden': tomme_stativer_weekends_df.loc[tomme_stativer_weekends_df['empty'] >= 50]['id'].count(),
        'tomme mindre enn 50% av tiden': tomme_stativer_weekends_df.loc[tomme_stativer_weekends_df['empty'] < 50]['id'].count()
    },
    'ukedager': {
        'tomme mer enn 50% av tiden': tomme_stativer_weekdays_df.loc[tomme_stativer_weekdays_df['empty'] >= 50]['id'].count(),
        'tomme mindre enn 50% av tiden': tomme_stativer_weekdays_df.loc[tomme_stativer_weekdays_df['empty'] < 50]['id'].count()
    }
}

tomme_stativer_status = pd.DataFrame(tomme_stativer_status)
tomme_stativer_status.plot.bar(figsize=(15,8), rot=0)
plt.show()

Antall stativer i helga som er helt tomme 50% av tiden eller mer:

In [25]:
tomme_stativer_weekends_df.loc[tomme_stativer_weekends_df['empty'] >= 50]['id'].count()
Out[25]:
111

Antall stativer i helga som er helt tomme under 50% av tiden:

In [26]:
tomme_stativer_weekends_df.loc[tomme_stativer_weekends_df['empty'] < 50]['id'].count()
Out[26]:
120

Antall stativer i ukedagene som er helt tomme 50% av tiden eller mer:

In [27]:
tomme_stativer_weekdays_df.loc[tomme_stativer_weekdays_df['empty'] >= 50]['id'].count()
Out[27]:
88

Antall stativer i ukedagene som er helt tomme under 50% av tiden:

In [28]:
tomme_stativer_weekdays_df.loc[tomme_stativer_weekdays_df['empty'] < 50]['id'].count()
Out[28]:
143

Hvor mange sykler er det vanligvis i stativet?

Vi tar en random sample på 30 rader fra df, og teller hvor mange ledige sykler det til enhver tid er på de ulike stativene.

In [29]:
for i, sample in df.sample(n=30).iterrows():
    df.loc[df['id'] == sample['id']]['bikes'].value_counts().sort_index().plot(kind='bar', figsize=(15,5), title=sample['title'])
    plt.show()

Svar: Majoriteten av stativene oppgir at de har et høyt antall målinger med null sykler i stativet.

Hvor mange sykkelstativer har på et tidspunkt vært tomme?

In [30]:
all_bikes = df.groupby('id')
In [31]:
all_bikes[['title', 'bikes']].aggregate(min).sort_values('bikes')['bikes'].value_counts()
Out[31]:
0    230
3      1
Name: bikes, dtype: int64

Svar: Alle stativer bortsett fra ett har i løpet av perioden vært tomme.