LangueFR
Français
English
Retour au sommaire du cours

Projet de A à Z : annonces immobilières, du scraping à la prédiction

Vingt-cinq modules de notions, et maintenant un projet complet où elles servent ensemble. L'objectif : partir d'annonces immobilières publiées sur le web, les collecter, les nettoyer, les stocker dans une base, entraîner un modèle de prédiction de prix, l'exposer derrière une API conteneurisée, et automatiser le tout avec une chaîne d'intégration continue. Rien d'exceptionnel techniquement — c'est précisément l'intérêt : c'est la forme qu'a réellement un projet de data science en entreprise.

1. Cadrage et architecture

La question, avant le code

Le module 12 insiste sur un point : commencer par la décision que le modèle doit éclairer. Ici, elle est simple — ce bien est-il affiché au-dessus ou en dessous de son prix de marché ? Cela fixe tout le reste :

  • Cible : le prix affiché en euros. On modélisera en réalité son logarithme (voir section 6).
  • Unité d'observation : une annonce à un instant donné.
  • Métrique : l'erreur relative compte plus que l'erreur absolue — se tromper de 20 000 € sur un studio ou sur un hôtel particulier n'a pas le même sens. On retient la MAE sur le logarithme du prix, interprétable en pourcentage, complétée par la médiane de l'erreur absolue en pourcentage (module 8).
  • Baseline : le prix médian au m² de la commune, multiplié par la surface. Tout modèle qui ne bat pas cela ne sert à rien.
  • Contrainte : prédiction en moins de 100 ms, et une estimation de l'incertitude, puisqu'une fourchette est bien plus honnête qu'un nombre unique.
architecture cible
  ┌──────────────┐   JSONL brut   ┌──────────────┐   Parquet    ┌──────────────┐
  │   COLLECTE   │ ─────────────► │  NETTOYAGE   │ ───────────► │  INGESTION   │
  │ httpx async  │  (immuable)    │   polars     │  (validé)    │  PostgreSQL  │
  │  §3          │                │   §4         │              │   §5         │
  └──────────────┘                └──────────────┘              └──────┬───────┘
                                                                       │ SQL
                                                                       ▼
  ┌──────────────┐   image        ┌──────────────┐   pipeline   ┌──────────────┐
  │   CI/CD      │ ◄───────────── │  API FastAPI │ ◄─────────── │ ENTRAÎNEMENT │
  │ GitHub Act.  │   Docker       │   + Docker   │  .joblib     │  LightGBM    │
  │  §8          │                │   §7         │              │   §6         │
  └──────────────┘                └──────────────┘              └──────────────┘
arborescence du projet (module 1)
immo-prix/
├── pyproject.toml
├── uv.lock
├── compose.yaml                 # API + PostgreSQL          (module 5)
├── Dockerfile
├── .dockerignore  .gitignore  .pre-commit-config.yaml
├── .github/workflows/ci.yml     # intégration continue      (module 26)
├── sql/
│   ├── 001_schema.sql           # tables, index, vues       (module 4)
│   └── 002_vues.sql
├── src/immo/
│   ├── config.py                # pydantic-settings         (module 6)
│   ├── schemas.py               # modèles Pydantic partagés
│   ├── scrape.py                # §3
│   ├── clean.py                 # §4
│   ├── ingest.py                # §5
│   ├── features.py              # §6
│   ├── train.py                 # §6
│   └── api.py                   # §7
├── tests/
└── data/                        # ignoré par Git
    ├── raw/                     # JSONL horodatés, jamais modifiés
    └── clean/                   # Parquet

Une règle qui structure tout le projet : les données brutes sont immuables

Ce qui est collecté est écrit une fois, horodaté, et n'est plus jamais modifié. Toute transformation produit un nouveau fichier. Pourquoi : quand on découvre trois semaines plus tard que la fonction de nettoyage confondait « 3 pièces » et « 3 chambres », on veut pouvoir tout recalculer depuis la source — ce qui est impossible si le nettoyage a écrasé les données d'origine. C'est le même principe que l'immuabilité d'une configuration (module 1), appliqué aux données.

2. Choisir une source de données (et le droit)

Le cadre juridique, en clair

Collecter automatiquement des pages web n'est pas neutre juridiquement, et c'est le premier sujet à traiter — pas le dernier.

  • Les conditions générales d'utilisation du site s'imposent. La plupart des grands portails immobiliers interdisent explicitement l'extraction automatisée. Passer outre expose à des poursuites, et des décisions de justice françaises et européennes ont déjà sanctionné l'aspiration massive de bases d'annonces.
  • Le droit des bases de données (directive européenne de 1996, transposée en France) protège le producteur d'une base contre l'extraction d'une « partie substantielle » de son contenu, même si les données individuelles ne sont pas protégées par le droit d'auteur.
  • Le RGPD s'applique dès qu'une donnée permet d'identifier une personne : nom d'un vendeur particulier, numéro de téléphone, photo où l'on distingue quelqu'un. Un prix et une surface ne sont pas des données personnelles ; le contact d'un particulier l'est.
  • robots.txt n'a pas de valeur contraignante en soi, mais il exprime la volonté de l'éditeur : l'ignorer affaiblit toute position défendable.
  • La charge imposée au serveur peut constituer un trouble en elle-même. Un scraper qui envoie cent requêtes par seconde peut être assimilé à une attaque.
La conclusion pratique. Pour un projet destiné à être publié, la bonne source est l'open data. En France, la base DVF (Demandes de Valeurs Foncières) publie l'ensemble des transactions immobilières réelles — prix, surface, nombre de pièces, localisation, date — sous licence ouverte, avec une API officielle. C'est mieux que du scraping à tous points de vue : ce sont des prix de vente et non des prix affichés, la couverture est exhaustive, et l'usage est explicitement autorisé. Le code de la section suivante reste écrit comme un vrai scraper — c'est une compétence à part entière, utile sur un site interne ou sur un site qui l'autorise — mais il s'exerce ici sur un domaine d'exemple, et la vérification préalable fait partie du programme.
Python — src/immo/sources.py : la source open data
import httpx, polars as pl

