Project : NYC Yellow Taxi Demand Analysis (2022–2024)

Bouzouita Hayette - Data Analyst

Step 1 : Data Import

Objective :¶

Load the analytical dataset from the data warehouse (mart layer) into a pandas DataFrame for further analysis.

Objectif :¶

Charger le jeu de données analytique depuis la table mart de l’entrepôt de données vers un DataFrame pandas afin de réaliser les analyses.

1.1 – Libraries import¶

In [47]:
import pandas as pd
import numpy as np

from sqlalchemy import create_engine

1.2 – Database connection¶

In [52]:
engine = create_engine(
    "postgresql://postgres:postgres@localhost:5432/taxi"
)

EN / The connection is established directly to the PostgreSQL database hosting the curated data mart.
FR / La connexion est établie directement vers la base PostgreSQL hébergeant le mart de données préparé lors de la phase de data engineering.

1.3 – Load mart table¶

In [57]:
query = """
SELECT *
FROM mart.mart_demand_hourly;
"""

df = pd.read_sql(query, engine)

1.4 – First inspection¶

In [60]:
df.head()
Out[60]:
hour_ts pu_location_id trips avg_trip_distance avg_total_amount pct_cash pct_card
0 2022-01-26 09:00:00 87 21 4.330952 23.917619 19.047619 71.428571
1 2022-01-26 13:00:00 89 1 5.200000 27.000000 0.000000 100.000000
2 2022-01-27 06:00:00 93 1 0.210000 174.360000 0.000000 100.000000
3 2022-01-26 07:00:00 42 8 2.378750 14.601250 62.500000 37.500000
4 2022-01-24 16:00:00 50 42 2.923333 18.084750 30.952381 59.523810

EN / Payment percentages do not always sum to 100%, as some trips correspond to other payment types (e.g., no charge, dispute, unknown).
FR / Les pourcentages de paiement ne totalisent pas toujours 100 %, car certaines courses correspondent à d’autres types de paiement (gratuit, litige, inconnu)

In [62]:
df.shape
Out[62]:
(2819948, 7)

EN / The dataset contains approximately 2.8 million rows, each representing an hourly taxi demand observation for a given pickup zone.
FR / Le jeu de données contient environ 2,8 millions de lignes, chacune représentant une observation de la demande horaire de taxis pour une zone de prise en charge donnée.

In [64]:
df.info()
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 2819948 entries, 0 to 2819947
Data columns (total 7 columns):
 #   Column             Dtype         
---  ------             -----         
 0   hour_ts            datetime64[ns]
 1   pu_location_id     int64         
 2   trips              int64         
 3   avg_trip_distance  float64       
 4   avg_total_amount   float64       
 5   pct_cash           float64       
 6   pct_card           float64       
dtypes: datetime64[ns](1), float64(4), int64(2)
memory usage: 150.6 MB

1.5 – Data scope verification¶

In [69]:
df['hour_ts'].min(), df['hour_ts'].max()
Out[69]:
(Timestamp('2022-01-01 00:00:00'), Timestamp('2024-12-31 23:00:00'))

EN / The data covers the period from 2022 to 2024, ensuring consistency with the project scope.
FR / Les données couvrent la période 2022–2024, conformément au périmètre du projet.

1.6 – Enrich data with taxi zone information¶

EN / Enrich the analytical dataset with human-readable taxi zone information to enable meaningful business analysis.
FR / Enrichir le jeu de données analytique avec des informations de zones de taxi lisibles afin de faciliter l’analyse métier.

In [78]:
zones = pd.read_sql("""
SELECT
    location_id AS pu_location_id,
    borough,
    zone
FROM curated.dim_taxi_zone;
""", engine)
In [80]:
zones.head()
Out[80]:
pu_location_id borough zone
0 1 EWR Newark Airport
1 2 Queens Jamaica Bay
2 3 Bronx Allerton/Pelham Gardens
3 4 Manhattan Alphabet City
4 5 Staten Island Arden Heights

EN / Taxi zones are described at different spatial levels: boroughs provide a macro-level view of the city, while zones offer a finer-grained spatial resolution.
FR / Les zones de taxi sont décrites à différents niveaux spatiaux : les boroughs offrent une vision macroscopique de la ville, tandis que les zones permettent une analyse plus fine.

In [82]:
df = df.merge(
    zones,
    on="pu_location_id",
    how="left"
)
In [84]:
df.head()
Out[84]:
hour_ts pu_location_id trips avg_trip_distance avg_total_amount pct_cash pct_card borough zone
0 2022-01-26 09:00:00 87 21 4.330952 23.917619 19.047619 71.428571 Manhattan Financial District North
1 2022-01-26 13:00:00 89 1 5.200000 27.000000 0.000000 100.000000 Brooklyn Flatbush/Ditmas Park
2 2022-01-27 06:00:00 93 1 0.210000 174.360000 0.000000 100.000000 Queens Flushing Meadows-Corona Park
3 2022-01-26 07:00:00 42 8 2.378750 14.601250 62.500000 37.500000 Manhattan Central Harlem North
4 2022-01-24 16:00:00 50 42 2.923333 18.084750 30.952381 59.523810 Manhattan Clinton West
In [86]:
df['borough'].isna().mean()
Out[86]:
0.0

EN / At this stage, the dataset has been successfully imported and inspected. The data is aggregated at an hourly and spatial level and is ready for analytical preprocessing and exploratory analysis.
FR / À ce stade, le jeu de données a été importé et inspecté avec succès. Les données sont agrégées à un niveau horaire et spatial et sont prêtes pour le prétraitement analytique et l’analyse exploratoire.

Step 2 : Data Cleaning & Preprocessing (Analytical level)

Objective :¶

Ensure data quality and consistency at the analytical level before exploratory analysis and modeling.

Objectif :¶

Garantir la qualité et la cohérence des données au niveau analytique avant l’analyse exploratoire et la modélisation.

2.1 – Missing values (NA check)¶

In [110]:
df.isna().mean().sort_values(ascending=False)
Out[110]:
avg_total_amount     0.000077
hour_ts              0.000000
pu_location_id       0.000000
trips                0.000000
avg_trip_distance    0.000000
pct_cash             0.000000
pct_card             0.000000
borough              0.000000
zone                 0.000000
dtype: float64

EN / Given the extremely low proportion of missing values and the aggregated nature of the variable, avg_total_amount was left unchanged to avoid introducing artificial bias into the data.
FR / Étant donné la proportion extrêmement faible de valeurs manquantes et la nature agrégée de la variable, avg_total_amount a été conservée telle quelle afin d’éviter l’introduction d’un biais artificiel dans les données.

2.2 Target Variable Validation (trips)¶

In [118]:
df['trips'].describe()
Out[118]:
count    2.819948e+06
mean     4.224735e+01
std      7.519024e+01
min      1.000000e+00
25%      1.000000e+00
50%      5.000000e+00
75%      5.000000e+01
max      1.239000e+03
Name: trips, dtype: float64
In [205]:
import matplotlib.pyplot as plt
import seaborn as sns 
plt.figure(figsize=(8,4))
sns.boxplot(x=df['trips'],color='gold')
plt.title("Distribution of hourly taxi trips per zone")
plt.show()
No description has been provided for this image

EN / Extreme values were retained as they reflect genuine high-demand situations rather than data quality issues. Extreme values were considered structural as they are statistically consistent, operationally plausible, and recurrent across specific zones and peak hours.
FR / Les valeurs extrêmes ont été conservées car elles reflètent des situations réelles de forte demande et non des problèmes de qualité des données. Les valeurs extrêmes ont été considérées comme structurelles car elles sont statistiquement cohérentes, plausibles d’un point de vue métier et récurrentes sur certaines zones et périodes de pointe.

