Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Introduction

1. Contexte et problématique métier

Dans le secteur de la vente automobile, et particulièrement pour les concessionnaires achetant des véhicules aux enchères, l’estimation du prix de revente est critique. L’objectif est de déterminer un prix d’achat maximal qui garantisse une marge bénéficiaire.

Cependant, se baser sur une simple estimation moyenne (le prix “moyen” du marché) est une stratégie risquée. Si le concessionnaire achète un véhicule à son prix moyen estimé, il court le risque de perdre de l’argent dans 50 % des cas (si la voiture se revend finalement moins cher).

L’objectif de ce projet est de sécuriser les achats en passant d’une prédiction ponctuelle à une quantification d’incertitude. Le concessionnaire ne cherche pas le prix “juste”, mais un prix plancher : “Je veux être sûr à 90 % de pouvoir revendre ce véhicule au moins X €”.

2. Présentation des données

Pour cette étude, nous utilisons le jeu de données Car Prices Dataset (disponible sur Kaggle). Il s’agit d’un registre de voitures d’occasion contenant des caractéristiques techniques et commerciales telles que :

  • La marque et le modèle.

  • L’année de fabrication et le kilométrage.

  • Le type de carburant et l’état général.

La tâche est une régression visant à prédire le prix de vente (variable continue). Ce jeu de données est idéal pour la quantification d’incertitude car il présente une forte hétéroscédasticité : la variance du prix n’est pas constante.

3. Approche méthodologique : Régression quantile et prédiction conforme

Les modèles de régression classiques minimisent l’erreur moyenne. Ils ne répondent pas à notre besoin de garantie.

Pour pallier ce manque, nous intégrons plusieurs méthodes issues de la prédiction conforme. Ces méthodes transforment la prédiction simple en intervalles de prédictions avec une garantie statistique de fiabilité.

Cette approche permet au métier de fixer ses prix d’achat sur des garanties statistiques (ex: “Je suis sûr à 80% de revendre au moins 14 000€”) plutôt que sur des intuitions.

4. Applicabilité de la prédiction conforme

Pour bénéficier des garanties théoriques de la prédiction conforme, il faut que les données respectent l’hypothèse d’échangeabilité. Dans le contexte du marché automobile, cette hypothèse est raisonnable car les véhicules sont vendus dans différentes régions et conditions, et les données sont réparties aléatoirement entre les ensembles d’entraînement, de calibration et de test.

De plus, ce registre a été collecté dans un intervalle de temps réduit, ce qui permet en pratique de valider l’hypothèse d’échangeabilité nécessaire aux méthodes de prédiction conforme.

Analyse exploratoire des données

Chargement des données

import polars as pl
from kagglehub import KaggleDatasetAdapter, dataset_load

# Polars display options
pl.Config.set_tbl_hide_dataframe_shape(True)
pl.Config.set_float_precision(2)

df: pl.DataFrame = dataset_load(
    adapter=KaggleDatasetAdapter.POLARS,
    handle="sidharth178/car-prices-dataset",
    path="train.csv",
).collect()
df.head()
Loading...

Nettoyage des données

df = df.drop(["ID"])

# Extract features and cleaning
df = df.with_columns(
    # Create Turbo binary feature
    pl.col("Engine volume").str.contains("Turbo").alias("Turbo"),
    # Parse Engine volume: extract first number (e.g., '2.5 Turbo' -> 2.5)
    pl.col("Engine volume").str.extract(r"(\d+\.?\d*)", 1).cast(pl.Float64),
    # Parse Mileage: remove 'km' and convert to int
    pl.col("Mileage").str.replace(" km", "").cast(pl.Int64),
    # Cast Levy to numeric ('-' becomes null)
    pl.col("Levy").cast(pl.Int64, strict=False),
    # Convert Leather interior to binary
    (pl.col("Leather interior") == "Yes").cast(pl.Boolean),
    # Parse Doors: extract first number
    pl.col("Doors").str.extract(r"(\d+)", 1).cast(pl.Int64),
)

# Rename columns
df = df.rename(
    {
        "Engine volume": "Engine volume (L)",
        "Mileage": "Mileage (km)",
        "Prod. year": "Production year",
        "Levy": "Levy tax",
        "Manufacturer": "Brand",
    }
)
df.head()
Loading...

Statistiques descriptives

df.describe()
Loading...

Il y a des valeurs aberrantes dans les données pour le prix et le kilométrage.

Nettoyage des données aberrantes

df.sort("Price", descending=True).head(3)
Loading...
df.sort("Mileage (km)", descending=True).head(8)
Loading...
# On supprime les valeurs aberrantes

condition = (pl.col("Price") < 1_000_000) & (pl.col("Mileage (km)") < 1_000_000)
df = df.filter(condition)

Distribution des variables numériques

import altair as alt

alt.data_transformers.enable("vegafusion")

numerical_cols = [
    "Production year",
    "Mileage (km)",
    "Price",
    "Levy tax",
    "Engine volume (L)",
]

for col in numerical_cols:
    alt.Chart(df).mark_boxplot(outliers={"size": 5}).encode(
        alt.X(f"{col}:Q").scale(zero=False),
    ).properties(title=f"Distribution of {col}").show()
Loading...
Loading...
Loading...
Loading...
Loading...

Distribution des variables catégorielles et booléennes

low_card_categorical = df.select([s for s in df if s.n_unique() < 50]).columns

for col in low_card_categorical:
    alt.Chart(df).mark_bar().encode(
        alt.X("count()"),
        alt.Y(f"{col}:N", sort="-x", title=None),
        tooltip=[f"{col}:N", "count()"],
    ).properties(
        title=f"Distribution of {col}",
    ).show()
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...

Matrice de corrélation

On observe une faible corrélation entre le prix de la voiture et l’année de production, le kilométrage et la présence d’un turbo.

from utils import plot_correlation

plot_correlation(df)
Loading...

Évolution des prix par année de production

alt.Chart(df).mark_bar().encode(
    alt.X("Production year:T"),
    alt.Y("mean(Price):Q", title=None),
    tooltip=["Production year:T", "mean(Price):Q"],
).properties(title="Average Price by Production Year")
Loading...

Relation entre prix et année de production

alt.Chart(df).mark_rect(clip=True).encode(
    alt.X("Production year:Q").bin(maxbins=50),
    alt.Y("Price:Q").bin(maxbins=200).scale(domain=[0, 200_000]),
    alt.Color("count()").scale(type="log"),
    tooltip=["count()"],
).properties(title="Price vs Production year (Density Plot)")
Loading...

Relation entre prix et kilométrage

alt.Chart(df).mark_rect(clip=True).encode(
    alt.X("Mileage (km):Q").bin(maxbins=50).scale(domain=[0, 500_000]),
    alt.Y("Price:Q").bin(maxbins=100).scale(domain=[0, 200_000]),
    alt.Color("count()").scale(type="log"),
    tooltip=["count()"],
).properties(title="Price vs Mileage (Density Plot)")
Loading...

Distribution des prix par type de carburant

alt.Chart(df).mark_boxplot(extent="min-max", clip=True).encode(
    alt.X("Price:Q").scale(domain=[0, 50_000]),
    alt.Y("Fuel type:N", title=None),
    alt.Color("Fuel type:N", legend=None),
).properties(title="Price Distribution by Fuel Type")
Loading...

Distribution des prix par catégorie de véhicule

alt.Chart(df).mark_boxplot(extent="min-max", clip=True).encode(
    alt.X("Price:Q").scale(domain=[0, 50_000]),
    alt.Y("Category:N", title=None),
    alt.Color("Category:N", legend=None),
).properties(title="Price Distribution by Vehicle Category")
Loading...

Distribution des prix par top 10 marque

# Get top 10 brands by count
top_brands = df["Brand"].value_counts(sort=True).head(10)["Brand"].to_list()
df_top_brands = df.filter(pl.col("Brand").is_in(top_brands))

alt.Chart(df_top_brands).mark_boxplot(extent="min-max", clip=True).encode(
    alt.X("Price:Q").scale(domain=[0, 50_000]),
    alt.Y("Brand:N", title=None, sort="-x"),
    alt.Color("Brand:N", legend=None),
).properties(title="Price Distribution by Top 10 Brands")
Loading...

On constate une hétéroscédasticité des prix en fonction du kilométrage des voitures, des marques, des catégories de véhicules et des types de carburant.

Conversion en données catégorielles

Utile plus tard pour la gestion automatique des variables catégorielles dans les modèles.

categorical_features = [
    "Brand",
    "Category",
    "Fuel type",
    "Gear box type",
    "Drive wheels",
    "Wheel",
    "Color",
]
# Cast to categorical dtypes
df = df.cast(dict.fromkeys(categorical_features, pl.Categorical))

Sauvegarde des données nettoyées

# Save preprocessed data for modeling
df.write_parquet("../../data/car_prices_clean.parquet")