def transactions_dvf(code_commune: str, annee: int) -> pl.DataFrame:
    """Transactions réelles issues de l'open data DVF (licence ouverte)."""
    r = httpx.get("https://api.cquest.org/dvf",
                  params={"code_commune": code_commune}, timeout=30)
    r.raise_for_status()
    return (
        pl.DataFrame(r.json()["resultats"])
          .filter(pl.col("date_mutation").str.starts_with(str(annee)))
          .select("date_mutation", "valeur_fonciere", "surface_relle_bati",
                  "nombre_pieces_principales", "type_local", "code_postal", "commune")
    )

3. Collecte : le scraper

Avant d'écrire la moindre requête

Python — src/immo/scrape.py (1/4) : la vérification préalable
from urllib.robotparser import RobotFileParser
from urllib.parse import urljoin

AGENT = "immo-prix-bot/1.0 (+https://nils-malmberg.fr/project7-m28.html)"

def autorise(base_url: str, chemin: str) -> tuple[bool, float]:
    """Lit robots.txt : (le chemin est-il autorisé, délai demandé entre requêtes)."""
    rp = RobotFileParser()
    rp.set_url(urljoin(base_url, "/robots.txt"))
    rp.read()
    delai = rp.crawl_delay(AGENT) or 1.0        # délai demandé, 1 s par défaut
    return rp.can_fetch(AGENT, urljoin(base_url, chemin)), float(delai)

ok, delai = autorise("https://immo-exemple.fr", "/annonces/")
if not ok:
    raise SystemExit("robots.txt interdit cette section : on s'arrête ici.")

Le User-Agent déclaré mérite un commentaire : il est honnête, identifie le projet et donne un moyen de contact. Se faire passer pour un navigateur est techniquement possible et juridiquement indéfendable ; un administrateur qui voit passer un agent identifié peut demander l'arrêt plutôt que bannir en silence.

Récupérer les pages

C'est l'application directe du module 2 : des requêtes HTTP sont des opérations d'entrée/sortie, donc asyncio avec un sémaphore pour brider la charge, et une politique de reprise pour les erreurs passagères.

Python — src/immo/scrape.py (2/4) : la récupération
import asyncio, hashlib, random
from pathlib import Path
import httpx

CACHE = Path("data/cache")
CACHE.mkdir(parents=True, exist_ok=True)

async def recuperer(client: httpx.AsyncClient, url: str,
                    sem: asyncio.Semaphore, delai: float) -> str | None:
    """Renvoie le HTML d'une page, avec cache disque, reprise et politesse."""
    fichier = CACHE / (hashlib.sha256(url.encode()).hexdigest()[:16] + ".html")
    if fichier.exists():                       # cache : on ne redemande jamais deux fois
        return fichier.read_text(encoding="utf-8")

    async with sem:                            # au plus N requêtes simultanées
        for essai in range(4):
            try:
                r = await client.get(url, timeout=20, follow_redirects=True)
                if r.status_code == 429:       # trop de requêtes : on ralentit
                    await asyncio.sleep(float(r.headers.get("Retry-After", 30)))
                    continue
                r.raise_for_status()
                fichier.write_text(r.text, encoding="utf-8")
                # politesse : délai demandé + un peu d'aléa pour ne pas être régulier
                await asyncio.sleep(delai + random.uniform(0, 0.5))
                return r.text
            except (httpx.TimeoutException, httpx.HTTPStatusError) as e:
                if essai == 3:
                    print(f"abandon {url}: {e!r}")
                    return None
                await asyncio.sleep(2 ** essai)      # attente exponentielle : 1, 2, 4 s

Pourquoi un cache disque dès le premier jour

Pendant le développement, on relance le script vingt fois pour corriger l'extraction. Sans cache, ce sont vingt fois les mêmes requêtes envoyées au site — inutile, impoli, et c'est ce qui fait bannir. Avec le cache, seule la première exécution touche le réseau ; les suivantes travaillent sur des fichiers locaux, et sont instantanées. C'est aussi ce qui rend l'extraction reproductible : on peut rejouer exactement le même HTML après avoir corrigé le parseur.

Extraire les champs

Le HTML est transformé en données structurées par des sélecteurs CSS. J'utilise selectolax, nettement plus rapide que BeautifulSoup sur de gros volumes, avec une API très proche.

Python — src/immo/scrape.py (3/4) : l'extraction typée
import re
from selectolax.parser import HTMLParser
from pydantic import BaseModel, Field, field_validator

class AnnonceBrute(BaseModel):
    """Ce qu'on extrait d'une page. Pydantic (module 6) fait la validation ici."""
    url: str
    titre: str
    prix_texte: str
    surface_texte: str | None = None
    pieces_texte: str | None = None
    ville: str
    code_postal: str = Field(pattern=r"^\d{5}$")
    description: str = ""
    collecte_le: str                        # horodatage ISO

    @field_validator("titre", "ville")
    @classmethod
    def non_vide(cls, v: str) -> str:
        v = v.strip()
        if not v:
            raise ValueError("champ vide")
        return v

def texte(noeud, selecteur: str, defaut: str = "") -> str:
    """Renvoie le texte d'un sélecteur, ou une valeur par défaut s'il est absent."""
    trouve = noeud.css_first(selecteur)
    return trouve.text(strip=True) if trouve else defaut