In [152]:
df.sort_values('trips', ascending=False).head(10)
Out[152]:
hour_ts pu_location_id trips avg_trip_distance avg_total_amount pct_cash pct_card borough zone pct_sum hour weekday is_weekend month year
2639147 2024-11-03 01:00:00 79 1239 2.428515 22.308139 5.649718 84.907183 Manhattan East Village 90.556901 1 6 True 11 2024
737986 2022-11-06 01:00:00 79 1115 2.931973 18.220752 6.816143 89.955157 Manhattan East Village 96.771300 1 6 True 11 2022
1615645 2023-11-05 01:00:00 79 1106 2.291646 23.086694 6.871609 81.826401 Manhattan East Village 88.698011 1 6 True 11 2023
2639183 2024-11-03 01:00:00 148 879 2.713675 23.967356 4.323094 84.414107 Manhattan Lower East Side 88.737201 1 6 True 11 2024
2639232 2024-11-03 01:00:00 249 862 2.336462 22.369906 6.844548 79.930394 Manhattan West Village 86.774942 1 6 True 11 2024
2037237 2024-04-14 01:00:00 79 849 2.554723 22.068698 4.240283 64.428740 Manhattan East Village 68.669022 1 6 True 4 2024
1896413 2024-02-25 01:00:00 79 846 2.239421 21.546734 5.437352 65.602837 Manhattan East Village 71.040189 1 6 True 2 2024
1615710 2023-11-05 01:00:00 249 837 2.295006 23.515884 4.181601 85.185185 Manhattan West Village 89.366786 1 6 True 11 2023
2780088 2024-12-18 18:00:00 237 830 1.469518 24.069842 8.795181 82.409639 Manhattan Upper East Side South 91.204819 18 2 False 12 2024
1896306 2024-02-25 00:00:00 79 803 2.117572 21.978279 5.479452 67.372354 Manhattan East Village 72.851806 0 6 True 2 2024

EN / A detailed inspection of the highest trip counts confirms that extreme values are concentrated in central Manhattan zones and occur during peak demand hours, supporting their interpretation as genuine high-demand observations.
FR / L’analyse détaillée des valeurs les plus élevées de trajets montre qu’elles sont concentrées dans des zones centrales de Manhattan et surviennent lors de périodes de forte demande, confirmant leur caractère structurel.

2.3 Payment Percentage Consistency Check¶

In [134]:
df['pct_sum'] = df['pct_cash'] + df['pct_card']

df['pct_sum'].describe()
Out[134]:
count    2.819948e+06
mean     8.178977e+01
std      3.035457e+01
min      0.000000e+00
25%      8.000000e+01
50%      9.628099e+01
75%      1.000000e+02
max      1.000000e+02
Name: pct_sum, dtype: float64
In [199]:
plt.figure(figsize=(8,4))
sns.histplot(df['pct_sum'], bins=50, color='gold')
plt.title("Distribution of pct_cash + pct_card")
plt.show()
No description has been provided for this image

EN / The sum of pct_cash and pct_card is equal to 100% for the majority of observations, indicating consistent payment share calculations.
Lower values are expected, as other payment types are not included in the analysis.
Observations with a zero sum correspond to zone-hour combinations where all trips were paid using alternative payment methods.
FR / La somme de pct_cash et pct_card est égale à 100 % pour la majorité des observations, ce qui confirme la cohérence du calcul des parts de paiement.
Les valeurs inférieures à 100 % sont attendues, d’autres modes de paiement n’étant pas inclus dans l’analyse.
Les observations à somme nulle correspondent à des combinaisons zone–heure où les trajets ont été réglés exclusivement via des modes de paiement alternatifs.

In [159]:
df['pct_sum'].between(0, 100).all()
Out[159]:
True

All payment shares fall within valid bounds [0, 100].

2.4 Duplicate Records Verification¶

In [138]:
df.duplicated(subset=['hour_ts', 'pu_location_id']).sum()
Out[138]:
0

EN / No duplicate observations were found at the hour–zone level.
FR / Aucun doublon n’a été détecté au niveau heure–zone.

2.5 Chronological Sorting¶

In [141]:
df = df.sort_values(['hour_ts', 'pu_location_id']).reset_index(drop=True)
In [145]:
df.head()
Out[145]:
hour_ts pu_location_id trips avg_trip_distance avg_total_amount pct_cash pct_card borough zone pct_sum
0 2022-01-01 4 11 3.200000 18.520909 54.545455 45.454545 Manhattan Alphabet City 100.000000
1 2022-01-01 7 6 3.531667 15.353333 50.000000 16.666667 Queens Astoria 66.666667
2 2022-01-01 10 1 7.910000 25.300000 0.000000 100.000000 Queens Baisley Park 100.000000
3 2022-01-01 12 2 6.640000 29.025000 50.000000 50.000000 Manhattan Battery Park 100.000000
4 2022-01-01 13 12 4.325000 22.370000 25.000000 66.666667 Manhattan Battery Park City 91.666667

EN / The dataset was sorted chronologically by timestamp and pickup location identifier to ensure temporal consistency.
This ordering facilitates time-based analysis, feature engineering, and prevents information leakage in downstream modeling steps.
-FR / Le dataset a été trié chronologiquement par horodatage et identifiant de zone de prise en charge afin de garantir la cohérence temporelle.
Ce tri facilite les analyses temporelles, la création de variables temporelles et permet d’éviter toute fuite d’information lors des étapes de modélisation ultérieures.

2.6 Temporal Feature Preparation¶

In [147]:
df['hour'] = df['hour_ts'].dt.hour
df['weekday'] = df['hour_ts'].dt.weekday  # 0 = Monday
df['is_weekend'] = df['weekday'].isin([5, 6])
df['month'] = df['hour_ts'].dt.month
df['year'] = df['hour_ts'].dt.year
In [149]:
df[['hour_ts', 'hour', 'weekday', 'is_weekend', 'month', 'year']].head()
Out[149]:
hour_ts hour weekday is_weekend month year
0 2022-01-01 0 5 True 1 2022
1 2022-01-01 0 5 True 1 2022
2 2022-01-01 0 5 True 1 2022
3 2022-01-01 0 5 True 1 2022
4 2022-01-01 0 5 True 1 2022

EN / These variables enable the analysis of intraday, weekly, and seasonal demand patterns and support time-aware modeling.
FR / Ces variables permettent d’analyser les dynamiques intra-journalières, hebdomadaires et saisonnières de la demande et facilitent les modèles tenant compte du temps.

EN / The dataset has been thoroughly cleaned and validated. No major data quality issues were identified, and no corrective transformations were required at this stage.
FR / Le dataset a été nettoyé et validé de manière approfondie. Aucun problème majeur de qualité des données n’a été identifié et aucune transformation corrective n’a été nécessaire à ce stade.

Step 3 : Exploratory Data Analysis (EDA)

Objective :¶

Explore temporal and spatial patterns of taxi demand in order to better understand customer behavior and demand dynamics.

Objectif :¶

Explorer les dynamiques temporelles et spatiales de la demande de taxis afin de mieux comprendre les comportements clients et la structure de la demande.

3.1 Global Demand Distribution¶

Question¶

EN: How is taxi demand distributed overall?
FR: Comment se répartit globalement la demande de taxis ?

In [191]:
df['trips'].describe()
Out[191]:
count    2.819948e+06
mean     4.224735e+01
std      7.519024e+01
min      1.000000e+00
25%      1.000000e+00
50%      5.000000e+00
75%      5.000000e+01
max      1.239000e+03
Name: trips, dtype: float64
In [197]:
plt.figure(figsize=(8,4))
sns.histplot(df['trips'], bins=50, color='gold')
plt.title("Distribution of Hourly Taxi Demand per Zone")
plt.xlabel("Number of trips")
plt.show()
No description has been provided for this image

EN / The distribution of hourly taxi trips per zone is highly right-skewed.
Most zone–hour observations correspond to low demand levels, while a small number of cases exhibit very high numbers of trips.
This indicates that taxi demand is unevenly distributed across zones and time periods.
FR / La distribution du nombre de trajets horaires par zone est fortement asymétrique à droite.
La majorité des observations zone–heure correspond à des niveaux de demande faibles, tandis qu’un nombre limité de cas présente des volumes de trajets très élevés. Cela montre que la demande de taxis est répartie de manière inégale selon les zones et les périodes.

3.2 Hourly Demand Pattern¶

Question¶

EN: How does demand vary throughout the day?
FR: Comment la demande évolue-t-elle au cours de la journée ?

In [693]:
hourly_pattern = (
    df.groupby('hour')['trips']
      .mean()
      .reset_index()
)

plt.figure(figsize=(10,5))
sns.lineplot(data=hourly_pattern, x='hour', y='trips', marker='o',color='gold')
plt.title("Average Taxi Demand by Hour of Day")
plt.xlabel("Hour of day")
plt.ylabel("Average number of trips")


plt.savefig("averageTaxiDemandByHour.png",dpi=300, bbox_inches="tight")
plt.show()
No description has been provided for this image

