Analyse données Vélos partagées


In [2]:
import pandas as pd
import matplotlib.pyplot as plt
import numpy as np
import seaborn as sns

%matplotlib inline

On importe les données correspondant au réseau de vélos partagés lyonnais, et on considère cinq semaines de mesures, soit entre le lundi 21 août 2017 et le dimanche 25 septembre 2017.


In [3]:
lyon = pd.read_csv("velodata/lyon.csv", parse_dates = ['last_update'])
lyon = lyon.query("last_update >= '2017-08-21' and last_update < '2017-09-25'")

In [4]:
print(lyon.shape)
lyon.head(10)


(3494564, 9)
Out[4]:
number last_update bike_stands available_bike_stands available_bikes availabilitycode availability bonus status
3867410 10063 2017-08-21 00:03:21 34 22 12 1 Vert Non OPEN
3867417 8015 2017-08-21 00:01:07 16 4 12 1 Vert Non OPEN
3867418 7057 2017-08-21 00:00:52 24 0 23 2 Bleu Non OPEN
3867419 7007 2017-08-21 00:02:30 20 2 18 1 Vert Non OPEN
3867421 1003 2017-08-21 00:02:52 16 14 1 1 Vert Non OPEN
3867423 7056 2017-08-21 00:03:30 22 0 22 2 Bleu Non OPEN
3867427 10031 2017-08-21 00:00:00 22 4 17 1 Vert Non OPEN
3867428 1016 2017-08-21 00:02:58 17 15 2 1 Vert Non OPEN
3867430 10120 2017-08-21 00:00:50 16 0 15 2 Bleu Non OPEN
3867432 10122 2017-08-21 00:00:59 21 18 3 1 Vert Non OPEN

Pour se représenter l'activité d'une station Velov, on affiche une simple chronique temporelle du nombre de vélo disponible sur la station, ainsi que du nombre de stations disponibles. En principe, ces deux grandeurs donnent le nombre d'emplacements total sur la station, mais il peut arriver qu'un ou plusieurs emplacements soient en maintenance ; on conserve tout de même l'idée que le nombre total d'emplacement est une borne supérieure de la somme des vélos et des emplacements disponibles.


In [5]:
def plot_station(data, index):
    """Plot available bikes and bike stands for a given station"""
    station = data.query("number == @index")
    f, ax = plt.subplots(2, 1, figsize=(16, 6))
    ax[0].plot(station.last_update, station.available_bike_stands, 'r-')
    ax[0].set_xlabel("Available bike stands")
    ax[1].plot(station.last_update, station.available_bikes, 'b-')
    ax[1].set_xlabel("Available bikes")

In [6]:
plot_station(lyon, 3085)


Pour la prochaine section (analyse de séries temporelles), on ne considère qu'une station Velov, mais la même analyse peut être réalisée pour n'importe quelle station.


In [7]:
station = lyon.query("number == 3085")
avl_velos = station.set_index("last_update")[['available_bikes', 'bike_stands']].resample("15T")
avl_stands = station.set_index("last_update")[['available_bike_stands', 'bike_stands']].resample("15T")

Analyse de série temporelle avec Pyflux

Une première tentative d'analyse des séries temporelles est réalisée avec le package pyflux, solution standard dans cette optique.


In [8]:
import pyflux as pf

In [26]:
model = pf.ARIMA(avl_velos.reset_index(), 4, 4)

In [27]:
x = model.fit()
x.summary()


Hessian not invertible! Consider a different model specification.

Normal ARIMA(4,0,4)                                                                                       
======================================================= ==================================================
Dependent Variable: last_update                         Method: MLE                                       
Start Date: 4                                           Log Likelihood: -1.02642917433e+31                
End Date: 3359                                          AIC: 2.05285834867e+31                            
Number of observations: 3356                            BIC: 2.05285834867e+31                            
==========================================================================================================
Latent Variable                          Estimate  
======================================== ==========
Constant                                 1.50478515
AR(1)                                    -0.0      
AR(2)                                    -0.0      
AR(3)                                    -0.0      
AR(4)                                    -0.0      
MA(1)                                    1.2844    
MA(2)                                    0.8008    
MA(3)                                    0.3508    
MA(4)                                    0.3609    
Normal Scale                             3.2785    
==========================================================================================================