def extraire(html: str, url: str) -> AnnonceBrute | None:
    arbre = HTMLParser(html)
    try:
        return AnnonceBrute(
            url=url,
            titre=texte(arbre, "h1.annonce-titre"),
            prix_texte=texte(arbre, "[data-testid='prix']"),
            surface_texte=texte(arbre, "li.critere-surface") or None,
            pieces_texte=texte(arbre, "li.critere-pieces") or None,
            ville=texte(arbre, "span.localisation-ville"),
            code_postal=re.search(r"\b\d{5}\b", texte(arbre, "span.localisation")).group(),
            description=texte(arbre, "div.description"),
            collecte_le=datetime.now(timezone.utc).isoformat(),
        )
    except (ValidationError, AttributeError) as e:
        print(f"page ignorée {url}: {e}")     # structure inattendue → on saute, sans planter
        return None

Deux choix importants ici. D'abord, on extrait le texte brut (prix_texte = "249 500 €") sans le convertir : la conversion est une décision de nettoyage, qui appartient à l'étape suivante et doit pouvoir être corrigée sans recollecter. Ensuite, une page dont la structure a changé produit une erreur de validation attrapée localement : une annonce est perdue, pas l'exécution entière — un site modifie son HTML sans prévenir, et un scraper qui plante sur la première anomalie ne collecte jamais rien.

Python — src/immo/scrape.py (4/4) : l'orchestration et l'écriture
import json
from datetime import datetime, timezone

async def collecter(urls: list[str], sortie: Path, concurrence: int = 4) -> int:
    ok, delai = autorise("https://immo-exemple.fr", "/annonces/")
    if not ok:
        raise SystemExit("collecte non autorisée")

    sem = asyncio.Semaphore(concurrence)     # 4 requêtes simultanées, pas 400
    n = 0
    async with httpx.AsyncClient(headers={"User-Agent": AGENT}, http2=True) as client:
        # JSONL : une ligne = un objet JSON. Écriture au fil de l'eau, sans tout
        # garder en mémoire — le principe des générateurs du module 1.
        with sortie.open("w", encoding="utf-8") as f:
            taches = [recuperer(client, u, sem, delai) for u in urls]
            for future, url in zip(asyncio.as_completed(taches), urls):
                html = await future
                if html and (annonce := extraire(html, url)):
                    f.write(annonce.model_dump_json() + "\n")
                    n += 1
    return n

if __name__ == "__main__":
    horodatage = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S")
    sortie = Path(f"data/raw/annonces_{horodatage}.jsonl")   # fichier HORODATÉ, immuable
    print(f"{asyncio.run(collecter(lire_urls(), sortie))} annonces écrites dans {sortie}")

Le format JSONL (un objet JSON par ligne) est celui qui convient à une collecte : on peut écrire au fil de l'eau, reprendre après interruption sans corrompre le fichier, et le relire en flux avec un générateur. Un unique gros tableau JSON obligerait à tout garder en mémoire et serait invalide si l'exécution s'arrêtait en cours de route.

Les bonnes pratiques de scraping, résumées

  • Vérifier CGU et robots.txt avant, et s'arrêter si c'est interdit.
  • User-Agent honnête, avec un moyen de contact.
  • Concurrence faible (2 à 5) et délai entre requêtes ; respecter les codes 429.
  • Cache disque systématique pendant le développement.
  • Préférer l'API officielle ou l'open data quand ils existent — presque toujours.
  • Extraire brut, convertir plus tard ; horodater chaque collecte.
  • Ne jamais collecter de données personnelles dont on n'a pas besoin.
  • Surveiller le taux d'échec d'extraction : une hausse brutale signale un changement de structure du site, pas un problème de données.

4. Nettoyage et validation

Les données brutes sont hétérogènes : « 249 500 € », « 249500 EUR », « Nous consulter ». Le nettoyage transforme ce texte en valeurs exploitables et — tout aussi important — documente ce qu'il a jeté. C'est l'application du module 12.

Python — src/immo/clean.py (1/2) : parsing et normalisation
import re
import polars as pl                       # module 3 : lazy, rapide, expressif

def nettoyer(chemin_jsonl: str) -> pl.DataFrame:
    brut = pl.read_ndjson(chemin_jsonl)

    return (
        brut
        # ── 1. extraction numérique depuis le texte ──
        .with_columns(
            prix=pl.col("prix_texte")
                   .str.replace_all(r"[^\d]", "")      # ne garder que les chiffres
                   .cast(pl.Float64, strict=False),    # "Nous consulter" → null
            surface=pl.col("surface_texte")
                      .str.extract(r"(\d+[,.]?\d*)")   # "72,5 m²" → "72,5"
                      .str.replace(",", ".")
                      .cast(pl.Float64, strict=False),
            n_pieces=pl.col("pieces_texte")
                       .str.extract(r"(\d+)")
                       .cast(pl.Int8, strict=False),
        )
        # ── 2. normalisation des chaînes ──
        .with_columns(
            ville=pl.col("ville").str.to_uppercase()
                    .str.replace_all(r"[-']", " ")
                    .str.replace_all(r"\s+", " ").str.strip_chars(),
            departement=pl.col("code_postal").str.slice(0, 2),
            collecte_le=pl.col("collecte_le").str.to_datetime(),
        )
        # ── 3. features dérivées utiles au contrôle qualité ──
        .with_columns(
            prix_m2=pl.col("prix") / pl.col("surface"),
            surface_par_piece=pl.col("surface") / pl.col("n_pieces"),
            a_ascenseur=pl.col("description").str.contains("(?i)ascenseur"),
            a_balcon=pl.col("description").str.contains("(?i)balcon|terrasse"),
        )
    )