EN / Taxi demand follows a clear intraday pattern.
Demand is lowest during the early morning hours, increases steadily after 6 a.m., and peaks in the late afternoon and early evening. After the peak, demand gradually declines toward nighttime.
This pattern highlights the importance of time-of-day effects in taxi demand.
FR / La demande de taxis suit un schéma intra-journalier bien marqué.
Elle est minimale en début de matinée, augmente progressivement à partir de 6 h, puis atteint un pic en fin d’après-midi et en début de soirée.
Après ce pic, la demande diminue progressivement au cours de la nuit.
Ce schéma met en évidence l’importance de l’heure de la journée dans la dynamique de la demande.

3.3 Weekday vs Weekend Demand¶

Question¶

EN: Is taxi demand different on weekends?
FR: La demande est-elle différente le week-end ?

In [209]:
week_pattern = (
    df.groupby('is_weekend')['trips']
      .mean()
      .reset_index()
)

week_pattern['is_weekend'] = week_pattern['is_weekend'].map(
    {False: 'Weekday', True: 'Weekend'}
)

sns.barplot(data=week_pattern, x='is_weekend', y='trips', color='gold')
plt.title("Average Taxi Demand: Weekday vs Weekend")
plt.ylabel("Average number of trips")
plt.show()
No description has been provided for this image

EN / Average taxi demand is slightly higher on weekdays than on weekends.
While demand remains relatively high during weekends, the difference suggests stronger and more consistent taxi usage during working days.
The difference represents approximately a 4–5% increase in average demand on weekdays.
FR / La demande moyenne de taxis est légèrement plus élevée en semaine que le week-end.
Bien que la demande reste soutenue durant le week-end, cet écart suggère une utilisation plus forte et plus régulière des taxis les jours ouvrés.
Cet écart représente environ une augmentation de 4–5 % de la demande moyenne en semaine.

3.4 Spatial Analysis – Demand by Borough¶

Question¶

EN: Which boroughs generate the highest taxi demand?
FR: Quels boroughs génèrent le plus de demande taxi ?

In [211]:
borough_demand = (
    df.groupby('borough')['trips']
      .mean()
      .sort_values(ascending=False)
      .reset_index()
)

plt.figure(figsize=(8,5))
sns.barplot(data=borough_demand, x='trips', y='borough', color='gold')
plt.title("Average Taxi Demand by Borough")
plt.xlabel("Average number of trips")
plt.show()
No description has been provided for this image

EN / Taxi demand varies strongly across boroughs.
Manhattan clearly generates the highest average number of trips, far exceeding other boroughs.
Queens follows at a significantly lower level, while Brooklyn, the Bronx, and Staten Island exhibit much lower average demand.
The EWR category represents airport-related trips outside New York City and shows lower average demand due to its specific and localized usage.
Categories labeled as “Unknown” or “N/A” correspond to records with missing or unmapped borough information and do not represent actual boroughs.
FR / La demande de taxis varie fortement selon les boroughs.
Manhattan génère de loin le plus grand nombre moyen de trajets, largement supérieur aux autres boroughs.
Queens arrive ensuite à un niveau nettement inférieur, tandis que Brooklyn, le Bronx et Staten Island présentent une demande moyenne beaucoup plus faible.
La catégorie EWR correspond aux trajets liés à l’aéroport de Newark, situé en dehors de New York City, et affiche une demande moyenne plus faible en raison de son usage spécifique et localisé.
Les catégories “Unknown” et “N/A” correspondent à des enregistrements dont l’information de borough est manquante ou non renseignée et ne représentent pas des boroughs réels.

image.png

3.5 Top Pickup Zones¶

Question¶

EN: Which pickup zones are the most active?
FR: Quelles zones de prise en charge sont les plus actives ?

In [213]:
top_zones = (
    df.groupby('zone')['trips']
      .mean()
      .sort_values(ascending=False)
      .head(15)
      .reset_index()
)

plt.figure(figsize=(8,6))
sns.barplot(data=top_zones, x='trips', y='zone', color='gold')
plt.title("Top 15 Pickup Zones by Average Demand")
plt.xlabel("Average number of trips")
plt.show()
No description has been provided for this image

EN / Taxi demand is highly concentrated in a limited number of pickup zones.
The most active zones are primarily located in Manhattan, particularly in Midtown and Upper East Side areas, reflecting dense commercial and residential activity.
Major transportation hubs such as JFK Airport and LaGuardia Airport also appear among the top pickup zones, highlighting their role as key demand generators. Airport zones exhibit high average demand due to concentrated passenger flows, whereas central urban zones reflect sustained and diversified taxi usage.
FR / La demande de taxis est fortement concentrée dans un nombre limité de zones de prise en charge.
Les zones les plus actives se situent majoritairement à Manhattan, notamment dans les secteurs de Midtown et de l’Upper East Side, ce qui reflète une forte densité d’activités commerciales et résidentielles.
Les grands pôles de transport tels que les aéroports JFK et LaGuardia figurent également parmi les zones les plus actives, soulignant leur rôle central dans la génération de la demande.
Les zones aéroportuaires présentent une forte demande moyenne en raison de flux de passagers concentrés, tandis que les zones centrales urbaines reflètent une utilisation plus continue et diversifiée des taxis.

image.png

3.6 Demand Evolution Over Time¶

Question¶

EN: How does taxi demand evolve over the 2022–2024 period?
FR: Comment la demande taxi évolue-t-elle entre 2022 et 2024 ?

In [215]:
daily_demand = (
    df.groupby(df['hour_ts'].dt.date)['trips']
      .sum()
      .reset_index()
)

plt.figure(figsize=(12,5))
plt.plot(daily_demand['hour_ts'], daily_demand['trips'],color='gold')
plt.title("Daily Taxi Demand Over Time (2022–2024)")
plt.xlabel("Date")
plt.ylabel("Total number of trips")
plt.show()
No description has been provided for this image

EN / Taxi demand shows a clear upward trend over the 2022–2024 period.
Daily demand fluctuates strongly, displaying regular short-term variations and recurring peaks and troughs.
Overall, demand levels in 2024 appear higher than in 2022, suggesting a gradual recovery and growth over time.
FR / La demande de taxis présente une tendance globale à la hausse sur la période 2022–2024.
La demande quotidienne fluctue fortement, avec des variations régulières à court terme et des pics et creux récurrents.
Globalement, les niveaux de demande observés en 2024 sont plus élevés qu’en 2022, ce qui suggère une reprise et une croissance progressive au fil du temps.

3.7 Key EDA Takeaways - EN¶

  • Taxi demand is highly unevenly distributed, with most zone–hour combinations exhibiting low activity and a small number of cases showing very high demand.

  • Demand follows a strong intraday pattern, with low activity during early morning hours and peak demand occurring in the late afternoon and early evening.

  • Average taxi demand is slightly higher on weekdays than on weekends, indicating a small but consistent weekday effect.

  • Spatial analysis shows that Manhattan dominates taxi demand, far exceeding other boroughs, while Queens plays a secondary role.

  • Taxi activity is highly concentrated in specific pickup zones, particularly central Manhattan areas and major transportation hubs such as JFK and LaGuardia airports.

  • Over the 2022–2024 period, taxi demand exhibits strong short-term fluctuations and a clear upward long-term trend, suggesting a gradual recovery and growth in demand.

3.7 Key EDA Takeaways - FR¶

  • La demande de taxis est très inégalement répartie : la majorité des combinaisons zone–heure présentent une faible activité, tandis qu’un nombre limité de cas affiche des niveaux de demande très élevés.

  • La demande suit un schéma intra-journalier marqué, avec une activité faible en début de matinée et un pic en fin d’après-midi et en début de soirée.

  • La demande moyenne est légèrement plus élevée en semaine que le week-end, ce qui met en évidence un effet semaine modéré mais constant.

  • L’analyse spatiale montre que Manhattan domine largement la demande de taxis, devant les autres boroughs, tandis que Queens occupe une position secondaire.

  • L’activité taxi est fortement concentrée dans certaines zones de prise en charge, notamment les zones centrales de Manhattan et les grands pôles de transport comme les aéroports JFK et LaGuardia.

  • Sur la période 2022–2024, la demande de taxis présente de fortes fluctuations à court terme ainsi qu’une tendance globale à la hausse, suggérant une reprise et une croissance progressives.

Step 4 : Feature Engineering

Objective :¶

Create meaningful features from temporal and historical patterns to improve taxi demand prediction.

Objectif :¶

Créer des variables explicatives pertinentes à partir des dynamiques temporelles et historiques afin d’améliorer la prédiction de la demande de taxis.

4.1 Define Target and Base Features¶

