SPB Git forge

spb/lou-ka

Public

Lou·Ka — tous les logements à louer du Québec, un seul endroit.

232commits 1branches 0releases
172.9 MBsize
maindefault branch
2 days agolast push
HTML 98.9% Python 0.6%
20.2 KB · 482 lines python
Raw Blame History
1# -----------------------------------------------------------------------------2# Lou-Ka — Agrégateur de logements à louer (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# fairvalue.py : framework d'estimation de la JUSTE VALEUR locative (fair value)5#6#   Moteur générique Groupe-KA « estimation de juste valeur » : les7#   spécificités logement vivent dans CONFIG (segments, caractéristiques,8#   seuils) — décliner sur immo-ka (biens en vente) ou auto-ka (véhicules)9#   = fournir une autre CONFIG et une autre requête de chargement.10#11#   Trois niveaux, du plus robuste au plus fin :12#     N1  référence par segment de marché (ville[/quartier] × chambres),13#         médiane + p25/p75, volume minimal sinon repli au niveau supérieur ;14#     N2  ajustements multiplicatifs par caractéristiques (meublé, chauffage15#         inclus, stationnement…) estimés SUR LES DONNÉES elles-mêmes ;16#     N3  comparables kNN géographiques (grille spatiale, pondération17#         gaussienne par la distance).18#   Combinaison + couloir de sécurité autour du segment + indice de confiance.19#   AUCUNE donnée inventée : segment trop rare => confiance faible / pas de20#   verdict, jamais d'estimation à l'aveugle.21# -----------------------------------------------------------------------------22from __future__ import annotations2324import math25import re26import time27from statistics import median2829MODEL_VERSION = "fv-logement-1.0"3031CONFIG = {32    # volume minimal d'annonces pour qu'un segment fasse référence33    "min_segment": 8,34    # comparables (niveau 3)35    "knn_k": 12,36    "knn_min": 4,37    "knn_radius_m": 3000.0,38    "knn_bandwidth_m": 800.0,       # pondération gaussienne exp(-(d/bw)^2)39    # combinaison : poids du kNN quand il est disponible40    "knn_weight": 0.6,41    # couloir de sécurité autour de la référence du segment42    "corridor": (0.70, 1.30),        # x p25 … x p7543    # classification (écart relatif au marché)44    "seuil_sous": -0.08,45    "seuil_sur": 0.08,46    # au-delà : donnée probablement corrompue (prix à la semaine, coquille…)47    # -> pas de verdict plutôt qu'un verdict trompeur48    "verdict_dev_bounds": (-0.50, 1.00),49    # ajustement superficie : (aire/aire_type)^elasticité, borné50    "area_elasticity": 0.35,51    "area_adj_bounds": (0.80, 1.30),52    # caractéristiques ajustables (chemin json_extract dans details, ou champ)53    "features": {54        "meuble":        ("field", "furnished"),55        "chauffage":     ("details", "$.inclusions.heating"),56        "electricite":   ("details", "$.inclusions.electricity"),57        "stationnement": ("details", "$.parking.available"),58        "climatisation": ("details", "$.ac"),59        "balcon":        ("details", "$.balcony"),60        "ascenseur":     ("details", "$.elevator"),61        "piscine":       ("details", "$.pool"),62        "lave_vaisselle": ("details", "$.appliances.dishwasher"),63    },64    # bornes de sanité des coefficients appris (un coef hors bornes = bruit)65    "coef_bounds": (0.85, 1.35),66    "coef_min_pairs": 60,            # nb min d'annonces avec ET sans la carac.67}686970# --------------------------------------------------------------- utilitaires71# location de CHAMBRE / colocation : segment à part — comparer une chambre72# au loyer d'un logement entier fabriquerait de fausses « bonnes affaires »73_CHAMBRE = re.compile(74    r"(chambre\s+(à|a)\s+louer|chambre\s+dans|chambre\s+disponible|"75    r"chambre\s+meubl|colocation|\bcoloc\b|room\s+for\s+rent|"76    r"room\s+in\b|roommate|chambre\s+(à|a)\s+partager)", re.IGNORECASE)7778# prix affiché non mensuel (semaine/jour/nuit) : l'écart serait trompeur79_NON_MENSUEL = re.compile(80    r"(/\s*sem|par\s+sem|semaine|hebdo|/\s*jour|par\s+jour|/\s*nuit|"81    r"par\s+nuit|/\s*wk|/\s*week\b|weekly|/\s*night|nightly|/\s*day\b)",82    re.IGNORECASE)838485def _bed_group(bedrooms) -> str:86    """Groupe de chambres : 0 (studio), 1, 2, 3, 4+ — clé de segment."""87    if bedrooms is None:88        return "?"89    b = int(round(float(bedrooms)))90    return "4+" if b >= 4 else str(max(0, b))919293def _pct(sorted_vals: list[float], q: float) -> float:94    if not sorted_vals:95        return 0.096    i = q * (len(sorted_vals) - 1)97    lo, hi = int(math.floor(i)), int(math.ceil(i))98    return sorted_vals[lo] + (sorted_vals[hi] - sorted_vals[lo]) * (i - lo)99100101def _dist_m(lat1, lng1, lat2, lng2) -> float:102    """Distance approx. en mètres (équirectangulaire — suffisant à < 10 km)."""103    kx = 111320.0 * math.cos(math.radians((lat1 + lat2) / 2))104    return math.hypot((lat1 - lat2) * 110540.0, (lng1 - lng2) * kx)105106107def _ensure_tables(con) -> None:108    con.executescript("""109    CREATE TABLE IF NOT EXISTS fairvalue (110        uid           TEXT PRIMARY KEY,      -- listings.uid111        fv            REAL,                  -- juste valeur estimée ($/mois)112        fv_low        REAL,                  -- fourchette basse113        fv_high       REAL,                  -- fourchette haute114        deviation     REAL,                  -- (prix - fv) / fv115        verdict       TEXT,                  -- sous | marche | sur | NULL116        confidence    TEXT,                  -- fort | moyen | faible117        method        TEXT,                  -- segment | segment+ajust | knn+segment118        comps         INTEGER,               -- nb de comparables kNN retenus119        segment       TEXT,                  -- clé du segment de référence120        model_version TEXT,121        computed_at   REAL122    );123    CREATE TABLE IF NOT EXISTS fv_segments (   -- historisation des références124        ts     REAL,125        level  INTEGER,                        -- 1=ville+quartier+ch, 2=ville+ch, 3=ch, 4=global126        key    TEXT,127        n      INTEGER,128        p25    REAL, p50 REAL, p75 REAL,129        area_p50 REAL130    );131    CREATE INDEX IF NOT EXISTS idx_fv_segments_ts ON fv_segments(ts);132    """)133    con.commit()134135136# ------------------------------------------------------------- jeu de données137def _load(con) -> list[dict]:138    """Jeu de référence : annonces publiées (qualité validée), canoniques,139    au prix plausible. Les annonces en quarantaine sont EXCLUES."""140    feats = CONFIG["features"]141    cols = ", ".join(142        f"json_extract(details, '{path}') AS f_{name}"143        for name, (kind, path) in feats.items() if kind == "details")144    rows = con.execute(f"""145        SELECT uid, source, city, sector, unit_type, bedrooms, price,146               area_sqft, lat, lng, furnished, price_label, title,147               json_extract(details, '$.price_from') AS price_from, {cols}148        FROM listings149        WHERE active=1 AND published=1 AND dup_of IS NULL150          AND price IS NOT NULL AND price BETWEEN 175 AND 20000151    """).fetchall()152    out = []153    for r in rows:154        d = dict(r)155        if ((d.get("unit_type") or "").strip().lower() in ("chambre", "chambres")156                or _CHAMBRE.search(d.get("title") or "")):157            d["bed"] = "ch"          # segment « chambre / colocation »158        else:159            d["bed"] = _bed_group(d["bedrooms"])160        # prix à la semaine/nuit rangé comme mensuel : EXCLU du jeu de161        # référence (fausserait les médianes) et jamais de verdict162        d["non_monthly"] = bool(163            _NON_MENSUEL.search(d.get("price_label") or "")164            or _NON_MENSUEL.search(d.get("title") or ""))165        if not d["non_monthly"]:166            out.append(d)167    return out168169170def _feature_value(item: dict, name: str):171    kind, path = CONFIG["features"][name]172    if kind == "field":173        return item.get(path)174    return item.get(f"f_{name}")175176177# ------------------------------------------------- niveau 1 : segments marché178def _build_segments(items: list[dict]) -> dict[str, dict]:179    """Références par segment avec échelle de repli :180    L1 ville|quartier|ch → L2 ville|ch → L3 ch (province) → L4 global."""181    buckets: dict[tuple[int, str], list[dict]] = {}182    for it in items:183        keys = []184        if it["city"] and it["sector"] and it["bed"] != "?":185            keys.append((1, f"{it['city']}|{it['sector']}|{it['bed']}"))186        if it["city"] and it["bed"] != "?":187            keys.append((2, f"{it['city']}|{it['bed']}"))188        if it["bed"] != "?":189            keys.append((3, it["bed"]))190        keys.append((4, "global"))191        for k in keys:192            buckets.setdefault(k, []).append(it)193    refs: dict[str, dict] = {}194    n_min = CONFIG["min_segment"]195    for (level, key), members in buckets.items():196        if len(members) < n_min and level != 4:197            continue198        prices = sorted(m["price"] for m in members)199        areas = sorted(m["area_sqft"] for m in members if m["area_sqft"])200        refs[f"{level}|{key}"] = {201            "level": level, "key": key, "n": len(members),202            "p25": _pct(prices, 0.25), "p50": _pct(prices, 0.50),203            "p75": _pct(prices, 0.75),204            "area_p50": median(areas) if areas else None,205        }206    return refs207208209def _segment_for(it: dict, refs: dict[str, dict]) -> dict | None:210    """Référence la plus fine disponible pour une annonce (repli en échelle)."""211    candidates = []212    if it["city"] and it["sector"] and it["bed"] != "?":213        candidates.append(f"1|{it['city']}|{it['sector']}|{it['bed']}")214    if it["city"] and it["bed"] != "?":215        candidates.append(f"2|{it['city']}|{it['bed']}")216    if it["bed"] != "?":217        candidates.append(f"3|{it['bed']}")218    candidates.append("4|global")219    for c in candidates:220        if c in refs:221            return refs[c]222    return None223224225# ------------------------------------- niveau 2 : coefficients par carac.226def _learn_coefficients(items: list[dict], refs: dict) -> dict[str, float]:227    """Coefficient multiplicatif de chaque caractéristique, appris des données :228    médiane de (prix / p50 du segment) des annonces AVEC la caractéristique,229    divisée par celle des annonces SANS. Borné (coef_bounds) et ignoré si230    l'échantillon est trop mince — aucun coefficient inventé."""231    coefs: dict[str, tuple[float, float]] = {}232    lo, hi = CONFIG["coef_bounds"]233    for name in CONFIG["features"]:234        with_r, without_r = [], []235        for it in items:236            ref = _segment_for(it, refs)237            if ref is None or ref["level"] >= 4 or ref["p50"] <= 0:238                continue239            v = _feature_value(it, name)240            if v is None:241                continue242            ratio = it["price"] / ref["p50"]243            (with_r if v else without_r).append(ratio)244        if (len(with_r) >= CONFIG["coef_min_pairs"]245                and len(without_r) >= CONFIG["coef_min_pairs"]):246            c = median(with_r) / max(median(without_r), 1e-9)247            c = min(hi, max(lo, c))248            # part des annonces (au statut connu) qui ONT la caractéristique :249            # sert à centrer l'ajustement — le p50 du segment reflète déjà le250            # mélange avec/sans, appliquer c brut gonflerait toutes les251            # estimations (biais systématique vers « sous-évalué »)252            share = len(with_r) / (len(with_r) + len(without_r))253            coefs[name] = (c, share)254    return coefs255256257def _adjusted(it: dict, ref: dict, coefs: dict[str, float]) -> float:258    """Niveau 2 : référence du segment ajustée aux caractéristiques."""259    fv = ref["p50"]260    for name, (coef, share) in coefs.items():261        v = _feature_value(it, name)262        if v is None:263            continue                      # statut inconnu = effet moyen (neutre)264        norm = share * coef + (1.0 - share)   # effet moyen déjà dans le p50265        fv *= (coef if v else 1.0) / norm266    if it["area_sqft"] and ref.get("area_p50"):267        lo, hi = CONFIG["area_adj_bounds"]268        adj = (it["area_sqft"] / ref["area_p50"]) ** CONFIG["area_elasticity"]269        fv *= min(hi, max(lo, adj))270    return fv271272273# -------------------------------------------- niveau 3 : comparables kNN géo274def _build_grid(items: list[dict]) -> dict[tuple[int, int, str], list[dict]]:275    grid: dict[tuple[int, int, str], list[dict]] = {}276    for it in items:277        if it["lat"] is None or it["lng"] is None or it["bed"] == "?":278            continue279        cell = (int(it["lat"] * 50), int(it["lng"] * 50), it["bed"])  # ~2 km280        grid.setdefault(cell, []).append(it)281    return grid282283284def _knn(it: dict, grid: dict) -> tuple[float | None, int]:285    if it["lat"] is None or it["lng"] is None or it["bed"] == "?":286        return None, 0287    ci, cj = int(it["lat"] * 50), int(it["lng"] * 50)288    cands = []289    for di in (-1, 0, 1):290        for dj in (-1, 0, 1):291            cands.extend(grid.get((ci + di, cj + dj, it["bed"]), ()))292    radius, bw = CONFIG["knn_radius_m"], CONFIG["knn_bandwidth_m"]293    scored = []294    for c in cands:295        if c["uid"] == it["uid"]:296            continue297        d = _dist_m(it["lat"], it["lng"], c["lat"], c["lng"])298        if d > radius:299            continue300        w = math.exp(-((d / bw) ** 2))301        if it["unit_type"] and c["unit_type"] and it["unit_type"] == c["unit_type"]:302            w *= 1.3                       # même type d'unité = plus comparable303        scored.append((w, c["price"], d))304    if len(scored) < CONFIG["knn_min"]:305        return None, len(scored)306    scored.sort(key=lambda x: -x[0])307    top = scored[:CONFIG["knn_k"]]308    # médiane PONDÉRÉE des loyers comparables (la moyenne serait tirée vers le309    # haut par l'asymétrie des loyers)310    by_price = sorted(top, key=lambda x: x[1])311    half = sum(w for w, _, _ in top) / 2312    acc = 0.0313    fv = by_price[-1][1]314    for w, price, _ in by_price:315        acc += w316        if acc >= half:317            fv = price318            break319    return fv, len(top)320321322# --------------------------------------------------------- calcul d'ensemble323def compute_all(verbose: bool = True) -> dict:324    """(Re)calcule la fair value de toutes les annonces publiées.325326    Appelé à chaque cycle de synchronisation (ingest.watch) et via327    `python run.py fairvalue`. Historise les références de segments.328    """329    from . import db330    con = db.connect()331    _ensure_tables(con)332    t0 = time.time()333334    items = _load(con)335    refs = _build_segments(items)336    coefs = _learn_coefficients(items, refs)337    grid = _build_grid(items)338339    now = time.time()340    stats = {"annonces": len(items), "segments": len(refs), "coefs": coefs,341             "sous": 0, "marche": 0, "sur": 0, "sans_verdict": 0}342    rows = []343    for it in items:344        ref = _segment_for(it, refs)345        if ref is None:346            continue347        fv_seg = _adjusted(it, ref, coefs)348        fv_knn, n_comps = _knn(it, grid)349        if fv_knn is not None:350            w = CONFIG["knn_weight"]351            fv = w * fv_knn + (1 - w) * fv_seg352            method = "knn+segment"353        else:354            fv = fv_seg355            method = "segment+ajust" if coefs else "segment"356        # couloir de sécurité autour du segment (jamais d'estimation absurde)357        c_lo, c_hi = CONFIG["corridor"]358        fv = min(max(fv, ref["p25"] * c_lo), ref["p75"] * c_hi)359360        # fourchette : dispersion relative du segment appliquée à l'estimation361        lo = fv * (ref["p25"] / ref["p50"]) if ref["p50"] else fv * 0.85362        hi = fv * (ref["p75"] / ref["p50"]) if ref["p50"] else fv * 1.15363364        # divergence forte entre méthodes -> fourchette élargie, confiance365        # abaissée (les deux estimateurs ne racontent pas la même histoire)366        divergent = (fv_knn is not None and fv_seg > 0367                     and abs(fv_knn - fv_seg) / fv_seg > 0.25)368        if divergent:369            lo, hi = min(lo, min(fv_knn, fv_seg)), max(hi, max(fv_knn, fv_seg))370371        if (n_comps >= 8 and ref["level"] <= 2 and not divergent):372            confidence = "fort"373        elif n_comps >= CONFIG["knn_min"] or (ref["level"] <= 2 and ref["n"] >= 15):374            confidence = "moyen"375        else:376            confidence = "faible"377378        deviation = (it["price"] - fv) / fv if fv > 0 else None379        # prudence : pas de verdict si confiance faible, prix ambigu380        # (« à partir de » = plancher) ou écart hors du plausible (donnée381        # douteuse : coquille de prix, périodicité mal lue…)382        b_lo, b_hi = CONFIG["verdict_dev_bounds"]383        verdict = None384        if (deviation is not None and confidence != "faible"385                and not it["price_from"] and b_lo < deviation < b_hi):386            if deviation <= CONFIG["seuil_sous"]:387                verdict = "sous"388            elif deviation >= CONFIG["seuil_sur"]:389                verdict = "sur"390            else:391                verdict = "marche"392            stats[verdict] += 1393        else:394            stats["sans_verdict"] += 1395396        rows.append((it["uid"], round(fv), round(lo), round(hi),397                     round(deviation, 4) if deviation is not None else None,398                     verdict, confidence, method, n_comps,399                     f"{ref['level']}|{ref['key']}", MODEL_VERSION, now))400401    con.execute("DELETE FROM fairvalue")402    con.executemany(403        "INSERT INTO fairvalue (uid, fv, fv_low, fv_high, deviation, verdict,"404        " confidence, method, comps, segment, model_version, computed_at)"405        " VALUES (?,?,?,?,?,?,?,?,?,?,?,?)", rows)406    # historisation des références (évolution des loyers par segment)407    con.executemany(408        "INSERT INTO fv_segments (ts, level, key, n, p25, p50, p75, area_p50)"409        " VALUES (?,?,?,?,?,?,?,?)",410        [(now, r["level"], r["key"], r["n"], round(r["p25"]), round(r["p50"]),411          round(r["p75"]), r["area_p50"]) for r in refs.values()412         if r["level"] <= 2])413    con.commit()414    con.close()415    stats["seconds"] = round(time.time() - t0, 1)416    stats["estimees"] = len(rows)417    if verbose:418        c = {k: round(v[0], 3) for k, v in coefs.items()}419        print(f"[lou-ka] fairvalue: {stats['estimees']} estimées "420              f"({stats['segments']} segments) en {stats['seconds']}s — "421              f"sous {stats['sous']} / marché {stats['marche']} / "422              f"sur {stats['sur']} / sans verdict {stats['sans_verdict']}")423        print(f"[lou-ka] fairvalue coefs: {c}")424    return stats425426427# ------------------------------------------------- explication d'une annonce428def explain(uid: str) -> dict | None:429    """Détail d'une estimation pour la fiche (analyse de prix) : fair value,430    fourchette, confiance, distribution du segment (mini-graphique)."""431    from . import db432    con = db.connect()433    _ensure_tables(con)434    row = con.execute("SELECT * FROM fairvalue WHERE uid=?", (uid,)).fetchone()435    if row is None:436        con.close()437        return None438    d = dict(row)439    # distribution des loyers du segment de référence (pour l'histogramme)440    level, key = d["segment"].split("|", 1)441    parts = key.split("|")442    sql = ("SELECT price FROM listings WHERE active=1 AND published=1"443           " AND dup_of IS NULL AND price BETWEEN 175 AND 20000")444    args: list = []445    if level == "1":446        sql += " AND city=? AND sector=?"447        args += parts[0:2]448        bed = parts[2]449    elif level == "2":450        sql += " AND city=?"451        args.append(parts[0])452        bed = parts[1]453    else:454        bed = parts[0] if parts[0] != "global" else None455    prices = [r["price"] for r in con.execute(sql, args)]456    if bed is not None:457        beds = {r["uid"]: r["bedrooms"] for r in con.execute(458            "SELECT uid, bedrooms FROM listings WHERE active=1 AND published=1")}459        # re-filtrer par groupe de chambres via une requête directe (plus sûr)460        sql2 = sql + (" AND ((?='0' AND (bedrooms IS NOT NULL AND CAST(ROUND(bedrooms) AS INT)<=0))"461                      " OR (?='4+' AND CAST(ROUND(bedrooms) AS INT)>=4)"462                      " OR (CAST(ROUND(bedrooms) AS INT) = CAST(? AS INT) AND ? NOT IN ('0','4+')))")463        prices = [r["price"] for r in con.execute(sql2, args + [bed, bed, bed, bed])]464    con.close()465    prices.sort()466    # histogramme en 24 classes bornées p1–p99 (queues écrasées sinon)467    hist = []468    if len(prices) >= 8:469        lo, hi = _pct(prices, 0.01), _pct(prices, 0.99)470        if hi > lo:471            nbins = 24472            counts = [0] * nbins473            for p in prices:474                i = int((min(max(p, lo), hi) - lo) / (hi - lo) * (nbins - 1))475                counts[i] += 1476            hist = [{"x0": round(lo + (hi - lo) * i / nbins),477                     "x1": round(lo + (hi - lo) * (i + 1) / nbins),478                     "n": c} for i, c in enumerate(counts)]479    d["segment_n"] = len(prices)480    d["histogram"] = hist481    return d482