Python — src/immo/clean.py (2/2) : doublons, aberrants, rapport
def filtrer(df: pl.DataFrame) -> tuple[pl.DataFrame, dict]:
    """Renvoie les données retenues ET le compte de ce qui a été écarté."""
    n0 = len(df)
    rapport = {"initial": n0}

    # ── doublons ──
    df = df.unique(subset=["url"], keep="last")           # même annonce recollectée
    rapport["doublons_url"] = n0 - len(df); n1 = len(df)

    # quasi-doublons : même bien republié sous une autre URL
    df = df.unique(subset=["ville", "surface", "n_pieces", "prix"], keep="first")
    rapport["doublons_contenu"] = n1 - len(df); n2 = len(df)

    # ── valeurs manquantes sur les colonnes indispensables ──
    df = df.drop_nulls(["prix", "surface"])
    rapport["sans_prix_ou_surface"] = n2 - len(df); n3 = len(df)

    # ── aberrants : bornes MÉTIER, pas statistiques ──
    df = df.filter(
        (pl.col("surface").is_between(8, 1000))           # < 8 m² : impossible en France
        & (pl.col("prix").is_between(10_000, 5_000_000))
        & (pl.col("prix_m2").is_between(300, 25_000))     # hors de ces bornes = erreur
        & (pl.col("n_pieces").is_null() | pl.col("n_pieces").is_between(1, 20))
    )
    rapport["aberrants"] = n3 - len(df)
    rapport["retenu"] = len(df)
    rapport["taux_retenu"] = round(len(df) / n0, 3)
    return df, rapport
Écarter des lignes n'est jamais anodin. Filtrer sur prix_m2 < 25 000 supprime les erreurs de saisie et les biens de luxe réels : le modèle ne saura plus les estimer, et il faut le dire explicitement plutôt que le découvrir en production. D'où le rapport chiffré à chaque étape — si le taux retenu tombe de 92 % à 60 % d'une collecte à l'autre, c'est le site qui a changé, pas le marché. Ce rapport est journalisé à chaque exécution, et un seuil déclenche une alerte (module 26).

Dernière étape avant le stockage : une vérification de schéma qui échoue bruyamment si les données ne respectent pas le contrat attendu. C'est le test de données du module 26, et c'est la protection la plus rentable de tout le pipeline.

Python — contrôle du contrat de données
ATTENDU = {"prix": pl.Float64, "surface": pl.Float64, "n_pieces": pl.Int8,
           "ville": pl.String, "code_postal": pl.String, "collecte_le": pl.Datetime}

def verifier(df: pl.DataFrame) -> None:
    for colonne, dtype in ATTENDU.items():
        assert colonne in df.columns, f"colonne manquante : {colonne}"
        assert df[colonne].dtype == dtype, f"{colonne}: {df[colonne].dtype} ≠ {dtype}"
    assert df["prix"].min() > 0
    assert df["code_postal"].str.contains(r"^\d{5}$").all()
    assert df["prix"].null_count() == 0
    taux_manquants = df["n_pieces"].null_count() / len(df)
    assert taux_manquants < 0.30, f"trop de n_pieces manquants : {taux_manquants:.0%}"

df_propre, rapport = filtrer(nettoyer("data/raw/annonces_20260316T090000.jsonl"))
verifier(df_propre)
print(rapport)   # {'initial': 4820, 'doublons_url': 112, 'doublons_contenu': 340,
                 #  'sans_prix_ou_surface': 87, 'aberrants': 61,
                 #  'retenu': 4220, 'taux_retenu': 0.876}
df_propre.write_parquet("data/clean/annonces_20260316.parquet")

Le format Parquet remplace ici le CSV : il conserve les types (une date reste une date, un entier reste un entier), il se compresse cinq à dix fois mieux, et il se lit par colonnes — ce qui accélère toutes les lectures partielles (module 4).

5. Stockage en base de données

Pourquoi une base, puisqu'on a du Parquet

Le Parquet est parfait pour l'entraînement : un fichier, lu d'un coup, en colonnes. Il l'est beaucoup moins pour le reste : l'API doit chercher des biens comparables en quelques millisecondes, chaque collecte ajoute des lignes et met à jour des annonces existantes, et plusieurs processus y accèdent en même temps. Ce sont exactement les cas où une base s'impose (module 4). On garde donc les deux : PostgreSQL comme source de vérité, Parquet comme export figé pour l'entraînement.

sql/001_schema.sql
CREATE TABLE IF NOT EXISTS annonces (
    annonce_id     BIGSERIAL PRIMARY KEY,
    url            TEXT        NOT NULL UNIQUE,   -- la clé métier : une URL = un bien
    titre          TEXT        NOT NULL,
    prix           NUMERIC(12,2) NOT NULL CHECK (prix > 0),
    surface        REAL        NOT NULL CHECK (surface BETWEEN 8 AND 1000),
    n_pieces       SMALLINT    CHECK (n_pieces BETWEEN 1 AND 20),
    ville          TEXT        NOT NULL,
    code_postal    CHAR(5)     NOT NULL CHECK (code_postal ~ '^\d{5}$'),
    departement    CHAR(2)     GENERATED ALWAYS AS (LEFT(code_postal, 2)) STORED,
    a_ascenseur    BOOLEAN     NOT NULL DEFAULT FALSE,
    a_balcon       BOOLEAN     NOT NULL DEFAULT FALSE,
    description    TEXT,
    collecte_le    TIMESTAMPTZ NOT NULL,
    maj_le         TIMESTAMPTZ NOT NULL DEFAULT now(),
    prix_m2        NUMERIC(10,2) GENERATED ALWAYS AS (prix / surface) STORED
);

-- index : jointures et filtres les plus fréquents (module 4)
CREATE INDEX IF NOT EXISTS idx_annonces_cp     ON annonces(code_postal);
CREATE INDEX IF NOT EXISTS idx_annonces_date   ON annonces(collecte_le DESC);
CREATE INDEX IF NOT EXISTS idx_annonces_comp   ON annonces(code_postal, surface);

-- historique des prix : une annonce republiée moins cher est un signal fort
CREATE TABLE IF NOT EXISTS historique_prix (
    annonce_id  BIGINT      NOT NULL REFERENCES annonces(annonce_id) ON DELETE CASCADE,
    prix        NUMERIC(12,2) NOT NULL,
    constate_le TIMESTAMPTZ NOT NULL DEFAULT now(),
    PRIMARY KEY (annonce_id, constate_le)
);