EN / At this stage, we define the prediction target and identify the base features already available in the dataset.
The analysis is conducted at an hourly and pickup-zone level.

  • Target variable: number of taxi trips per hour and per pickup zone
  • Granularity: hour × pickup zone

FR / À cette étape, nous définissons la variable à prédire ainsi que les variables de base déjà disponibles dans le jeu de données.
L’analyse est réalisée à une granularité heure × zone de prise en charge.

  • Variable cible : nombre de courses de taxi par heure et par zone
  • Granularité : heure × zone
In [243]:
target = 'trips'

#base features :  hour, weekday, is_weekend, month, year, borough, zone

4.2 Lag Features (historical demand)¶

EN

Taxi demand exhibits strong temporal autocorrelation: past demand levels are highly informative of future demand.
To capture this behavior, lagged features are created at different time horizons.

The following lag features are considered:

  • 1-hour lag (short-term dependency)
  • 24-hour lag (daily seasonality)
  • 168-hour lag (weekly seasonality)

FR

La demande de taxis présente une forte autocorrélation temporelle : la demande passée est très informative de la demande future.
Afin de capturer ce comportement, des variables de retard (lags) sont créées à différents horizons temporels.

Les retards suivants sont considérés :

  • 1 heure (dépendance court terme)
  • 24 heures (saisonnalité journalière)
  • 168 heures (saisonnalité hebdomadaire)
In [270]:
df = df.sort_values(["zone", "hour_ts"])

for lag in [1, 24, 168]:
    df[f"trips_lag_{lag}h"] = (
        df.groupby("zone")["trips"]
          .shift(lag)
    )
In [280]:
#check 

df[
    ["zone", "hour_ts", "trips", "trips_lag_1h", "trips_lag_24h", "trips_lag_168h"]
]

#les NaN ne sont pas des erreurs , ça veut juste dire "pas assez d'historique" on les supprimera plus tard (4.4)
Out[280]:
zone hour_ts trips trips_lag_1h trips_lag_24h trips_lag_168h
404 Allerton/Pelham Gardens 2022-01-01 04:00:00 1 NaN NaN NaN
1207 Allerton/Pelham Gardens 2022-01-01 13:00:00 1 1.0 NaN NaN
9541 Allerton/Pelham Gardens 2022-01-05 12:00:00 1 1.0 NaN NaN
9649 Allerton/Pelham Gardens 2022-01-05 13:00:00 1 1.0 NaN NaN
10062 Allerton/Pelham Gardens 2022-01-05 17:00:00 1 1.0 NaN NaN
... ... ... ... ... ... ...
2819245 Yorkville West 2024-12-31 19:00:00 151 147.0 51.0 91.0
2819395 Yorkville West 2024-12-31 20:00:00 238 151.0 64.0 101.0
2819577 Yorkville West 2024-12-31 21:00:00 252 238.0 78.0 90.0
2819772 Yorkville West 2024-12-31 22:00:00 196 252.0 58.0 91.0
2819945 Yorkville West 2024-12-31 23:00:00 97 196.0 37.0 53.0

2819948 rows × 6 columns

EN / Lag features were created to capture short-term, daily, and weekly demand dependencies.
FR / Des variables de retard ont été créées afin de capturer les dépendances de la demande à court terme, journalières et hebdomadaires.

4.3 Rolling Statistics (Moving Averages)¶

EN

In addition to lag features, rolling statistics are used to smooth short-term fluctuations and capture local demand trends.
Moving averages help reduce noise and improve model stability.

Two rolling windows are considered:

  • 24 hours (daily trend)
  • 168 hours (weekly trend)

FR

En complément des variables de retard, des statistiques glissantes sont utilisées afin de lisser les fluctuations à court terme et de capturer les tendances locales de la demande.
Les moyennes mobiles permettent de réduire le bruit et d’améliorer la stabilité du modèle.

Deux fenêtres temporelles sont considérées :

  • 24 heures (tendance journalière)
  • 168 heures (tendance hebdomadaire)
In [288]:
df["trips_roll_mean_24h"] = (
    df.groupby("zone")["trips"]
      .rolling(window=24)
      .mean()
      .reset_index(level=0, drop=True)
)

df["trips_roll_mean_168h"] = (
    df.groupby("zone")["trips"]
      .rolling(window=168)
      .mean()
      .reset_index(level=0, drop=True)
)
In [297]:
#check 

df[
    [
        "zone",
        "hour_ts",
        "trips",
        "trips_roll_mean_24h",
        "trips_roll_mean_168h"
    ]
]

#lag = mémoire ponctuelle 
#rolling mean = tendance 
# les 2 sont complémentaires , jamais redondants 

# résultat parfaitement cohérent métier :
#moyenne 24h > moyenne 168h → activité récente plus forte
#rolling mean < valeur instantanée → pic horaire
Out[297]:
zone hour_ts trips trips_roll_mean_24h trips_roll_mean_168h
404 Allerton/Pelham Gardens 2022-01-01 04:00:00 1 NaN NaN
1207 Allerton/Pelham Gardens 2022-01-01 13:00:00 1 NaN NaN
9541 Allerton/Pelham Gardens 2022-01-05 12:00:00 1 NaN NaN
9649 Allerton/Pelham Gardens 2022-01-05 13:00:00 1 NaN NaN
10062 Allerton/Pelham Gardens 2022-01-05 17:00:00 1 NaN NaN
... ... ... ... ... ...
2819245 Yorkville West 2024-12-31 19:00:00 151 66.833333 56.773810
2819395 Yorkville West 2024-12-31 20:00:00 238 74.083333 57.589286
2819577 Yorkville West 2024-12-31 21:00:00 252 81.333333 58.553571
2819772 Yorkville West 2024-12-31 22:00:00 196 87.083333 59.178571
2819945 Yorkville West 2024-12-31 23:00:00 97 89.583333 59.440476

2819948 rows × 5 columns

EN / Rolling mean features were created to capture daily and weekly demand trends while smoothing short-term noise.
FR / Des moyennes mobiles ont été créées afin de capturer les tendances journalières et hebdomadaires de la demande tout en lissant le bruit à court terme.

4.4 Handle NaN Values¶

EN / Missing values observed at this stage are a direct consequence of lag and rolling feature creation.
They correspond to observations with insufficient historical context and are therefore removed to ensure model consistency.

FR / Les valeurs manquantes observées à ce stade sont une conséquence directe de la création des variables de retard et des moyennes mobiles.
Elles correspondent à des observations ne disposant pas d’un historique suffisant et sont donc supprimées afin de garantir la cohérence du modèle.

In [343]:
feature_cols = [
    "hour",
    "weekday",
    "is_weekend",
    "month",
    "trips_lag_1h",
    "trips_lag_24h",
    "trips_lag_168h",
    "trips_roll_mean_24h",
    "trips_roll_mean_168h"
]

df_model = df.dropna(subset=feature_cols + ["trips"])
In [344]:
#check 
df_model.isna().sum()
Out[344]:
hour_ts                   0
pu_location_id            0
trips                     0
avg_trip_distance         0
avg_total_amount        213
pct_cash                  0
pct_card                  0
borough                   0
zone                      0
pct_sum                   0
hour                      0
weekday                   0
is_weekend                0
month                     0
year                      0
trips_lag_1h              0
trips_lag_24h             0
trips_lag_168h            0
trips_roll_mean_24h       0
trips_roll_mean_168h      0
dtype: int64
In [345]:
#check 
df_model.shape
Out[345]:
(2777118, 20)

EN / Observations without sufficient historical context were removed, resulting in a clean and model-ready dataset
Monetary average features are preserved in the dataset for business analysis and dashboarding purposes. However, they are excluded from the demand prediction model, as the modeling objective focuses solely on forecasting taxi demand rather than revenue.
FR / Les observations ne disposant pas d’un historique suffisant ont été supprimées, aboutissant à un jeu de données propre et prêt pour la modélisation.
Les variables monétaires moyennes sont conservées dans le jeu de données à des fins d’analyse métier et de visualisation. En revanche, elles sont exclues du modèle de prédiction, l’objectif étant exclusivement la prévision de la demande et non du chiffre d’affaires.

4.5 Encoding Categorical Variables¶

EN / Machine learning models require numerical inputs.
Categorical variables must therefore be encoded before model training.

In this project, only the borough variable is encoded, as it contains a limited number of categories and provides meaningful spatial information.
The zone variable is intentionally excluded from encoding to avoid high-dimensionality and overfitting.

FR / Les modèles de machine learning nécessitent des entrées numériques.
Les variables catégorielles doivent donc être encodées avant l’entraînement du modèle.

