Skip to article frontmatterSkip to article content

On part des données publiques qui décrivent le trafic des vélos sur le pont de Fremont (à Portland - Oregon)

URL = "https://data.seattle.gov/api/views/65db-xm6k/rows.csv?accessType=DOWNLOAD"
import pandas as pd
# on a déja le fichier en local
local_file = "data/fremont.csv"

pour information, voici le code qu’on a utilisé pour aller chercher la donnée

# ceci nécessite alors
# %pip install requests
from pathlib import Path

if Path(local_file).exists():
    print(f"le fichier {local_file} est déjà là")
else:
    print(f"allons chercher le fichier {local_file}")

    import requests
    req = requests.get(URL)

    # doit afficher 200
    print(req.status_code)

    # on sauve tel quel dans le fichier local
    with open(local_file, 'w') as writer:
        writer.write(req.text)
le fichier data/fremont.csv est déjà là
!head $local_file
Date,Fremont Bridge Total,Fremont Bridge East Sidewalk,Fremont Bridge West Sidewalk
08/01/2022 12:00:00 AM,23,7,16
08/01/2022 01:00:00 AM,12,5,7
08/01/2022 02:00:00 AM,3,0,3
08/01/2022 03:00:00 AM,5,2,3
08/01/2022 04:00:00 AM,10,2,8
08/01/2022 05:00:00 AM,27,5,22
08/01/2022 06:00:00 AM,100,43,57
08/01/2022 07:00:00 AM,219,90,129
08/01/2022 08:00:00 AM,335,143,192

chargement

%pip install seaborn
Defaulting to user installation because normal site-packages is not writeable
Requirement already satisfied: seaborn in /home/runner/.local/lib/python3.12/site-packages (0.13.2)
Requirement already satisfied: numpy!=1.24.0,>=1.20 in /home/runner/.local/lib/python3.12/site-packages (from seaborn) (2.2.6)
Requirement already satisfied: pandas>=1.2 in /home/runner/.local/lib/python3.12/site-packages (from seaborn) (2.3.0)
Requirement already satisfied: matplotlib!=3.6.1,>=3.4 in /home/runner/.local/lib/python3.12/site-packages (from seaborn) (3.10.3)
Requirement already satisfied: contourpy>=1.0.1 in /home/runner/.local/lib/python3.12/site-packages (from matplotlib!=3.6.1,>=3.4->seaborn) (1.3.2)
Requirement already satisfied: cycler>=0.10 in /home/runner/.local/lib/python3.12/site-packages (from matplotlib!=3.6.1,>=3.4->seaborn) (0.12.1)
Requirement already satisfied: fonttools>=4.22.0 in /home/runner/.local/lib/python3.12/site-packages (from matplotlib!=3.6.1,>=3.4->seaborn) (4.58.1)
Requirement already satisfied: kiwisolver>=1.3.1 in /home/runner/.local/lib/python3.12/site-packages (from matplotlib!=3.6.1,>=3.4->seaborn) (1.4.8)
Requirement already satisfied: packaging>=20.0 in /usr/lib/python3/dist-packages (from matplotlib!=3.6.1,>=3.4->seaborn) (24.0)
Requirement already satisfied: pillow>=8 in /home/runner/.local/lib/python3.12/site-packages (from matplotlib!=3.6.1,>=3.4->seaborn) (11.2.1)
Requirement already satisfied: pyparsing>=2.3.1 in /usr/lib/python3/dist-packages (from matplotlib!=3.6.1,>=3.4->seaborn) (3.1.1)
Requirement already satisfied: python-dateutil>=2.7 in /usr/lib/python3/dist-packages (from matplotlib!=3.6.1,>=3.4->seaborn) (2.8.2)
Requirement already satisfied: pytz>=2020.1 in /usr/lib/python3/dist-packages (from pandas>=1.2->seaborn) (2024.1)
Requirement already satisfied: tzdata>=2022.7 in /home/runner/.local/lib/python3.12/site-packages (from pandas>=1.2->seaborn) (2025.2)
Note: you may need to restart the kernel to use updated packages.
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
# version naïve
data = pd.read_csv(local_file); data.shape
(175200, 4)
data.head()
Loading...

doublons

en fait ce qu’il se passe c’est que c’est un peu le bazar ce dataset, et que les données sont principalement présentes en deux exemplaires !

!grep '01/01/2014 12:00:00 AM' $local_file
01/01/2014 12:00:00 AM,23,5,18
01/01/2014 12:00:00 AM,23,5,18

du coup on nettoie

data.drop_duplicates(inplace=True)
data.shape
(87600, 4)

parser les dates

# intéressant aussi, pour voir notamment les points manquants
data.info();
<class 'pandas.core.frame.DataFrame'>
Index: 87600 entries, 0 to 87599
Data columns (total 4 columns):
 #   Column                        Non-Null Count  Dtype  
---  ------                        --------------  -----  
 0   Date                          87600 non-null  object 
 1   Fremont Bridge Total          87586 non-null  float64
 2   Fremont Bridge East Sidewalk  87586 non-null  float64
 3   Fremont Bridge West Sidewalk  87586 non-null  float64
dtypes: float64(3), object(1)
memory usage: 3.3+ MB
# ou tout simplement
data.dtypes
Date object Fremont Bridge Total float64 Fremont Bridge East Sidewalk float64 Fremont Bridge West Sidewalk float64 dtype: object

bref le point ici c’est que les dates sont des chaines et pas des dates

# la version lente

# avec cette forme, on demanderait à read_csv:
# de mettre la date comme index,
# et de parser les dates
# mais on ne va pas le faire car
# 1. c'est peu sûr
# 2. c'est trop lent

# data = pd.read_csv(local_file, index_col='Date', parse_dates=True); data.head()
# une meilleure idée, pour améliorer ces deux points d'un coup
# est de fournir nous-même le format des dates

data.index = pd.to_datetime(data.Date, format="%m/%d/%Y %I:%M:%S %p")
del data['Date']
data.head()
Loading...

renommons les colonnes

# les noms de colonne ne sont pas pratiques du tout
data.columns = ['Total', 'West', 'East']

données manquantes et extension types

de manière totalement optionnelle, mais on remarque que les nombres ont été convertis en flottants

et ça c’est parce qu’il y a eu quelques interruptions de service, apparemment, avec le système de récolte de l’information

data[data['Total'].isna()]
Loading...
# ou encore si on préfère

data[data.isna().any(axis=1)].shape
(14, 3)

on pourrait nettoyer, mais ici on va choisir d’ignorer ces données manquantes; à la place on va les remettre sous la forme d’entiers

# ceci va nous permettre d'avoir des colonnes d'entiers - avec des nan

data = data.convert_dtypes(convert_integer=True)
data.head()
Loading...
# ça ressemble à ceci

data.dtypes
Total Int64 West Int64 East Int64 dtype: object
# on a toujours les n/a, mais ce n'est pas grave
data[data.isna().any(axis=1)].shape
(14, 3)

à quoi ça ressemble

%matplotlib inline

# no longer working ?
# plt.style.use('seaborn')

sns.set(rc={'figure.figsize': (12, 4)})
#plt.rcParams["figure.figsize"] = (12, 4)
# un premier jet, pas terrible du tout

data[['East', 'West']].plot();
<Figure size 1200x400 with 1 Axes>

ajustement

# c'est plus lisible avec seulement un point par semaine
# on pourrait faire la moyenne aussi bien sûr,
# ça donnerait le même dessin mais avec les Y divisés par 7

# le point c'est qu'on a quelques années de plus que sur la vidéo

data.resample('W').sum().plot();
<Figure size 1200x400 with 1 Axes>

juste pour être en phase (pouvoir vérifier nos résultats par rapport à ceux de la vidéo), on va s’arrêter à la fin de 2017

(un détail à noter aussi, les données de la vidéo ne contenaient pas la colonne ‘total’...)

# c'est facile de couper, la date correpond à l'index de la df
# et grâce au type 'datetime' on peut simplement faire une comparaison

data = data[data.index.year <= 2017]
data.tail(3)
Loading...
data.resample('W').sum().plot();
<Figure size 1200x400 with 1 Axes>

resample() ?

décortiquons un peu cette histoire de resample()

data.shape
(45984, 3)
# la forme du resample() est de:
data.resample('1W').sum().shape
(274, 3)
# on vérifie que la version resamplée a bien 
# 7 * 24 = 168 fois moins d'entrées que la version brute
# puisqu'on a une mesure par heure et qu'on ré-échatillonne sur une semaine

(full, _), (resampled, _) = data.shape, data.resample('1W').sum().shape

full / resampled , 7 * 24
(167.82481751824818, 168)

reprenons

data.resample('1W').sum().plot();
<Figure size 1200x400 with 1 Axes>
# la somme sur une fenêtre tournante d'un an

# mais : méfiez-vous de l'échelle des Y

data.resample('1D').sum().rolling(365).sum().plot();
<Figure size 1200x400 with 1 Axes>
# on fait en sorte que le bas de l'échelle des Y soit bien 0
# pour eviter l'effet de loupe

ax = data.resample('1D').sum().rolling(365).sum().plot()
ax.set_ylim(0, None);
<Figure size 1200x400 with 1 Axes>
# regardons la tendance des profils journaliers en moyenne

data.groupby(data.index.time).mean().plot();
<Figure size 1200x400 with 1 Axes>
# mais pour y voir un peu mieux on veut afficher les jours 
# individuellement les uns des autres

# on veut donc dessiner autant de courbes que de jours
# et chaque courbe a en X l'heure de la journée et en Y le nombre (total) de passages

# pour ça on calcule une pivot table
# une courbe par jour: les colonnes sont les jours
# en X les heures: les lignes sont les heures

# ça pourrait se faire à coups de groupby/unstack
# mais c'est quand même plus simple comme ceci

pivoted = data.pivot_table('Total', index=data.index.time, columns=data.index.date)
# regardons le coin en haut à gauche

pivoted.iloc[:5, :5]
Loading...
# on confirme les dimensions 

pivoted.shape
(24, 1916)
# du coup on n'a plus qu'à dessiner
# l'astuce qui tue c'est alpha=0.01 pour éviter les gros patés

pivoted.plot(legend=False, alpha=0.01);
<Figure size 1200x400 with 1 Axes>

classification

ici il s’agit de classifier les jours en deux familles, qu’on voit très distinctement sur la figure

on veut faire une ACP sur un tableau qui aurait

et donc c’est presque exactement pivoted, sauf que c’est sa transposée !

pivoted.T.shape
(1916, 24)
# ! pip install sklearn
from sklearn.decomposition import PCA
# on transpose, et on remplit les trous avec des 0
pca_input = pivoted.T.fillna(0)

# on utilise le PCA de scikit-learn comme une boite noire
pca_output = PCA(2, svd_solver='full').fit_transform(pca_input)
# on obtient un tableau numpy avec deux colonnes seulement
# car on a demandé les deux premières composantes principales

type(pca_output), pca_output.shape
(numpy.ndarray, (1916, 2))
# on voit effectivement que cet ACP semble bien séparer deux clusters

plt.scatter(pca_output[:, 0], pca_output[:, 1], marker='.', color='green');
<Figure size 1200x400 with 1 Axes>
# pour les trouver ces deux clusters, Jake utilise une GaussianMixture

from sklearn.mixture import GaussianMixture
gmm = GaussianMixture(2)

# c'est ici que tout se passe
labels = gmm.fit(pca_input).predict(pca_input)


# la sortie est une association jour -> type
labels.shape, labels
((1916,), array([0, 0, 0, ..., 0, 1, 1], shape=(1916,)))
# cette prédiction est bien en phase avec les deux clusters de tout à l'heure

plt.scatter(pca_output[:, 0], pca_output[:, 1], c=labels, cmap='rainbow')
plt.colorbar();
<Figure size 1200x400 with 2 Axes>

première famille : label==0

# pour vérifier notre classification on peut redessiner
# les jours classés label==0 

# ça correspond donc aux jours de la semaine (à moins que ce soit l'inverse..)

pivoted.loc[:, labels==0].plot(legend=False, alpha=0.01);
<Figure size 1200x400 with 1 Axes>

deuxième famille label==1

# et les jours classés label==1

pivoted.loc[:, labels==1].plot(legend=False, alpha=0.01);
<Figure size 1200x400 with 1 Axes>

les deux clusters avec le jour de la semaine

essayons de vérifier que les deux clusters correspondent bien à l’intuition de départ

pour ça on redessine les deux clusters avec une couleur qui indique le jour de la semaine

# notre index horizontal n'est pas de type DatetimeIndex
pivoted.columns
Index([2012-10-03, 2012-10-04, 2012-10-05, 2012-10-06, 2012-10-07, 2012-10-08, 2012-10-09, 2012-10-10, 2012-10-11, 2012-10-12, ... 2017-12-22, 2017-12-23, 2017-12-24, 2017-12-25, 2017-12-26, 2017-12-27, 2017-12-28, 2017-12-29, 2017-12-30, 2017-12-31], dtype='object', length=1916)
# un index qui contient toutes nos dates et de type DatetimeIndex
dates = pd.DatetimeIndex(pivoted.columns)
# ceci nous calcule un index sur les jours
# mais avec comme valeur 0 pour le lundi, ... et 6 pour le dimanche

dayofweek = pd.DatetimeIndex(pivoted.columns).dayofweek

dayofweek.shape, dayofweek
((1916,), Index([2, 3, 4, 5, 6, 0, 1, 2, 3, 4, ... 4, 5, 6, 0, 1, 2, 3, 4, 5, 6], dtype='int32', length=1916))
# qu'on va utiliser pour mettre les jours en couleur
# les jours de weekend sont en orange et rouge

plt.scatter(pca_output[:, 0], pca_output[:, 1], c=dayofweek, cmap='rainbow')
# pour la légende
plt.colorbar();
<Figure size 1200x400 with 2 Axes>

les moutons noirs

# on remarque dans le cluster rouge-orange
# des jours d'une couleur qui jure

# pour comprendre à quoi ils correspondent 

odd_index = (labels == 1) & (dayofweek < 5)
odd_index.shape, odd_index
((1916,), array([False, False, False, ..., False, False, False], shape=(1916,)))
# afficher les 48 jours qui sont dans cette catégorie

# comme on peut s'y attendre
# on y retrouve les jours fériés (4 juillet, Noel, ...)

odd_dates = dates[odd_index]
odd_dates, len(odd_dates)
(DatetimeIndex(['2012-11-22', '2012-11-23', '2012-12-24', '2012-12-25', '2013-01-01', '2013-05-27', '2013-07-04', '2013-07-05', '2013-09-02', '2013-11-28', '2013-11-29', '2013-12-20', '2013-12-24', '2013-12-25', '2014-01-01', '2014-04-23', '2014-05-26', '2014-07-04', '2014-09-01', '2014-11-27', '2014-11-28', '2014-12-24', '2014-12-25', '2014-12-26', '2015-01-01', '2015-05-25', '2015-07-03', '2015-09-07', '2015-11-26', '2015-11-27', '2015-12-24', '2015-12-25', '2016-01-01', '2016-05-30', '2016-07-04', '2016-09-05', '2016-11-24', '2016-11-25', '2016-12-26', '2017-01-02', '2017-02-06', '2017-05-29', '2017-07-04', '2017-09-04', '2017-11-23', '2017-11-24', '2017-12-25', '2017-12-26'], dtype='datetime64[ns]', freq=None), 48)
# pour rafficher seulement ces jours-là

pivoted[odd_dates].plot(legend=False, alpha=0.3);
<Figure size 1200x400 with 1 Axes>