-- suivi des exécutions : indispensable pour le monitoring (module 26)
CREATE TABLE IF NOT EXISTS executions (
    execution_id  BIGSERIAL PRIMARY KEY,
    lance_le      TIMESTAMPTZ NOT NULL DEFAULT now(),
    fichier_source TEXT,
    n_collectees  INTEGER,
    n_retenues    INTEGER,
    taux_retenu   REAL,
    rapport       JSONB                  -- le dict de la section 4, tel quel
);

Trois choses méritent d'être remarquées. Les colonnes GENERATED ALWAYS AS … STORED sont calculées par la base : il est impossible d'avoir un prix_m2 incohérent avec le prix et la surface, alors qu'une colonne recopiée par le script aurait dérivé tôt ou tard. Les contraintes CHECK dupliquent volontairement les règles du nettoyage : c'est une seconde barrière, active quel que soit le programme qui écrit. Et la colonne JSONB permet de stocker le rapport de qualité sans figer son schéma — le bon usage du JSON dans une base relationnelle.

L'ingestion : idempotente

Une exécution peut échouer au milieu, être relancée, ou traiter un fichier déjà traité. Le script d'ingestion doit donc être idempotent : le relancer deux fois sur les mêmes données donne exactement le même résultat qu'une seule fois. C'est ce que garantit l'instruction INSERT … ON CONFLICT (un « upsert »).

Python — src/immo/ingest.py
import json
import polars as pl
from sqlalchemy import create_engine, text
from immo.config import parametres          # pydantic-settings (module 6)

moteur = create_engine(parametres.database_url, pool_pre_ping=True)

UPSERT = text("""
    INSERT INTO annonces (url, titre, prix, surface, n_pieces, ville, code_postal,
                          a_ascenseur, a_balcon, description, collecte_le)
    VALUES (:url, :titre, :prix, :surface, :n_pieces, :ville, :code_postal,
            :a_ascenseur, :a_balcon, :description, :collecte_le)
    ON CONFLICT (url) DO UPDATE SET          -- l'annonce existe déjà : on met à jour
        prix   = EXCLUDED.prix,
        maj_le = now()
    RETURNING annonce_id, (xmax = 0) AS insertion    -- xmax = 0 → c'était un INSERT
""")

def ingerer(chemin_parquet: str, rapport: dict) -> dict:
    df = pl.read_parquet(chemin_parquet)
    lignes = df.to_dicts()

    with moteur.begin() as con:              # TRANSACTION : tout ou rien (module 4)
        n_nouvelles = 0
        for lot in (lignes[i:i + 500] for i in range(0, len(lignes), 500)):
            for ligne in lot:
                res = con.execute(UPSERT, ligne).one()
                n_nouvelles += int(res.insertion)
                # on garde la trace du prix à chaque passage
                con.execute(text("""INSERT INTO historique_prix (annonce_id, prix)
                                    VALUES (:id, :prix)
                                    ON CONFLICT DO NOTHING"""),
                            {"id": res.annonce_id, "prix": ligne["prix"]})

        con.execute(text("""INSERT INTO executions
                            (fichier_source, n_collectees, n_retenues, taux_retenu, rapport)
                            VALUES (:f, :c, :r, :t, CAST(:j AS jsonb))"""),
                    {"f": chemin_parquet, "c": rapport["initial"], "r": rapport["retenu"],
                     "t": rapport["taux_retenu"], "j": json.dumps(rapport)})

    return {"nouvelles": n_nouvelles, "mises_a_jour": len(lignes) - n_nouvelles}
sql/002_vues.sql — l'analyse, côté base
-- prix médian au m² par commune : c'est la BASELINE du modèle (section 1)
CREATE OR REPLACE VIEW v_reference_commune AS
SELECT code_postal,
       COUNT(*)                                             AS n_annonces,
       PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY prix_m2) AS prix_m2_median,
       PERCENTILE_CONT(0.1) WITHIN GROUP (ORDER BY prix_m2) AS prix_m2_p10,
       PERCENTILE_CONT(0.9) WITHIN GROUP (ORDER BY prix_m2) AS prix_m2_p90
FROM annonces
WHERE collecte_le > now() - INTERVAL '90 days'
GROUP BY code_postal
HAVING COUNT(*) >= 20;            -- pas de référence sur 3 annonces

-- baisses de prix : fonction de fenêtre (module 4)
CREATE OR REPLACE VIEW v_baisses AS
SELECT a.url, a.ville, h.prix AS prix_actuel,
       FIRST_VALUE(h.prix) OVER (PARTITION BY h.annonce_id
                                 ORDER BY h.constate_le) AS prix_initial,
       h.prix / FIRST_VALUE(h.prix) OVER (PARTITION BY h.annonce_id
                                          ORDER BY h.constate_le) - 1 AS variation
FROM historique_prix h JOIN annonces a USING (annonce_id);

6. Modélisation et évaluation

Les features

Python — src/immo/features.py
import numpy as np, polars as pl

def construire_features(df: pl.DataFrame, reference: pl.DataFrame) -> pl.DataFrame:
    """reference = la vue v_reference_commune, calculée sur le passé uniquement."""
    return (
        df.join(reference, on="code_postal", how="left")
          .with_columns(
              # la variable la plus prédictive : le niveau de prix local
              prix_m2_median=pl.col("prix_m2_median").fill_null(
                  pl.col("prix_m2_median").median()),          # commune inconnue
              # non-linéarités simples, utiles aux modèles linéaires (module 14)
              log_surface=pl.col("surface").log(),
              surface_par_piece=pl.col("surface") / pl.col("n_pieces").fill_null(2),
              # indicateur de manquant : le FAIT de manquer est informatif (module 12)
              pieces_manquant=pl.col("n_pieces").is_null().cast(pl.Int8),
              n_pieces=pl.col("n_pieces").fill_null(-1),       # sentinelle pour les arbres
              # saisonnalité : le marché n'est pas le même en août (module 17)
              mois=pl.col("collecte_le").dt.month(),
              mois_sin=(2 * np.pi * pl.col("collecte_le").dt.month() / 12).sin(),
              mois_cos=(2 * np.pi * pl.col("collecte_le").dt.month() / 12).cos(),
          )
    )

COLONNES = ["surface", "log_surface", "n_pieces", "pieces_manquant",
            "surface_par_piece", "prix_m2_median", "a_ascenseur", "a_balcon",
            "mois_sin", "mois_cos", "departement"]
Deux fuites de données évitées ici (module 12). La référence communale est calculée sur la période d'entraînement uniquement : la calculer sur tout le jeu ferait entrer l'information du test dans les features, et le score serait flatteur mais faux. Et le découpage train/test est temporel, pas aléatoire : on entraîne sur les annonces d'avant une date et on teste sur les suivantes, puisque c'est exactement la situation d'usage — prédire le prix d'une annonce qui vient de paraître.

Entraînement

Python — src/immo/train.py
import joblib, numpy as np, polars as pl, mlflow
from lightgbm import LGBMRegressor, early_stopping
from sklearn.compose import ColumnTransformer
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import OrdinalEncoder
from sklearn.metrics import mean_absolute_error

VERSION = "1.0.0"

def preparer(df: pl.DataFrame, date_coupure: str):
    train = df.filter(pl.col("collecte_le") <  pl.lit(date_coupure).str.to_datetime())
    test  = df.filter(pl.col("collecte_le") >= pl.lit(date_coupure).str.to_datetime())
    # cible : le LOGARITHME du prix. L'erreur devient relative, et la distribution
    # fortement asymétrique des prix (module 10) devient à peu près symétrique.
    return (train[COLONNES].to_pandas(), np.log(train["prix"].to_numpy()),
            test[COLONNES].to_pandas(),  np.log(test["prix"].to_numpy()))

def entrainer(X_tr, y_tr, X_te, y_te):
    pre = ColumnTransformer(
        [("cat", OrdinalEncoder(handle_unknown="use_encoded_value", unknown_value=-1),
          ["departement"])],
        remainder="passthrough",
    )
    modele = Pipeline([                       # pipeline COMPLET : protège des fuites
        ("pre", pre),
        ("gbm", LGBMRegressor(n_estimators=3000, learning_rate=0.03, num_leaves=31,
                              min_child_samples=30, subsample=0.8, colsample_bytree=0.8,
                              random_state=42)),
    ])
    modele.fit(X_tr, y_tr,
               gbm__eval_set=[(pre.fit(X_tr).transform(X_te), y_te)],
               gbm__callbacks=[early_stopping(100, verbose=False)])
    return modele

def evaluer(modele, X_te, y_te, baseline_m2, surfaces) -> dict:
    pred_log = modele.predict(X_te)
    prix_reel, prix_pred = np.exp(y_te), np.exp(pred_log)

    return {
        "mae_log":   mean_absolute_error(y_te, pred_log),        # ≈ erreur relative
        "mape_med":  float(np.median(np.abs(prix_pred / prix_reel - 1))),
        "dans_10pct": float(np.mean(np.abs(prix_pred / prix_reel - 1) < 0.10)),
        # LA comparaison qui compte : bat-on la baseline ? (module 12)
        "mape_med_baseline": float(np.median(np.abs(baseline_m2 * surfaces / prix_reel - 1))),
    }
résultats typiques sur ce genre de jeu
baseline (prix médian au m² × surface)   erreur médiane : 18,4 %
LightGBM                                 erreur médiane :  9,1 %   ← division par deux
                                         62 % des biens estimés à ±10 %
                                         MAE sur log(prix) : 0,118

Deux compléments valent le détour. SHAP (module 14) explique une estimation individuelle : « ce bien est estimé 18 000 € au-dessus de la moyenne du quartier parce qu'il est grand et a un ascenseur » — c'est ce qui rend l'outil utilisable par un humain, bien plus qu'un nombre seul. Et un intervalle de prédiction vaut mieux qu'un point : on peut entraîner deux modèles quantiles supplémentaires (objective="quantile", alpha=0.1 et 0.9), ou conformaliser le modèle existant pour obtenir une couverture garantie (module 10) :

Python — intervalle conforme, cinq lignes
# jeu de calibration disjoint de l'entraînement
scores = np.abs(y_calib - modele.predict(X_calib))
n, alpha = len(scores), 0.1
q = np.quantile(scores, np.ceil((n + 1) * (1 - alpha)) / n, method="higher")
# en log : un écart constant devient un FACTEUR multiplicatif sur le prix
print(f"intervalle à 90 % : ×{np.exp(-q):.2f} à ×{np.exp(q):.2f}")   # ×0,82 à ×1,22
Python — journaliser l'expérience (module 26)
mlflow.set_experiment("immo-prix")
with mlflow.start_run(run_name=f"lgbm-{VERSION}"):
    mlflow.log_params({"n_estimators": 3000, "learning_rate": 0.03,
                       "date_coupure": DATE_COUPURE, "n_train": len(X_tr)})
    mlflow.log_param("git_sha", sha_courant())
    mlflow.log_metrics(metriques)
    mlflow.sklearn.log_model(modele, "modele", registered_model_name="immo-prix")

joblib.dump({"pipeline": modele, "version": VERSION, "colonnes": COLONNES,
             "q_conforme": q, "entraine_le": datetime.now(timezone.utc).isoformat()},
            f"models/immo-{VERSION}.joblib")     # le pipeline ENTIER, pas l'estimateur seul

7. API et conteneurisation

Python — src/immo/api.py
from contextlib import asynccontextmanager
import anyio, joblib, numpy as np, pandas as pd
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy import create_engine, text

etat: dict = {}

@asynccontextmanager
async def lifespan(app: FastAPI):
    artefact = joblib.load("models/immo-1.0.0.joblib")     # chargé UNE fois (module 6)
    etat.update(artefact)
    etat["moteur"] = create_engine(parametres.database_url, pool_pre_ping=True)
    yield
    etat.clear()

app = FastAPI(title="Estimation de prix immobilier", version="1.0.0", lifespan=lifespan)

class Bien(BaseModel):
    surface: float = Field(gt=8, le=1000, description="surface habitable en m²")
    n_pieces: int | None = Field(default=None, ge=1, le=20)
    code_postal: str = Field(pattern=r"^\d{5}$")
    a_ascenseur: bool = False
    a_balcon: bool = False

class Estimation(BaseModel):
    prix_estime: float
    prix_bas: float
    prix_haut: float
    prix_m2_estime: float
    reference_locale_m2: float | None
    n_comparables: int
    version_modele: str

@app.get("/health")
def health() -> dict:
    return {"status": "ok", "modele": etat.get("version")}

@app.post("/estimer", response_model=Estimation)
async def estimer(bien: Bien) -> Estimation:
    # référence locale : une requête SQL indexée, quelques millisecondes (module 4)
    with etat["moteur"].connect() as con:
        ref = con.execute(text("""SELECT prix_m2_median, n_annonces
                                  FROM v_reference_commune WHERE code_postal = :cp"""),
                          {"cp": bien.code_postal}).one_or_none()
    if ref is None:
        raise HTTPException(404, "commune sans référence : pas assez d'annonces")

    X = construire_ligne(bien, prix_m2_median=float(ref.prix_m2_median))
    log_prix = await anyio.to_thread.run_sync(etat["pipeline"].predict, X)   # module 2

    prix = float(np.exp(log_prix[0]))
    q = etat["q_conforme"]
    return Estimation(
        prix_estime=round(prix, -2),
        prix_bas=round(prix * float(np.exp(-q)), -2),
        prix_haut=round(prix * float(np.exp(q)), -2),
        prix_m2_estime=round(prix / bien.surface, 1),
        reference_locale_m2=float(ref.prix_m2_median),
        n_comparables=int(ref.n_annonces),
        version_modele=etat["version"],
    )
compose.yaml — la pile complète (module 5)
services:
  api:
    build: .
    ports: ["8000:8000"]
    environment:
      DATABASE_URL: postgresql+psycopg://immo:${POSTGRES_PASSWORD}@base:5432/immo
    volumes:
      - ./models:/app/models:ro          # le modèle, monté en lecture seule
    depends_on:
      base: { condition: service_healthy }
    restart: unless-stopped

  base:
    image: postgres:16
    environment:
      POSTGRES_USER: immo
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_DB: immo
    volumes:
      - donnees-pg:/var/lib/postgresql/data
      - ./sql:/docker-entrypoint-initdb.d:ro     # schéma appliqué au 1er démarrage
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U immo"]
      interval: 5s
      retries: 10

volumes:
  donnees-pg:
docker compose up -d --build
curl -X POST localhost:8000/estimer -H "Content-Type: application/json" \
     -d '{"surface": 72.5, "n_pieces": 3, "code_postal": "38000", "a_ascenseur": true}'
# {"prix_estime":248300.0,"prix_bas":203600.0,"prix_haut":302900.0,
#  "prix_m2_estime":3425.5,"reference_locale_m2":3180.0,"n_comparables":412,
#  "version_modele":"1.0.0"}

8. CI/CD avec GitHub Actions

Ce qu'on automatise, et pourquoi

Trois automatisations distinctes, à ne pas confondre : la vérification du code à chaque modification (intégration continue), la publication d'une image lorsqu'une version est prête (livraison continue), et l'exécution planifiée de la collecte. GitHub Actions couvre les trois. Le vocabulaire : un workflow est un fichier YAML déclenché par un événement ; il contient des jobs qui tournent chacun sur une machine neuve ; un job enchaîne des steps.

.github/workflows/ci.yml
name: CI

on:                                   # QUAND ce workflow se déclenche
  push:
    branches: [main]
  pull_request:                       # sur chaque proposition de modification

jobs:
  qualite:
    runs-on: ubuntu-latest
    services:                         # PostgreSQL éphémère pour les tests d'intégration
      postgres:
        image: postgres:16
        env:
          POSTGRES_USER: immo
          POSTGRES_PASSWORD: test
          POSTGRES_DB: immo_test
        ports: ["5432:5432"]
        options: >-
          --health-cmd pg_isready --health-interval 5s --health-retries 10

    steps:
      - uses: actions/checkout@v4                   # récupère le code

      - name: Installer uv                          # module 1
        uses: astral-sh/setup-uv@v3
        with:
          enable-cache: true                        # cache des dépendances entre exécutions

      - name: Installer les dépendances
        run: uv sync --frozen                       # --frozen : échoue si uv.lock a dérivé

      - name: Lint et formatage
        run: |
          uv run ruff check .
          uv run ruff format --check .

      - name: Types
        run: uv run mypy src/

      - name: Tests
        env:
          DATABASE_URL: postgresql+psycopg://immo:test@localhost:5432/immo_test
        run: uv run pytest -q --cov=src --cov-report=term-missing

      - name: Vérifier que le schéma SQL s'applique
        env:
          PGPASSWORD: test
        run: psql -h localhost -U immo -d immo_test -f sql/001_schema.sql

  image:
    needs: qualite                                  # ne démarre que si la qualité passe
    if: github.ref == 'refs/heads/main'             # uniquement sur main
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write                               # droit de publier l'image
    steps:
      - uses: actions/checkout@v4
      - uses: docker/setup-buildx-action@v3
      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}     # fourni automatiquement
      - uses: docker/build-push-action@v6
        with:
          push: true
          tags: |
            ghcr.io/${{ github.repository }}:latest
            ghcr.io/${{ github.repository }}:${{ github.sha }}
          cache-from: type=gha                      # cache des couches Docker
          cache-to: type=gha,mode=max
.github/workflows/collecte.yml — l'exécution planifiée
name: Collecte quotidienne

on:
  schedule:
    - cron: "17 4 * * *"          # tous les jours à 4 h 17 UTC (heure creuse)
  workflow_dispatch:              # et déclenchable à la main depuis l'interface

jobs:
  collecter:
    runs-on: ubuntu-latest
    timeout-minutes: 45           # garde-fou : un scraper bloqué ne tourne pas 6 h
    steps:
      - uses: actions/checkout@v4
      - uses: astral-sh/setup-uv@v3
      - run: uv sync --frozen

      - name: Collecte, nettoyage, ingestion
        env:
          DATABASE_URL: ${{ secrets.DATABASE_URL }}    # SECRET, jamais dans le code
        run: |
          uv run python -m immo.scrape
          uv run python -m immo.clean
          uv run python -m immo.ingest

      - name: Contrôle qualité              # échoue si le taux retenu s'effondre
        run: uv run python -m immo.checks --seuil-taux-retenu 0.75

      - name: Alerter en cas d'échec
        if: failure()
        run: echo "::error::La collecte a échoué — vérifier la structure du site"

Les points qui font la différence dans une CI de projet data

  • Un service PostgreSQL éphémère permet de tester réellement l'ingestion et les requêtes, pas seulement des fonctions isolées. C'est là que se trouvent la plupart des bugs.
  • uv sync --frozen échoue si le lockfile n'est pas à jour : impossible de fusionner du code dont les dépendances n'ont pas été résolues.
  • Les mêmes vérifications que pre-commit (module 1), mais ici personne ne peut les contourner avec --no-verify.
  • Les secrets passent par le coffre GitHub et sont masqués dans les journaux. Une chaîne de connexion en clair dans un fichier YAML est une fuite définitive, puisque l'historique Git la conserve.
  • Un contrôle qualité qui échoue bruyamment : la collecte silencieusement vide est le pire scénario, parce qu'on continue à entraîner sur des données figées sans s'en rendre compte.
  • L'image est étiquetée par le SHA du commit : chaque prédiction en production peut être reliée à une version précise du code (module 26).

9. Ce qui resterait à faire

Le pipeline tourne, mais un projet réel ne s'arrête pas là. Par ordre de priorité :

  • Surveillance de la dérive (module 26) — comparer chaque mois la distribution des surfaces, des prix au m² et des communes à celle du jeu d'entraînement (indice PSI), et déclencher un ré-entraînement au-delà d'un seuil. Le marché immobilier dérive lentement mais sûrement.
  • Suivi de la performance réelle — les prix affichés ne sont pas les prix de vente. Rapprocher les annonces des transactions DVF publiées quelques mois plus tard permettrait de mesurer l'erreur vraie, et non l'erreur sur le prix demandé.
  • Enrichissement géographique — géocoder les adresses, ajouter la distance au centre, aux transports, aux écoles ; ces variables expliquent une grande part du résidu. Les données de l'IGN et de l'INSEE sont ouvertes.
  • Texte des annonces (module 18) — « refait à neuf », « travaux à prévoir », « vue dégagée » portent une information réelle, qu'un TF-IDF ou un embedding capterait.
  • Découpage par groupe (module 12) — une même annonce republiée sous plusieurs URL peut se retrouver des deux côtés du découpage ; grouper par bien (surface, pièces, localisation) fiabiliserait l'évaluation.
  • Interface (module 6) — une page Jinja avec un formulaire et la décomposition SHAP rendrait l'outil utilisable sans passer par curl.

Les notions du cours mobilisées, et où

Module  Notion                              Où, dans ce projet
──────────────────────────────────────────────────────────────────────────────
1       src layout, UV, dataclass,          structure du projet, config, écriture
        générateurs, pre-commit             JSONL au fil de l'eau, hooks
2       asyncio, sémaphore, reprise         le scraper (§3)
3       polars, lazy, Parquet               le nettoyage (§4)
4       schéma, contraintes, index,         la base (§5), la vue de référence,
        upsert, fenêtres, transactions      l'historique des prix
5       Dockerfile, Compose, volumes        la pile de développement (§7)
6       Pydantic, FastAPI, lifespan         extraction typée (§3), API (§7)
7       métriques, log-cible                choix de la MAE sur log, évaluation (§6)
9       prédiction conforme                 l'intervalle d'estimation (§6)
10      fuite de données, baseline,         split temporel, référence calculée sur
        manquants, feature engineering      le train seul, indicateurs de manquant
12      gradient boosting, SHAP             le modèle et son explication (§6)
15      saisonnalité                        features cycliques mois_sin/mois_cos
16      texte des annonces                  piste d'amélioration (§9)
24      MLflow, CI/CD, tests de données,    journalisation, GitHub Actions (§8),
        monitoring                          contrôle qualité, dérive (§9)
25      RGPD, CGU, droit des bases          le cadre juridique de la collecte (§2)

Ce qu'il faut retenir du module

  • Le droit se traite avant le code : CGU, robots.txt, RGPD, et l'open data quand il existe.
  • Les données brutes sont immuables et horodatées ; toute transformation produit un nouveau fichier.
  • Extraire brut, convertir ensuite : le nettoyage doit pouvoir être corrigé sans recollecter.
  • Chaque filtre est compté et journalisé : un taux de rejet qui change signale un problème en amont.
  • La base garantit ce que le script ne peut pas garantir : unicité, bornes, cohérence, transactions.
  • Ingestion idempotente : relancer deux fois ne doit rien changer.
  • Découpage temporel, référence calculée sur le passé, pipeline complet sérialisé : trois protections contre la fuite.
  • Une fourchette vaut mieux qu'un nombre ; une explication vaut mieux qu'une fourchette.
  • La CI vérifie ce que personne ne peut contourner, et la collecte planifiée échoue bruyamment quand le site change.

ScrapingpolarsPostgreSQLLightGBMFastAPIDockerGitHub Actions