Dans ce projet, seule la variable borough est encodée, car elle contient un nombre limité de catégories et apporte une information spatiale pertinente.
La variable zone est volontairement exclue de l’encodage afin d’éviter une forte dimensionnalité et le surapprentissage.

In [350]:
df_model = pd.get_dummies(
    df_model,
    columns=["borough"],
    drop_first=False # je pourrai mettre en True pour retirer la colonne de reference car le model n'en a pas besoin en vrai
)
In [351]:
#check 
df_model.filter(like="borough_")
Out[351]:
borough_Bronx borough_Brooklyn borough_EWR borough_Manhattan borough_N/A borough_Queens borough_Staten Island borough_Unknown
293621 True False False False False False False False
301601 True False False False False False False False
301836 True False False False False False False False
304465 True False False False False False False False
306822 True False False False False False False False
... ... ... ... ... ... ... ... ...
2819245 False False False True False False False False
2819395 False False False True False False False False
2819577 False False False True False False False False
2819772 False False False True False False False False
2819945 False False False True False False False False

2777118 rows × 8 columns

4.6 Final Dataset for Modeling¶

EN / At this stage, the final dataset for machine learning is defined.
Only features relevant to taxi demand prediction and available at prediction time are included. Monetary variables are intentionally excluded from the model, as the objective focuses solely on forecasting demand.

FR / À cette étape, le jeu de données final destiné à la modélisation est défini.
Seules les variables pertinentes pour la prédiction de la demande taxi et disponibles au moment de la prédiction sont conservées. Les variables monétaires sont volontairement exclues du modèle, l’objectif étant exclusivement la prévision de la demande.

In [359]:
#target 
y = df_model["trips"]
In [361]:
#features 

feature_cols_final = [
    # temporal features
    "hour",
    "weekday",
    "is_weekend",
    "month",

    # lag features
    "trips_lag_1h",
    "trips_lag_24h",
    "trips_lag_168h",

    # rolling statistics
    "trips_roll_mean_24h",
    "trips_roll_mean_168h",
]

# ajouter les colonnes borough encodées
feature_cols_final += [
    col for col in df_model.columns if col.startswith("borough_")
]
In [363]:
#Dataset final ML
X = df_model[feature_cols_final]
In [365]:
#check 
X.shape, y.shape
Out[365]:
((2777118, 17), (2777118,))
In [367]:
#check
X.isna().sum().sum()
Out[367]:
0
In [369]:
#check 
X.head()
Out[369]:
hour weekday is_weekend month trips_lag_1h trips_lag_24h trips_lag_168h trips_roll_mean_24h trips_roll_mean_168h borough_Bronx borough_Brooklyn borough_EWR borough_Manhattan borough_N/A borough_Queens borough_Staten Island borough_Unknown
293621 23 5 True 5 1.0 1.0 1.0 1.041667 1.071429 True False False False False False False False
301601 9 2 False 5 1.0 1.0 1.0 1.041667 1.071429 True False False False False False False False
301836 11 2 False 5 1.0 1.0 1.0 1.041667 1.071429 True False False False False False False False
304465 13 3 False 5 1.0 1.0 1.0 1.041667 1.071429 True False False False False False False False
306822 12 4 False 5 1.0 1.0 1.0 1.041667 1.071429 True False False False False False False False

EN / A clean and well-defined modeling dataset was constructed, incorporating temporal, historical, and spatial features suitable for taxi demand forecasting.
FR / Un jeu de données de modélisation propre et clairement défini a été construit, intégrant des variables temporelles, historiques et spatiales adaptées à la prévision de la demande de taxis.

Step 5 : Modeling (ML)

Objective:¶

Predict the number of taxi trips trips for a given hour × zone using historical demand and temporal patterns.

Objectif:¶

Prédire le nombre de trajets taxi trips pour une zone donnée à une heure donnée, à partir de l’historique et des variables temporelles.

EN/ We split the dataset temporally to simulate a real-world forecasting scenario, ensuring that the model is trained only on past data and evaluated on future observations.
FR/ Nous effectuons une séparation temporelle des données afin de reproduire un scénario réel de prédiction, où le modèle apprend uniquement sur le passé et est évalué sur le futur.

5.1 Train/Test Split (Time-aware)¶

EN / To evaluate the model in a realistic forecasting setting, we split the data chronologically.
The model is trained on past observations (2022–2023) and evaluated on future observations (2024).
This prevents data leakage and better reflects real-world performance.

FR / Pour évaluer le modèle dans un contexte réaliste de prévision, nous séparons les données chronologiquement.
Le modèle est entraîné sur le passé (2022–2023) et évalué sur le futur (2024).
Cela évite les fuites de données et reflète mieux la performance en conditions réelles.

In [400]:
# Define split date (start of test period)
split_date = pd.Timestamp("2024-01-01")

# Boolean masks
train_mask = df_model["hour_ts"] < split_date
test_mask  = df_model["hour_ts"] >= split_date

# Split
X_train, X_test = X[train_mask], X[test_mask]
y_train, y_test = y[train_mask], y[test_mask]
In [402]:
#check 
X_train.shape, X_test.shape, y_train.shape, y_test.shape
Out[402]:
((1716184, 17), (1060934, 17), (1716184,), (1060934,))
In [404]:
#sanity check 
df_model.loc[train_mask, "hour_ts"].min(), df_model.loc[train_mask, "hour_ts"].max()
Out[404]:
(Timestamp('2022-01-08 00:00:00'), Timestamp('2023-12-31 23:00:00'))
In [406]:
#sanity check
df_model.loc[test_mask, "hour_ts"].min(), df_model.loc[test_mask, "hour_ts"].max()
Out[406]:
(Timestamp('2024-01-01 00:00:00'), Timestamp('2024-12-31 23:00:00'))

5.2 Baseline Model (Naive Forecast)¶

In [409]:
# Baseline prediction: persistence model
y_pred_baseline = X_test["trips_lag_1h"]

#The baseline model is evaluated on the test set only, as performance must be assessed on unseen future data to reflect real-world forecasting conditions.
#Le modèle de référence est évalué uniquement sur le jeu de test, car la performance doit être mesurée sur des données futures non vues afin de refléter un scénario réel de prévision.

EN / A naive baseline model was implemented using a persistence approach, where the predicted demand equals the observed demand one hour earlier. This baseline provides a lower bound for model performance.
FR / Un modèle de référence naïf a été implémenté selon une approche de persistance, où la demande prédite correspond à la demande observée une heure auparavant. Ce modèle sert de borne inférieure de performance.

In [411]:
from sklearn.metrics import mean_absolute_error, mean_squared_error
import numpy as np

mae_baseline = mean_absolute_error(y_test, y_pred_baseline)
rmse_baseline = np.sqrt(mean_squared_error(y_test, y_pred_baseline))

mae_baseline, rmse_baseline
Out[411]:
(9.894434526558674, 23.051931697546312)
In [416]:
y_pred_baseline.isna().sum() # si 0 = pipeline propre 
Out[416]:
0

EN / The Mean Absolute Error (MAE) of approximately 10 trips indicates that, on average, predictions deviate from actual demand by about ten trips per hour and per zone.
The higher RMSE value highlights the presence of demand spikes, which are not well captured by a simple lag-based approach.
These results establish a lower bound for model performance and motivate the use of more advanced machine learning models.
FR / Une erreur absolue moyenne (MAE) d’environ 10 courses indique qu’en moyenne, les prédictions s’écartent de la demande réelle d’une dizaine de courses par heure et par zone.
La valeur plus élevée du RMSE met en évidence la présence de pics de demande que ce modèle simple ne parvient pas à capturer.
Ces résultats constituent une borne inférieure de performance et justifient l’utilisation de modèles de machine learning plus avancés.

5.3 Random Forest Regressor¶

EN / Random Forest is an ensemble learning method that combines multiple decision trees trained on random subsets of data and features. By averaging their predictions, it reduces variance and improves generalization, making it well suited for non-linear regression problems with complex feature interactions.
It serves as a strong and interpretable benchmark for demand forecasting.
FR / Le Random Forest est une méthode d’apprentissage ensembliste qui combine plusieurs arbres de décision entraînés sur des sous-ensembles aléatoires de données et de variables. En moyennant leurs prédictions, il réduit la variance et améliore la généralisation, ce qui le rend particulièrement adapté aux problèmes de régression non linéaires avec interactions complexes.
Il constitue un excellent modèle de référence pour la prévision de la demande.

In [421]:
from sklearn.ensemble import RandomForestRegressor

rf = RandomForestRegressor(
    n_estimators=100,
    max_depth=20,
    random_state=42,
    n_jobs=-1
)
In [423]:
rf.fit(X_train, y_train)
Out[423]:
RandomForestRegressor(max_depth=20, n_jobs=-1, random_state=42)
In a Jupyter environment, please rerun this cell to show the HTML representation or trust the notebook.
On GitHub, the HTML representation is unable to render, please try loading this page with nbviewer.org.
RandomForestRegressor(max_depth=20, n_jobs=-1, random_state=42)
In [426]:
y_pred_rf = rf.predict(X_test)
In [428]:
mae_rf = mean_absolute_error(y_test, y_pred_rf)
rmse_rf = np.sqrt(mean_squared_error(y_test, y_pred_rf))

mae_rf, rmse_rf
Out[428]:
(6.356378538826315, 14.664632361972222)
In [443]:
comparison_df = pd.DataFrame({
    "Model": ["Baseline (Lag-1)", "Random Forest"],
    "MAE": [mae_baseline, mae_rf],
    "RMSE": [rmse_baseline, rmse_rf]
})

comparison_df.round(2)
Out[443]:
Model MAE RMSE
0 Baseline (Lag-1) 9.89 23.05
1 Random Forest 6.36 14.66

EN / The Random Forest model significantly outperformed the naive persistence baseline. MAE decreased from ~9.9 to ~6.4 and RMSE from ~23.1 to ~14.7, showing improved accuracy both on average and during demand peaks.
This confirms that temporal and historical features capture meaningful predictive patterns beyond simple persistence.
FR / Le modèle Random Forest surpasse nettement le modèle naïf de persistance. La MAE passe d’environ 9,9 à 6,4 et le RMSE de 23,1 à 14,7, indiquant une amélioration à la fois sur l’erreur moyenne et sur les pics de demande.
Cela confirme que les variables temporelles et historiques capturent des dynamiques prédictives pertinentes au-delà d’une simple persistance.

5.4 Feature Importance¶

In [454]:
feature_importance = pd.DataFrame({
    "feature": X_train.columns,
    "importance": rf.feature_importances_
}).sort_values(by="importance", ascending=False)

feature_importance.head(17)
Out[454]:
feature importance
4 trips_lag_1h 9.126270e-01
5 trips_lag_24h 3.356686e-02
0 hour 1.605595e-02
6 trips_lag_168h 1.454877e-02
7 trips_roll_mean_24h 9.257865e-03
8 trips_roll_mean_168h 6.347834e-03
1 weekday 3.522569e-03
3 month 2.159529e-03
14 borough_Queens 7.753297e-04
12 borough_Manhattan 6.087721e-04
2 is_weekend 5.031331e-04
16 borough_Unknown 1.863250e-05
13 borough_N/A 4.122380e-06
10 borough_Brooklyn 3.400801e-06
11 borough_EWR 1.267095e-07
9 borough_Bronx 9.415391e-08
15 borough_Staten Island 2.992754e-09
In [464]:
import matplotlib.pyplot as plt

plt.figure(figsize=(10, 6))
plt.barh(
    feature_importance["feature"].head(17)[::-1],
    feature_importance["importance"].head(17)[::-1], 
    color='gold'
)

for i, v in enumerate(feature_importance["importance"].head(17)[::-1]):
    plt.text(v, i, f"{v:.3f}", va="center")

plt.title("Top 15 Feature Importances (Random Forest)")
plt.xlabel("Importance")
plt.ylabel("Feature")
plt.tight_layout()
plt.show()
No description has been provided for this image

EN / The feature importance analysis reveals that short-term temporal dependencies dominate taxi demand prediction.

The most influential feature by far is trips_lag_1h, accounting for more than 90% of the total importance, indicating that the demand observed one hour earlier is the strongest predictor of current demand.

Additional lag-based features (trips_lag_24h, trips_lag_168h) and rolling means contribute marginally, capturing daily and weekly seasonal patterns.

Calendar-related features such as hour, weekday, and month have a smaller but non-negligible impact, reflecting regular temporal structures in taxi usage.

Spatial features (borough indicators) have relatively low importance, suggesting that temporal dynamics are more informative than spatial location once historical demand is known.

FR / L’analyse des importances des variables montre que la demande passée explique largement la demande future.

La variable la plus influente est trips_lag_1h, qui représente plus de 90 % de l’importance totale, ce qui indique que la demande observée une heure auparavant est le meilleur prédicteur de la demande actuelle.

Les autres variables de retard (trips_lag_24h, trips_lag_168h) ainsi que les moyennes mobiles capturent des effets saisonniers journaliers et hebdomadaires, mais avec un impact plus limité.

Les variables temporelles (hour, weekday, month) jouent un rôle secondaire mais cohérent avec les cycles réguliers de la demande.

Les variables spatiales (boroughs) ont une importance relativement faible, suggérant que la dynamique temporelle prime sur la localisation une fois l’historique de demande pris en compte.

5.5 Error analysis (où le modèle se trompe)¶

In [505]:
#création d'une table d'analyse des erreurs 
#un résidu = réalité - prédiction 

# Residuals
df_errors = X_test.copy()
df_errors["y_true"] = y_test.values
df_errors["y_pred"] = y_pred_rf
df_errors["error"] = df_errors["y_true"] - df_errors["y_pred"]
df_errors["abs_error"] = df_errors["error"].abs()

df_errors.head()
Out[505]:
hour weekday is_weekend month trips_lag_1h trips_lag_24h trips_lag_168h trips_roll_mean_24h trips_roll_mean_168h borough_Bronx ... borough_EWR borough_Manhattan borough_N/A borough_Queens borough_Staten Island borough_Unknown y_true y_pred error abs_error
1758081 1 0 False 1 1.0 1.0 1.0 1.041667 1.095238 True ... False False False False False False 1 1.013093 -0.013093 0.013093
1761180 6 1 False 1 1.0 1.0 1.0 1.041667 1.095238 True ... False False False False False False 1 1.032762 -0.032762 0.032762
1761310 7 1 False 1 1.0 1.0 1.0 1.041667 1.095238 True ... False False False False False False 1 1.053056 -0.053056 0.053056
1762133 13 1 False 1 1.0 1.0 1.0 1.041667 1.095238 True ... False False False False False False 1 1.032524 -0.032524 0.032524
1762751 18 1 False 1 1.0 1.0 2.0 1.041667 1.089286 True ... False False False False False False 1 1.057114 -0.057114 0.057114

5 rows × 21 columns

In [514]:
plt.figure(figsize=(10, 5))
plt.hist(df_errors["abs_error"], bins=100, color='gold')
plt.title("Distribution of Absolute Prediction Errors")
plt.xlabel("Absolute Error (|y_true - y_pred|)")
plt.ylabel("Frequency")
plt.tight_layout()

plt.show()
No description has been provided for this image

EN/ The distribution of absolute prediction errors is highly right-skewed. Most observations exhibit very small errors, indicating that the model performs well for typical demand levels. However, a long tail of larger errors suggests that the model struggles during rare high-demand situations, such as peak hours or unusual events.

FR/ La distribution des erreurs absolues est fortement asymétrique à droite. La majorité des observations présentent des erreurs très faibles, ce qui indique de bonnes performances du modèle pour les niveaux de demande courants. En revanche, une longue traîne d’erreurs plus élevées montre que le modèle rencontre davantage de difficultés lors de situations de forte demande ou d’événements atypiques.

In [530]:
plt.figure(figsize=(10, 5))
plt.scatter(
    df_errors["y_true"],
    df_errors["abs_error"],
    alpha=0.05,
    color='black'
)
plt.xlabel("Observed trips")
plt.ylabel("Absolute error")
plt.title("Error vs Observed Demand")
plt.tight_layout()

plt.axhline(df_errors["abs_error"].median(), linestyle="--", color="gold")

plt.show()
No description has been provided for this image

EN/ The model shows strong predictive performance for the majority of observations, particularly when demand ranges from 0 to approximately 500 trips per hour. In this range, prediction errors remain compact and well-controlled. Larger and more dispersed errors mainly occur during rare high-demand situations, indicating that extreme demand spikes are more challenging to predict.
FR/ Le modèle présente de bonnes performances pour la grande majorité des observations, notamment lorsque la demande se situe entre 0 et environ 500 courses par heure. Dans cette plage, les erreurs de prédiction restent contenues et bien concentrées. Les erreurs plus importantes apparaissent principalement lors de situations de demande exceptionnellement élevée, qui sont rares mais plus difficiles à anticiper.

In [572]:
error_by_hour = df_errors.groupby("hour")["abs_error"].mean()

plt.figure(figsize=(10, 5))
error_by_hour.plot(color='gold')
plt.title("Mean Absolute Error by Hour of Day")
plt.xlabel("Hour of day")
plt.ylabel("Mean Absolute Error")
plt.grid(True)
plt.tight_layout()

plt.axvspan(7, 9, alpha=0.15, label="Morning rush",color='pink')
plt.axvspan(16, 19, alpha=0.15, label="Evening rush")
plt.axvspan(20, 23, alpha=0.15, label="Evening social hours",color='blue')
plt.legend()

peak_hour = error_by_hour.idxmax()

plt.annotate(
    "Peak error (high volatility)",
    xy=(peak_hour, error_by_hour[peak_hour]),
    xytext=(peak_hour - 6, error_by_hour.max() - 1),
    arrowprops=dict(arrowstyle="->")
)


plt.show()
No description has been provided for this image

EN / Prediction errors are lowest during early morning hours (4–6 AM), when taxi demand is low and stable. Errors increase during morning and evening transition periods, with the highest values observed in the late evening. This suggests that demand volatility during peak social activity hours is more difficult to capture using historical patterns alone.
FR / Les erreurs de prédiction sont les plus faibles durant les heures creuses du matin (4–6h), lorsque la demande est faible et stable. Les erreurs augmentent lors des périodes de transition (heures de pointe) et atteignent un maximum en soirée, indiquant une demande plus volatile liée aux activités sociales, plus difficile à prédire à partir des données historiques.

In [503]:
df_errors.sort_values("abs_error", ascending=False).head(10)
Out[503]:
hour weekday is_weekend month trips_lag_1h trips_lag_24h trips_lag_168h trips_roll_mean_24h trips_roll_mean_168h borough_Bronx ... borough_EWR borough_Manhattan borough_N/A borough_Queens borough_Staten Island borough_Unknown y_true y_pred error abs_error
2639147 1 6 True 11 673.0 792.0 794.0 255.625000 134.482143 False ... False True False False False False 1239 714.380000 524.620000 524.620000
1768137 21 3 False 1 308.0 154.0 150.0 186.333333 122.726190 False ... False True False False False False 668 230.862550 437.137450 437.137450
2639183 1 6 True 11 397.0 456.0 350.0 138.291667 66.898810 False ... False True False False False False 879 444.199279 434.800721 434.800721
2761827 21 3 False 12 381.0 697.0 318.0 219.083333 204.571429 False ... False True False False False False 771 342.438322 428.561678 428.561678
2134968 22 4 False 5 259.0 285.0 369.0 179.916667 182.202381 False ... False True False False False False 651 262.688156 388.311844 388.311844
2150218 23 2 False 5 648.0 146.0 588.0 192.250000 174.327381 False ... False True False False False False 168 542.303333 -374.303333 374.303333
1931999 22 4 False 3 269.0 424.0 180.0 162.875000 169.613095 False ... False True False False False False 661 290.020369 370.979631 370.979631
2758773 21 2 False 12 389.0 635.0 310.0 223.291667 201.982143 False ... False True False False False False 697 344.958818 352.041182 352.041182
1823095 22 4 False 1 206.0 324.0 156.0 144.500000 146.404762 False ... False True False False False False 547 198.138736 348.861264 348.861264
2735441 0 2 False 12 381.0 514.0 244.0 246.625000 185.690476 False ... False False False True False False 71 411.842734 -340.842734 340.842734

10 rows × 21 columns

5.6 Conclusion & limits¶

Modeling Conclusion
EN / A Random Forest regression model was developed to predict hourly taxi demand by zone. Compared to a naive persistence baseline, the model significantly improved predictive performance, reducing both average errors (MAE) and large deviations (RMSE).

Feature importance analysis revealed that recent demand history (especially the previous hour) is the dominant driver of predictions, followed by daily and weekly temporal patterns.

Error analysis showed that the model performs well during stable demand periods, but struggles to anticipate rare and extreme demand spikes occurring mainly during late evening hours. Overall, the model provides reliable short-term demand forecasts under normal operating conditions.

FR / Un modèle de régression Random Forest a été développé afin de prédire la demande horaire de taxis par zone. Comparé à un modèle naïf de persistance, le modèle améliore significativement les performances de prédiction, avec une réduction notable de l’erreur moyenne (MAE) et des erreurs importantes (RMSE).

L’analyse de l’importance des variables montre que la demande récente — en particulier celle de l’heure précédente — constitue le principal facteur explicatif, complétée par des effets temporels journaliers et hebdomadaires.

L’analyse des erreurs indique que le modèle est performant dans des contextes de demande stable, mais présente des limites lors de pics exceptionnels survenant principalement en soirée. Dans l’ensemble, le modèle fournit des prévisions fiables de la demande à court terme dans des conditions normales.

Limitations
EN / Despite its strong performance, the model presents several limitations. First, it relies exclusively on historical demand and temporal features, and therefore cannot anticipate demand spikes driven by external factors such as weather conditions, public events, holidays, or disruptions.

Additionally, the model is optimized for short-term forecasting and may not generalize well to long-term demand shifts or structural changes in urban mobility patterns. Finally, prediction errors increase with demand magnitude, meaning that extreme high-demand hours are inherently more difficult to forecast accurately.

FR/ Malgré de bonnes performances globales, le modèle présente plusieurs limites. Il repose exclusivement sur l’historique de la demande et des variables temporelles, ce qui l’empêche d’anticiper des pics de demande liés à des facteurs externes tels que la météo, les événements publics, les jours fériés ou des perturbations exceptionnelles.

De plus, le modèle est principalement adapté à des prévisions de court terme et pourrait moins bien capturer des changements structurels de la mobilité urbaine sur le long terme. Enfin, les erreurs de prédiction augmentent avec l’intensité de la demande, rendant les heures de très forte affluence plus difficiles à prédire avec précision.

Business value EN/ This model can support operational decision-making by providing short-term demand forecasts at a fine spatial and temporal granularity.

Potential use cases include:

  • Dynamic fleet allocation by zone and hour
  • Anticipation of peak demand periods
  • Improved staffing and dispatch planning

While the model should not be used as a sole decision-making tool during extreme events, it provides strong guidance under normal operating conditions.

FR/ Ce modèle peut soutenir la prise de décision opérationnelle en fournissant des prévisions de demande à court terme avec une granularité spatiale et temporelle fine.

Les cas d’usage potentiels incluent :

  • L’allocation dynamique de la flotte par zone et par heure
  • L’anticipation des périodes de forte affluence
  • L’optimisation de la planification et du dispatch

Bien que le modèle ne doive pas être utilisé seul lors d’événements exceptionnels, il constitue un outil fiable dans des conditions d’exploitation normales.

Step 6 : Mini simulation de prédiction

EN/ Once trained, the model can be used to predict taxi demand for a given hour and zone by providing the same feature set used during training, including recent historical demand and temporal context.
FR/ Une fois entraîné, le modèle peut être utilisé pour prédire la demande de taxis pour une heure et une zone données, à condition de fournir les mêmes variables explicatives que lors de l’entraînement, notamment l’historique récent de la demande et le contexte temporel.

Démonstration :¶

In [662]:
def simulate_zone_forecast_walkforward(
    df_model: pd.DataFrame,
    model,
    X_columns,
    pu_location_id: int,
    start_ts: str,
    horizon: int ,
):
    start_ts = pd.Timestamp(start_ts)

    # --- 1) récupérer la série historique de cette zone (jusqu'à start_ts inclus)
    zone_hist = df_model[df_model["pu_location_id"] == pu_location_id].copy()
    zone_hist = zone_hist.sort_values("hour_ts")

    if zone_hist.empty:
        raise ValueError("Zone inconnue dans df_model.")

    # On doit avoir le point start_ts
    if start_ts not in set(zone_hist["hour_ts"]):
        raise ValueError("start_ts n'existe pas pour cette zone. Choisis un timestamp existant.")

    # On prend l'index du start
    zone_hist = zone_hist.set_index("hour_ts")
    t0 = start_ts

    # Valeur vraie à t0 (utile pour démo)
    y_true_t0 = float(zone_hist.loc[t0, "trips"])

    # --- 2) buffer d'historique : on prend les 168 dernières heures (si dispo)
    hist_series = zone_hist.loc[:t0, "trips"].copy()
    hist_series = hist_series.tail(168)  # 168h = 1 semaine

    # helper: construire une ligne X pour un timestamp donné
    def build_features(ts, hist):
        hour = ts.hour
        weekday = ts.weekday()  # 0..6
        is_weekend = weekday >= 5
        month = ts.month

        # lags (si pas assez d'historique, NaN)
        lag_1h = hist.iloc[-1] if len(hist) >= 1 else np.nan
        lag_24h = hist.iloc[-24] if len(hist) >= 24 else np.nan
        lag_168h = hist.iloc[-168] if len(hist) >= 168 else np.nan

        roll_24h = hist.tail(24).mean() if len(hist) >= 24 else np.nan
        roll_168h = hist.tail(168).mean() if len(hist) >= 168 else np.nan

        X = {
            "hour": hour,
            "weekday": weekday,
            "is_weekend": is_weekend,
            "month": month,
            "trips_lag_1h": lag_1h,
            "trips_lag_24h": lag_24h,
            "trips_lag_168h": lag_168h,
            "trips_roll_mean_24h": roll_24h,
            "trips_roll_mean_168h": roll_168h,
        }

        # borough dummies: on reprend depuis df_model à t0 (zone fixe)
        # on copie toutes les colonnes borough_* depuis une ligne existante (celle de t0)
        row_t0 = zone_hist.loc[t0]
        for c in X_columns:
            if c.startswith("borough_"):
                X[c] = bool(row_t0.get(c, False))

        X_df = pd.DataFrame([X])

        # compléter colonnes manquantes
        for c in X_columns:
            if c not in X_df.columns:
                X_df[c] = 0

        return X_df[X_columns]

    preds = []
    hist = hist_series.copy()

    # --- 3) walk-forward prediction
    for step in range(1, horizon + 1):
        ts_next = t0 + pd.Timedelta(hours=step)

        X_next = build_features(ts_next, hist)
        y_hat = float(model.predict(X_next)[0])

        # (option) éviter négatifs
        y_hat = max(0.0, y_hat)

        preds.append({
            "forecast_for": ts_next,
            "pu_location_id": pu_location_id,
            "pred_trips": y_hat,
            "used_lag_1h": float(X_next["trips_lag_1h"].iloc[0]),
            "used_lag_24h": float(X_next["trips_lag_24h"].iloc[0]) if not pd.isna(X_next["trips_lag_24h"].iloc[0]) else None,
            "used_roll_24h": float(X_next["trips_roll_mean_24h"].iloc[0]) if not pd.isna(X_next["trips_roll_mean_24h"].iloc[0]) else None,
        })

        # update historique avec la prédiction
        hist = pd.concat([hist, pd.Series([y_hat])], ignore_index=True)
        hist = hist.tail(168)  # garder max 168h

    return pd.DataFrame(preds), y_true_t0
In [664]:
pu = 237            # tu mets une zone
ts = "2024-07-05 18:00:00"   # tu mets une date/heure existante

forecast_df, y_true_t0 = simulate_zone_forecast_walkforward(
    df_model=df_model,
    model=rf,
    X_columns=X_train.columns,
    pu_location_id=pu,
    start_ts=ts,
    horizon=24
)

y_true_t0, forecast_df
Out[664]:
(163.0,
           forecast_for  pu_location_id  pred_trips  used_lag_1h  used_lag_24h  \
 0  2024-07-05 19:00:00             237  155.331634   163.000000          97.0   
 1  2024-07-05 20:00:00             237  135.194161   155.331634          82.0   
 2  2024-07-05 21:00:00             237  122.112945   135.194161          72.0   
 3  2024-07-05 22:00:00             237   95.154547   122.112945          80.0   
 4  2024-07-05 23:00:00             237   71.640927    95.154547          42.0   
 5  2024-07-06 00:00:00             237   48.417043    71.640927          43.0   
 6  2024-07-06 01:00:00             237   29.749044    48.417043          16.0   
 7  2024-07-06 02:00:00             237   14.740655    29.749044           3.0   
 8  2024-07-06 03:00:00             237    7.712255    14.740655           2.0   
 9  2024-07-06 04:00:00             237    6.008172     7.712255           3.0   
 10 2024-07-06 05:00:00             237    5.849520     6.008172           5.0   
 11 2024-07-06 06:00:00             237   10.628500     5.849520          10.0   
 12 2024-07-06 07:00:00             237   19.451888    10.628500          28.0   
 13 2024-07-06 08:00:00             237   42.179179    19.451888          72.0   
 14 2024-07-06 09:00:00             237   65.190020    42.179179          80.0   
 15 2024-07-06 10:00:00             237   96.089547    65.190020         108.0   
 16 2024-07-06 11:00:00             237  128.687549    96.089547         175.0   
 17 2024-07-06 12:00:00             237  149.872694   128.687549         196.0   
 18 2024-07-06 13:00:00             237  164.386619   149.872694         192.0   
 19 2024-07-06 14:00:00             237  180.989126   164.386619         203.0   
 20 2024-07-06 15:00:00             237  187.273025   180.989126         198.0   
 21 2024-07-06 16:00:00             237  179.578330   187.273025         194.0   
 22 2024-07-06 17:00:00             237  186.537123   179.578330         179.0   
 23 2024-07-06 18:00:00             237  190.761476   186.537123         163.0   
 
     used_roll_24h  
 0       93.458333  
 1       95.888818  
 2       98.105241  
 3      100.193281  
 4      100.824720  
 5      102.059759  
 6      102.285469  
 7      102.858346  
 8      103.347540  
 9      103.585550  
 10     103.710891  
 11     103.746288  
 12     103.772475  
 13     103.416304  
 14     102.173770  
 15     101.556687  
 16     101.060418  
 17      99.130733  
 18      97.208762  
 19      96.058204  
 20      95.141084  
 21      94.694127  
 22      94.093224  
 23      94.407271  )
In [665]:
plt.figure(figsize=(8,4))
plt.plot(forecast_df["forecast_for"], forecast_df["pred_trips"], marker="o", color="gold")
plt.title(f"Forecast - zone {pu} (next hours)")
plt.xlabel("Time")
plt.ylabel("Predicted trips")
plt.xticks(rotation=45)
plt.grid(True)
plt.tight_layout()

plt.show()
No description has been provided for this image

EN/ A 24-hour walk-forward simulation was performed for a selected pickup zone.
The model captures realistic intraday demand patterns, with a nocturnal decline, morning recovery, and sustained daytime demand. Predictions remain stable and coherent over time, indicating that the model has learned meaningful temporal dynamics rather than merely replicating the last observation.

FR/ Une simulation de prévision sur 24 heures a été réalisée pour une zone donnée.
Le modèle reproduit des dynamiques réalistes de demande, avec une baisse nocturne, une reprise matinale et une demande soutenue en journée. La stabilité et la cohérence des prévisions confirment que le modèle a appris des schémas temporels pertinents.

Step 7 : Streamlit

In [686]:
#bundle model pour démo 

import joblib

bundle = {
    "model": rf,
    "feature_columns": list(X_train.columns)
}

joblib.dump(bundle, "model_bundle.pkl")
Out[686]:
['model_bundle.pkl']
In [688]:
#dataset léger pour Streamlit (on ne veut pas tout le df_model mais juste ce qu'il faut)

borough_cols = [c for c in df_model.columns if c.startswith("borough_")]

df_app = df_model[["hour_ts", "pu_location_id", "trips"] + borough_cols].copy()
df_app["hour_ts"] = pd.to_datetime(df_app["hour_ts"])

df_app.to_parquet("df_app.parquet", index=False)

déployer l'app sur stremlit cloud¶

(dans le même dossier que les 2 fichiers précédents mettre requirement.txt)

EN/ For deployment, a lightweight version of the model is used to reduce artifact size and improve app responsiveness.
FR/ Pour le déploiement de l’application, une version allégée du modèle est utilisée afin de réduire la taille et améliorer la rapidité de l’app.

In [690]:
# Modèle léger uniquement pour la démo Streamlit (déploiement)
rf_demo = RandomForestRegressor(
    n_estimators=30,
    max_depth=10,
    random_state=42,
    n_jobs=-1
)

rf_demo.fit(X_train, y_train)

bundle_demo = {
    "model": rf_demo,
    "feature_columns": list(X_train.columns)
}

joblib.dump(bundle_demo, "model_bundle_demo.pkl")
Out[690]:
['model_bundle_demo.pkl']

cf app.py

In [ ]: