D5 – Mini-projet : une série temporelle¶

Python numérique – devoir n° 5 – à rendre pour le dimanche 18 octobre 2026, 23 h 59 (la séance 6 est le mardi 20)

Bienvenue dans votre cinquième devoir ! Nous allons y mettre en œuvre les notions vues en séance 5 : to_datetime, DatetimeIndex, la sélection par dates, resample, rolling, df.plot et les figures à plusieurs sous-graphiques avec matplotlib. C'est aussi un mini-projet : la dernière partie est ouverte, et c'est vous qui choisissez la question à laquelle répondre.

Durée indicative : 1 h 30.

{admonition}
:class: important

* Ce notebook reste dans le dossier `D5/` de votre dépôt `pe-homework` (celui que le zip du sujet a créé en se dézippant), avec ses figures à côté, puis il doit être **commité et poussé** (`git add`, `git commit`, `git push`) avant la deadline. Attention, c'est la **date du commit** qui fait foi, pas celle du push ; le nom du fichier, lui, n'a pas d'importance (`sujet.ipynb` convient très bien). Le dossier `data/` livré avec le sujet reste lui aussi dans `D5/` : `git add D5` embarque tout le dossier, et c'est très bien ainsi (il ne pèse que quelques kilo-octets).
* Juste avant de rendre, **pensez à ré-exécuter toutes les cellules** via *Kernel → Restart Kernel and Run All Cells* : toutes doivent s'exécuter dans l'ordre et sans erreur. En effet, un notebook qui plante à mi-chemin sera corrigé jusqu'à l'erreur…
* Le notebook lit les données dans `data/comptage-velos.csv` (chemin relatif) ; la figure `serie.png` est quant à elle écrite dans le dossier du notebook.
* Les cellules `# votre code` sont à compléter ; vous pouvez ajouter autant de cellules que vous le souhaitez. Les cellules de test (`assert`) sont là pour vous aider : si elles passent sans erreur, c'est bon signe. Ne les modifiez pas !
* Répondez aux questions "en français" dans une cellule *Markdown*, en deux ou trois phrases. Dans ce devoir, la rédaction compte autant que le code.
{admonition}
:class: note

* La documentation de `pandas` / `matplotlib`, les notebooks du cours, vos notes.
* Les assistants IA sont tolérés, avec la règle habituelle : **vous devez être capable d'expliquer chaque ligne que vous rendez**, et je pourrai vous demander de le faire à l'oral en séance. Le devoir est individuel.
* En revanche, pas de boucle `for` sur les lignes de la série : tout se fait avec les opérations `pandas`.
{admonition}
:class: warning

`data/comptage-velos.csv` contient le nombre de vélos comptés chaque jour par un compteur automatique installé sur une piste cyclable parisienne, du 1er janvier 2023 au 31 décembre 2024 : une colonne `date` (texte `AAAA-MM-JJ`) et une colonne `comptage`, séparées par `;`.

Attention, **ces données sont simulées** : je les ai produites avec le script `data/generate.py`, en fixant la graine afin que tout le monde travaille sur les mêmes valeurs. Vous pouvez le lire, mais seulement *après* avoir fait le devoir ! Elles ont cependant l'allure de vraies données de comptage, défauts compris : le fichier n'est pas trié, certaines lignes sont en double, certains jours n'ont pas de mesure, et le capteur a eu au moins deux ratés. Ainsi, comme dans la vraie vie, vous allez d'abord nettoyer, puis analyser.

Barème¶

Exercice Contenu Points
1 Chargement, index temporel, diagnostic et nettoyage 6
2 Rééchantillonnage (resample) et lissage (rolling) 4
3 Profils hebdomadaire et mensuel, 2023 vs 2024 4
4 Figure à deux sous-graphiques serie.png 3
5 Question ouverte : une question, une figure, trois lignes de conclusion 3
Total 20
Bonus Jour le plus creux, croissance annuelle +2

Inspiré des exercices de https://numerique-exos.info-mines.paris.

In [1]:
import os
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt

Exercice 1 – Charger, indexer, nettoyer (6 pts)¶

1.a Lisez le fichier dans une DataFrame df et affichez df.info(). Quel est le type de la colonne date ? Pourquoi n'est-ce pas satisfaisant ? Le fichier est-il trié par date ?

In [2]:
# SOLUTION
df = pd.read_csv("data/comptage-velos.csv", sep=";")
df.info()
print("trié :", df["date"].is_monotonic_increasing)
df.head()
<class 'pandas.DataFrame'>
RangeIndex: 734 entries, 0 to 733
Data columns (total 2 columns):
 #   Column    Non-Null Count  Dtype  
---  ------    --------------  -----  
 0   date      734 non-null    str    
 1   comptage  726 non-null    float64
dtypes: float64(1), str(1)
memory usage: 11.6 KB
trié : False
Out[2]:
date comptage
0 2023-01-01 304.0
1 2023-01-02 1658.0
2 2023-01-03 1450.0
3 2023-01-04 1902.0
4 2023-01-05 1715.0
In [3]:
assert df.shape == (734, 2)
assert list(df.columns) == ["date", "comptage"]
print("ok")
ok

1.b Construisez maintenant la Series brut : les comptages indexés par la date, avec un vrai DatetimeIndex, triée par date et en flottants.

In [4]:
# SOLUTION
df["date"] = pd.to_datetime(df["date"])
brut = df.sort_values("date").set_index("date")["comptage"].astype(float)
brut.head()
Out[4]:
date
2023-01-01     304.0
2023-01-02    1658.0
2023-01-03    1450.0
2023-01-04    1902.0
2023-01-05    1715.0
Name: comptage, dtype: float64
In [5]:
assert isinstance(brut, pd.Series)
assert isinstance(brut.index, pd.DatetimeIndex)
assert brut.index.is_monotonic_increasing
assert len(brut) == 734
assert brut.index[0] == pd.Timestamp("2023-01-01") and brut.index[-1] == pd.Timestamp("2024-12-31")
print("ok")
ok
{admonition}
:class: note

`serie.duplicated()` — ou `index.duplicated()` — renvoie un tableau de booléens de même longueur, avec `True` sur les valeurs *déjà rencontrées plus haut* : la première occurrence vaut `False`, les suivantes `True`. Ainsi, sur `[3, 1, 3, 3]`, on obtient `[False, False, True, True]`. Combiné à `~`, il permet donc de ne garder que la première occurrence de chaque valeur.

1.c Place au diagnostic. Répondez d'abord par du code, puis résumez vos observations dans une cellule Markdown :

  • combien de dates apparaissent en double ? lesquelles ?
  • combien de valeurs manquantes ? quels jours ?
  • observez brut.describe() : le minimum et le maximum vous semblent-ils plausibles pour un compteur vélo ? Affichez les jours dont le comptage est nul, et ceux dont le comptage dépasse 3 fois la médiane.
In [6]:
# SOLUTION
doublons = brut.index.duplicated()
print("doublons :", doublons.sum(), list(brut.index[doublons].strftime("%Y-%m-%d")))
print("manquants :", brut.isna().sum(), list(brut.index[brut.isna()].strftime("%Y-%m-%d")))
print(brut.describe().round(0))
mediane = brut.median()
print("nuls :"); print(brut[brut == 0])
print("> 3 × médiane :"); print(brut[brut > 3 * mediane])
doublons : 3 ['2023-05-25', '2023-12-21', '2024-02-27']
manquants : 8 ['2023-02-23', '2023-06-25', '2023-12-01', '2024-01-09', '2024-02-08', '2024-03-13', '2024-11-17', '2024-11-22']
count      726.0
mean      2455.0
std       1646.0
min          0.0
25%       1770.0
50%       2301.0
75%       3090.0
max      39660.0
Name: comptage, dtype: float64
nuls :
date
2024-06-27    0.0
Name: comptage, dtype: float64
> 3 × médiane :
date
2024-07-04    39660.0
Name: comptage, dtype: float64
In [7]:
assert brut.index.duplicated().sum() == 3
assert brut.isna().sum() == 8
assert (brut == 0).sum() == 1
assert (brut > 3 * brut.median()).sum() == 1
print("ok")
ok
{admonition}
3 doublons (2023-05-25, 2023-12-21, 2024-02-27), 8 jours vides, un 0 le 2024-06-27 (panne) et 39 660 le 2024-07-04 (≈ 10 × la normale). Le maximum "normal" est inférieur à 5 000.
{admonition}
:class: note

`serie.interpolate()` remplace chaque `NaN` par une valeur déduite de ses voisins non manquants, par interpolation linéaire par défaut : sur `[10, NaN, NaN, 40]`, on obtient `[10, 20, 30, 40]`. C'est l'alternative à `fillna` quand les données ont un ordre naturel, comme ici le temps.

1.d Passons au nettoyage. Construisez la série propre velos, en trois temps :

  1. supprimez les doublons d'index en ne gardant que la première occurrence (duplicated()) ;
  2. remplacez les deux valeurs aberrantes (comptage nul ou supérieur à 3 fois la médiane) par NaN ;
  3. remplissez tous les NaN (les 8 jours vides + les 2 aberrants) par interpolation linéaire.

Vérifiez enfin que velos a exactement une valeur par jour du 1er janvier 2023 au 31 décembre 2024 (soit 731 jours), et plus aucun NaN.

In [8]:
# SOLUTION
velos = brut[~brut.index.duplicated()].copy()
aberrant = (velos == 0) | (velos > 3 * velos.median())
velos[aberrant] = np.nan
print("NaN avant interpolation :", velos.isna().sum())
velos = velos.interpolate()
print(len(velos), "jours, NaN :", velos.isna().sum(), ", min :", velos.min(), ", max :", velos.max())
NaN avant interpolation : 10
731 jours, NaN : 0 , min : 304.0 , max : 4764.0
In [9]:
assert len(velos) == 731
assert velos.isna().sum() == 0
assert not velos.index.duplicated().any()
assert (velos.index == pd.date_range("2023-01-01", "2024-12-31", freq="D")).all()
assert velos.min() > 100 and velos.max() < 6000
assert abs(velos.loc["2024-07-04"] - (velos.loc["2024-07-03"] + velos.loc["2024-07-05"]) / 2) < 1
print("ok")
ok

Exercice 2 – Rééchantillonner et lisser (4 pts)¶

2.a Calculez :

  • hebdo : la moyenne par semaine ;
  • mensuel : la moyenne par mois (l'alias de fréquence des mois est "ME", month end, depuis pandas 2.2 ; "M" si votre pandas est plus ancien) ;
  • annuel : le total par année.

Combien de lignes ont hebdo et mensuel ? Pourquoi hebdo n'a-t-il pas exactement 731 / 7 lignes ?

In [10]:
# SOLUTION
hebdo = velos.resample("W").mean()
mensuel = velos.resample("ME").mean()
annuel = velos.resample("YE").sum()
print(len(hebdo), len(mensuel), len(annuel))
annuel
106 24 2
Out[10]:
date
2023-12-31    834384.0
2024-12-31    924366.5
Freq: YE-DEC, Name: comptage, dtype: float64
In [11]:
assert len(hebdo) == 106 and len(mensuel) == 24 and len(annuel) == 2
assert abs(mensuel.sum() * 30.4 - velos.sum()) / velos.sum() < 0.05
assert annuel.iloc[1] > annuel.iloc[0]
print("ok")
ok
{admonition}
731 jours = 104 semaines + 3 jours ; les semaines de `resample("W")` se terminent le dimanche, et la première (1er janvier 2023, un dimanche) comme la dernière sont incomplètes : 106 lignes.

2.b Calculez lisse, la moyenne glissante centrée sur 7 jours. Combien de NaN contient-elle, et où ? Pourquoi 7 jours est-il un bon choix de fenêtre pour cette série ? Enfin, tracez velos (trait fin, transparent) et lisse sur le même graphique, avec la méthode .plot() des Series.

In [12]:
# SOLUTION
lisse = velos.rolling(7, center=True).mean()
print("NaN :", lisse.isna().sum(), "->", list(lisse.index[lisse.isna()].strftime("%Y-%m-%d")))
ax = velos.plot(figsize=(11, 4), lw=0.6, alpha=0.5, label="journalier")
lisse.plot(ax=ax, lw=1.5, label="moyenne glissante 7 j")
ax.set_ylabel("vélos / jour"); ax.set_xlabel("")
ax.legend(); ax.set_title("Comptage journalier et lissage")
plt.show()
NaN : 6 -> ['2023-01-01', '2023-01-02', '2023-01-03', '2024-12-29', '2024-12-30', '2024-12-31']
No description has been provided for this image
In [13]:
assert len(lisse) == 731
assert lisse.isna().sum() == 6
assert abs(lisse.loc["2023-06-15"] - velos.loc["2023-06-12":"2023-06-18"].mean()) < 1e-9
print("ok")
ok
{admonition}
6 `NaN` : les 3 premiers et 3 derniers jours (la fenêtre centrée déborde). 7 jours = une semaine entière : chaque fenêtre contient exactement un week-end, ce qui gomme le cycle hebdomadaire sans déformer la saisonnalité.

Exercice 3 – Profils (4 pts)¶

3.a Commençons par le profil hebdomadaire : calculez profil_semaine, la moyenne des comptages pour chaque jour de la semaine, avec 0 = lundi … 6 = dimanche. Remplacez ensuite l'index 0..6 par les noms des jours ("lun", "mar", … "dim"). De combien (en %) le dimanche est-il plus bas que la moyenne des jours de semaine (lundi à vendredi) ?

In [14]:
# SOLUTION
profil_semaine = velos.groupby(velos.index.dayofweek).mean()
profil_semaine.index = ["lun", "mar", "mer", "jeu", "ven", "sam", "dim"]
semaine = profil_semaine.iloc[:5].mean()
print(profil_semaine.round(0))
print(f"dimanche : {100 * (profil_semaine['dim'] / semaine - 1):.0f} % par rapport à lundi-vendredi")
lun    2694.0
mar    2869.0
mer    2878.0
jeu    2782.0
ven    2595.0
sam    1640.0
dim    1386.0
Name: comptage, dtype: float64
dimanche : -50 % par rapport à lundi-vendredi
In [15]:
assert profil_semaine.shape == (7,)
assert list(profil_semaine.index) == ["lun", "mar", "mer", "jeu", "ven", "sam", "dim"]
assert profil_semaine["sam"] < 0.75 * profil_semaine["lun"]
assert profil_semaine["dim"] < profil_semaine["sam"]
print("ok")
ok
{admonition}
:class: note

`serie.idxmax()` renvoie l'**étiquette** de la plus grande valeur (ici un numéro de mois), alors que `serie.max()` renvoie la valeur elle-même ; `idxmin` fait de même pour la plus petite. Sur une série indexée par des dates, on récupère donc directement le `Timestamp` du maximum.

3.b Intéressons-nous maintenant à la saisonnalité : calculez profil_mois, la moyenne pour chaque mois de l'année (1 à 12), les deux années confondues. Quel est le mois le plus chargé ? Et que se passe-t-il en août ?

In [16]:
# SOLUTION
profil_mois = velos.groupby(velos.index.month).mean()
print(profil_mois.round(0))
print("mois le plus chargé :", profil_mois.idxmax())
date
1     1548.0
2     1845.0
3     2144.0
4     2585.0
5     3013.0
6     3313.0
7     3285.0
8     2536.0
9     2694.0
10    2309.0
11    1924.0
12    1658.0
Name: comptage, dtype: float64
mois le plus chargé : 6
In [17]:
assert profil_mois.shape == (12,) and list(profil_mois.index) == list(range(1, 13))
assert profil_mois.idxmax() in (6, 7)
assert profil_mois[8] < profil_mois[7] and profil_mois[8] < profil_mois[9]
assert profil_mois.idxmin() in (1, 12)
print("ok")
ok

3.c Opposons enfin 2023 à 2024 : avec la sélection par chaîne de date, calculez la moyenne journalière de chaque année, puis la croissance en % de 2023 à 2024 (variable croissance). Posez-vous ensuite la même question mois par mois : construisez la table par_mois de forme (12, 2) (lignes = mois, colonnes = années) — unstack peut aider — et repérez le mois où la progression a été la plus forte.

In [18]:
# SOLUTION
m2023 = velos.loc["2023"].mean()
m2024 = velos.loc["2024"].mean()
croissance = 100 * (m2024 / m2023 - 1)
print(f"2023 : {m2023:.0f}/jour, 2024 : {m2024:.0f}/jour, croissance : {croissance:.1f} %")

par_mois = velos.groupby([velos.index.year, velos.index.month]).mean().unstack(0)
progression = 100 * (par_mois[2024] / par_mois[2023] - 1)
print("progression la plus forte : mois", progression.idxmax(), f"({progression.max():.1f} %)")
par_mois.round(0)
2023 : 2286/jour, 2024 : 2526/jour, croissance : 10.5 %
progression la plus forte : mois 4 (16.0 %)
Out[18]:
date 2023 2024
date
1 1452.0 1644.0
2 1755.0 1932.0
3 2054.0 2235.0
4 2393.0 2777.0
5 2850.0 3176.0
6 3223.0 3403.0
7 3092.0 3479.0
8 2454.0 2617.0
9 2537.0 2850.0
10 2232.0 2387.0
11 1816.0 2032.0
12 1549.0 1767.0
In [19]:
assert 7 < croissance < 14
assert par_mois.shape == (12, 2)
assert list(par_mois.columns) == [2023, 2024]
assert (par_mois[2024] > par_mois[2023]).sum() >= 10
print("ok")
ok

Exercice 4 – Figure à deux sous-graphiques (3 pts)¶

Construisez une figure fig, (ax_haut, ax_bas) = plt.subplots(2, 1, figsize=(11, 8)) :

  • en haut : la série journalière velos (trait fin, transparent) et la série lissée lisse (trait plus épais), avec une légende, un titre et un nom d'axe des ordonnées ;
  • en bas : le profil hebdomadaire profil_semaine en barres, avec un titre et un nom d'axe des ordonnées.

Terminez par fig.tight_layout() et enregistrez dans serie.png (fig.savefig).

In [20]:
# SOLUTION
fig, (ax_haut, ax_bas) = plt.subplots(2, 1, figsize=(11, 8))

velos.plot(ax=ax_haut, lw=0.6, alpha=0.5, label="journalier")
lisse.plot(ax=ax_haut, lw=1.8, label="moyenne glissante 7 jours")
ax_haut.set_title("Compteur vélo : comptage journalier 2023-2024")
ax_haut.set_ylabel("vélos / jour"); ax_haut.set_xlabel("")
ax_haut.legend()

profil_semaine.plot.bar(ax=ax_bas, rot=0, color="tab:green")
ax_bas.set_title("Profil hebdomadaire (moyenne par jour de la semaine)")
ax_bas.set_ylabel("vélos / jour")

fig.tight_layout()
fig.savefig("serie.png", dpi=120)
plt.show()
No description has been provided for this image
In [21]:
assert os.path.exists("serie.png")
print("ok")
ok

Exercice 5 – Question ouverte (3 pts)¶

C'est ici que vous prenez la main ! Choisissez une question sur cette série, puis répondez-y avec une figure (au moins une courbe ou un diagramme, légendée et titrée) et trois lignes de conclusion en Markdown. Voici quelques idées, mais une question à vous est la bienvenue :

  • le creux d'août a-t-il la même ampleur en 2023 et en 2024 ?
  • l'écart week-end / semaine est-il le même en été et en hiver ?
  • quelle semaine de l'année est la plus chargée ? la plus creuse ? est-ce la même les deux années ?
  • la croissance entre 2023 et 2024 est-elle uniforme sur l'année, ou concentrée sur certains mois ?
  • que se passe-t-il autour des jours fériés que vous connaissez ?

Côté barème, je compte un point pour la pertinence de la méthode, un point pour une figure lisible et correcte, et un point pour une conclusion chiffrée et honnête.

In [22]:
# SOLUTION
# Exemple : la croissance 2023 -> 2024 est-elle uniforme sur l'année ?
ax = par_mois.plot.bar(figsize=(10, 4), rot=0)
ax.set_title("Moyenne journalière par mois : 2023 vs 2024")
ax.set_xlabel("mois"); ax.set_ylabel("vélos / jour")
ax.legend(title="année")
plt.show()

ax = progression.plot.bar(figsize=(10, 3), rot=0, color="tab:orange")
ax.axhline(croissance, color="k", ls="--", label=f"croissance annuelle {croissance:.1f} %")
ax.set_title("Progression 2024 / 2023, mois par mois (%)")
ax.set_xlabel("mois"); ax.legend()
plt.show()
No description has been provided for this image
No description has been provided for this image
{admonition}
Exemple de conclusion attendue : « La croissance annuelle est d'environ 11 %, mais elle varie de mois en mois (de quelques % à plus de 15 %) : avec une seule année de recul et un bruit journalier de l'ordre de 8 %, les écarts entre mois ne sont pas tous significatifs ». On valorise une conclusion qui cite des chiffres et reconnaît les limites (série simulée, 2 ans seulement).

Bonus (+2)¶

Bo.1 Quel est le jour le plus creux de toute la série ? Et le plus chargé ? Pour chacun, donnez le jour de la semaine (.day_name()) et proposez une explication.

{admonition}
:class: note

`serie.pct_change()` renvoie la variation *relative* d'une valeur à la suivante : sur `[100, 110, 99]`, elle donne `[NaN, 0.10, -0.10]`. La première case vaut `NaN` faute de valeur précédente, et le résultat est une proportion, à multiplier par 100 pour l'exprimer en pourcentage.

Bo.2 Calculez la croissance annuelle en % à partir des totaux annuels annuel. Pourquoi ce chiffre est-il légèrement différent de croissance (exercice 3.c) ?

In [23]:
# SOLUTION
creux, pic = velos.idxmin(), velos.idxmax()
print("plus creux :", creux.date(), creux.day_name(), velos.min())
print("plus chargé :", pic.date(), pic.day_name(), velos.max())
print("croissance des totaux annuels :", (100 * annuel.pct_change()).round(2).iloc[-1], "%")
print("croissance des moyennes journalières :", round(croissance, 2), "%")
plus creux : 2023-01-01 Sunday 304.0
plus chargé : 2024-07-18 Thursday 4764.0
croissance des totaux annuels : 10.78 %
croissance des moyennes journalières : 10.48 %
{admonition}
Le jour le plus creux est le dimanche 1er janvier 2023 (jour férié + dimanche + plein hiver) ; le plus chargé, un jour de semaine de juillet 2024. Les totaux annuels comparent 366 jours à 365, d'où ≈ 0,3 point de différence avec la comparaison des moyennes journalières.

Bon courage à tous ! Pour toute question sur le sujet, vous pouvez me contacter par email : raphael@imagine-app.fr.

Au plaisir de vous lire,

votre responsable de cours, Raphael Prasquier (sujet conçu par Aurélien Noce)