On obtient ce résultat, donnant la valeur des coefficients propres au modèle ARIMA. Trouver un jeu de paramètres pour lequel la matrice Hessienne est inversible reste toutefois compliqué, et dans ce cas de figure, il est impossible de confronter les valeurs obtenues à des tests statistiques.

On observe également que les coefficients associés à la partie auto-regressive du modèle sont nuls.

Analyse de série temporelle avec Fbprophet

Pour aller plus loin, une autre librairie Python existe : Prophet, dont le code est disponible sur Github.


In [28]:
from fbprophet import Prophet

In [29]:
avl_velos = avl_velos.reset_index()

Inconvénient, il faut absolument que la colonne correspondant aux dates soit nommée ds, et que celle référant aux données mesurées soit nommée y. Une borne supérieure correspondant à la capacité du phénomène étudiée peut être rajoutée, la colonne correspondante devant être nommée cap. Ici, nous sommes dans ce cas, puisque la quantité de vélos disponible par station est bornée par le nombre d'emplacements.


In [30]:
avl_velos.columns = ["ds", "y", "cap"]
data = avl_velos.query("ds < '2017-09-18'")
future = avl_velos.query("ds >= '2017-09-18'")

Un modèle est déclaré selon un mode de croissance linear ou logistic, les différentes composantes de saisonnalité peuvent être précisées également (sans compter que des éléments de saisonnalité supplémentaires peuvent être rajoutés par la suite, cf API).


In [31]:
m = Prophet(growth='logistic',
            daily_seasonality=True, weekly_seasonality=True, yearly_seasonality=False,
            n_changepoints=10)
m.fit(data)
forecast = m.predict(future)

In [54]:
forecast.sample().T


Out[54]:
387
ds 2017-09-22 00:45:00
trend 9.07363
cap 16
trend_lower 9.01413
trend_upper 9.13214
yhat_lower 10.7485
yhat_upper 18.4835
daily 5.74493
daily_lower 5.74493
daily_upper 5.74493
seasonal 5.66422
seasonal_lower 5.66422
seasonal_upper 5.66422
seasonalities 5.66422
seasonalities_lower 5.66422
seasonalities_upper 5.66422
weekly -0.0807163
weekly_lower -0.0807163
weekly_upper -0.0807163
yhat 14.7378

Prophet dispose d'une fonction d'affichage très propre, que l'on peut hacker via l'API de matplotlib (exemple ici : on rajoute les points correspondants aux observations réelles, en vert, à comparer aux prévisions du modèle, en bleu).


In [32]:
mp = m.plot(forecast)
test = mp.get_axes()
test[0].plot(future.ds, future.y, 'go', ms=3)


Out[32]:
[<matplotlib.lines.Line2D at 0x7f9aa2c8e6a0>]

Une autre fonction bien pratique permet d'afficher les composantes de la prévision, en séparant la courbe tendancielle des effets de saisonnalité.


In [53]:
m.plot_components(forecast)


Out[53]:

Malheureusement (au-delà du petit bug qui fait afficher les courbes en double), aucune fonction ne permet de récupérer les données ayant conduit à la génération de ces graphiques... Celle-ci peuvent malgré tout être reconstruites à la main, ce que nous pouvons montrer dans la section suivante.

Récupération des données de saisonnalité

Deux effets de saisonnalité sont cruciaux ici : l'effet de heure de la journée, et celui du jour de la semaine.

Saisonnalité par rapport au jour de la semaine


In [40]:
days_of_week = ["lundi", "mardi", "mercredi", "jeudi", "vendredi", "samedi", "dimanche"]
forecast['day_index'] = forecast['ds'].dt.dayofweek
forecast['day'] = [days_of_week[index] for index in forecast['day_index']]

In [41]:
forecast.groupby("day")['weekly'].describe()


Out[41]:
count mean std min 25% 50% 75% max
day
dimanche 96.0 0.611957 0.380698 -0.168536 0.304280 0.713261 0.966518 1.030979
jeudi 96.0 -0.399275 0.147368 -0.556869 -0.541022 -0.440164 -0.274987 -0.106667
lundi 96.0 -0.700285 0.197797 -0.885890 -0.857400 -0.774606 -0.599679 -0.188066
mardi 96.0 -0.143759 0.275840 -0.679553 -0.378147 -0.076432 0.120569 0.162718
mercredi 96.0 -0.188823 0.227945 -0.535201 -0.402855 -0.189082 0.026432 0.152951
samedi 96.0 0.703759 0.226914 0.324685 0.498764 0.715125 0.917622 1.027005
vendredi 96.0 0.116427 0.112634 -0.100212 0.030222 0.121620 0.204003 0.318664

In [44]:
f, ax = plt.subplots(1, 2, figsize=(16, 6))
sns.boxplot(x=forecast.day, y=forecast.weekly, ax=ax[0])
ax[1].plot(forecast.day_index.drop_duplicates(), forecast.groupby("day_index")["weekly"].mean(), '-', color='#0072B2')
f.tight_layout()


Le résultat sur les jours de semaine montre une utilisation plus intensive des Velov en semaine, et plus particulièrement le lundi. On note toutefois que ce résultat n'est pas équivalent à celui proposé via l'utilisation des fonctions d'affichage du package Prophet (alors même que le calcul à la main utilise la prédiction réalisée via Prophet)...

Saisonnalité due à l'heure de la journée


In [45]:
forecast['hour'] = forecast['ds'].dt.hour

In [46]:
forecast.groupby("hour")['daily'].describe()


Out[46]:
count mean std min 25% 50% 75% max
hour
0 28.0 5.557576 0.156760 5.335344 5.463026 5.574900 5.669450 5.745162
1 28.0 5.803027 0.021823 5.768303 5.796330 5.808667 5.815365 5.826471
2 28.0 5.603812 0.075011 5.509339 5.551603 5.600104 5.652313 5.705702
3 28.0 5.486102 0.023712 5.463959 5.471234 5.477771 5.492638 5.524906
4 28.0 5.676683 0.063720 5.586153 5.637446 5.684919 5.724155 5.750741
5 28.0 5.542592 0.191061 5.251736 5.445250 5.588598 5.685941 5.741436
6 28.0 4.019410 0.702495 3.037048 3.588345 4.080055 4.511119 4.880483
7 28.0 0.730116 1.132970 -0.788023 -0.005069 0.757437 1.492622 2.193614
8 28.0 -3.327598 1.102797 -4.743331 -4.074144 -3.362482 -2.615936 -1.842096
9 28.0 -6.345455 0.571499 -7.015241 -6.757142 -6.421312 -6.009625 -5.523953
10 28.0 -7.142149 0.111810 -7.246594 -7.214882 -7.181035 -7.108302 -6.959932
11 28.0 -6.173632 0.386919 -6.680356 -6.432388 -6.175040 -5.916284 -5.664092
12 28.0 -5.122938 0.160049 -5.370497 -5.201314 -5.075951 -4.997574 -4.969354
13 28.0 -5.378482 0.294114 -5.802235 -5.551485 -5.337353 -5.164350 -5.036987
14 28.0 -6.773006 0.437013 -7.333409 -7.070665 -6.786441 -6.488783 -6.185732
15 28.0 -7.741937 0.084625 -7.833403 -7.796470 -7.762294 -7.707761 -7.609757
16 28.0 -6.751008 0.623582 -7.495342 -7.195828 -6.822019 -6.377199 -5.864651
17 28.0 -3.677652 1.072270 -5.065566 -4.398383 -3.700511 -2.979781 -2.244022
18 28.0 0.091272 1.003870 -1.264677 -0.554177 0.126658 0.772107 1.376448
19 28.0 2.861151 0.550518 2.082665 2.529312 2.918325 3.250164 3.525291
20 28.0 3.967284 0.116318 3.786438 3.912606 4.000634 4.055312 4.081433
21 28.0 4.055970 0.015752 4.041459 4.042335 4.051511 4.065146 4.079398
22 28.0 4.200661 0.116257 4.068536 4.115550 4.181802 4.266912 4.370506
23 28.0 4.838200 0.229014 4.540174 4.684630 4.835140 4.988710 5.142347

In [47]:
f, ax = plt.subplots(1, 2, figsize=(16, 6))
sns.boxplot(x=forecast.hour, y=forecast.daily, ax=ax[0])
ax[1].plot(forecast.hour.drop_duplicates(), forecast.groupby("hour")["daily"].mean(), '-', color='#0072B2')
f.tight_layout()


Le résultat en fonction des heures est aussi extrêmement intéressant : les Velov sont très utilisés en journée, et particulièrement sur des pointes matinales à 10h le matin et 15h l'après-midi. La nuit, la station tend généralement à être remplie. Au contraire des jours de la semaine, ce résultat correspond à celui proposé par le package Prophet.

Generaliser à l'ensemble des stations Velov

On cherche à reproduire le travail précédent sur l'ensemble des stations pour caractériser chacune avec des profils temporels.


In [76]:
station_ids = lyon.number.drop_duplicates()

In [73]:
def daily_pattern(data, station):
    avl_velos = data.query("number==@station").set_index("last_update")['available_bikes'].resample("15T").reset_index()
    avl_velos['hour'] = avl_velos['last_update'].dt.hour
    daily_pattern = avl_velos.groupby("hour")["available_bikes"].mean()
    return daily_pattern

In [75]:
def weekly_pattern(data, station):
    avl_velos = data.query("number==@station").set_index("last_update")['available_bikes'].resample("15T").reset_index()
    avl_velos['day'] = avl_velos['last_update'].dt.dayofweek
    weekly_pattern = avl_velos.groupby("day")["available_bikes"].mean()
    return weekly_pattern

In [78]:
daily_patterns = pd.DataFrame([daily_pattern(lyon, station_id) for station_id in station_ids])
daily_patterns.index = station_ids
weekly_patterns = pd.DataFrame([weekly_pattern(lyon, station_id) for station_id in station_ids])
weekly_patterns.index = station_ids

Clustering sur les stations Velov, par rapport à la disponibilité des vélos


In [86]:
from sklearn.cluster import KMeans

In [88]:
model = KMeans(n_clusters=4, random_state=0)

In [89]:
result = model.fit(weekly_patterns)

In [108]:
weekly_pattern_labels = pd.DataFrame({"label": result.labels_}, index=weekly_patterns.index)

Afficher les stations sur une carte d'après leur cluster d'appartenance


In [93]:
import folium

In [95]:
position = [45.750000, 4.850000] # Lyon lat, lon coordinates

In [145]:
mp = folium.Map(location=position, zoom_start=13, tiles='cartodbpositron')

In [98]:
locations = pd.read_csv("./data/lyon-stations.csv")

In [109]:
locations = locations.merge(weekly_pattern_labels, right_index=True, left_on='idstation')


Out[109]:
idstation nom x y label
0 10027 Mairie de Villeurbanne 4.879894 45.766831 2
1 10030 Greuze 4.893848 45.773844 1
2 10034 MJC 4.886157 45.761788 0
3 10036 Chaplin / Dutriévoz 4.859155 45.774357 0
4 10038 Condorcet / 11 Nov. 1918 4.866778 45.779046 3

In [130]:
locations.query("idstation==3085")


Out[130]:
idstation nom x y label
200 3085 Sacré Coeur 4.868395 45.75783 1

In [100]:
colors = sns.color_palette('Set1', 4)
hex_colors = colors.as_hex()

In [143]:
locations["color"] = [hex_colors[label] for label in locations.label]
for _, row in locations.iterrows():
    plt.plot(row.x, row.y, 'o', color=row.color)



In [146]:
for _,row in locations.iterrows():
    folium.CircleMarker(
        location=[row['x'], row['y']],
        radius=5,
        popup=row['nom'],
        color=hex_colors[row['label']],
        fill=True,
        fill_opacity=0.7,
        fill_color=hex_colors[row['label']]
    ).add_to(mp)

In [147]:
mp


Out[147]: