SPB Git forge

spb/wp14_uqo

Public
3commits 1branches 1releases
1.5 MBsize
maindefault branch
1 h agolast push
Python 52.4% TeX 47.6%
9.9 KB · 171 lines python
Raw Blame History
1#!/usr/bin/env python32# -*- coding: utf-8 -*-3"""Réseau de neurones tabulaire (embeddings ville/type + MLP) — 5-fold CV.4Comparable aux modèles de hedonic_ml.py (même variables, même KFold seed=42)."""5import json, re, warnings6import numpy as np, pandas as pd7import torch, torch.nn as nn8from sklearn.model_selection import KFold9from sklearn.metrics import r2_score10warnings.filterwarnings("ignore")11torch.manual_seed(42); np.random.seed(42)12CUR_YEAR=202613dev="mps" if torch.backends.mps.is_available() else "cpu"14print("device:",dev)1516# ---- specs GPT ----17specs={}18for l in open("data/processed/specs.jsonl",encoding="utf-8"):19    r=json.loads(l)20    if r.get("specs"): specs[r["uid"]]=r["specs"]21COND={"a_renover":0,"original":1,"bon":2,"renove":3,"refait_neuf":4,"neuf":5,"inconnu":2}22BOOL=["turnkey","recently_renovated","garage","pool","waterfront","finished_basement","fireplace","central_ac","new_construction","income_property","renovated_kitchen","renovated_bathroom","large_lot","quiet_area"]23def gfeat(uid):24    s=specs.get(uid);o={"g_"+b:0 for b in BOOL};o.update({"g_cond":2,"g_hardwood":0,"g_quartz_granite":0,"g_view_water":0})25    if not s: return o26    for b in BOOL: o["g_"+b]=int(bool(s.get(b)))27    o["g_cond"]=COND.get(s.get("condition","inconnu"),2)28    fl=s.get("flooring") or [];o["g_hardwood"]=int("bois_franc" in fl)29    o["g_quartz_granite"]=int(s.get("countertop") in ("quartz","granit","marbre"));o["g_view_water"]=int(s.get("view")=="eau");return o30def parse_area(s):31    if not s or not isinstance(s,str): return np.nan32    t=s.lower();m=("m2" in t) or ("m²" in t);n=re.sub(r"[^0-9.,\s]","",t).strip().replace(" ","").replace(",","")33    try: v=float(n)34    except: return np.nan35    return (v*10.7639 if m else v) if v>0 else np.nan36def cc(c):37    if not c or not isinstance(c,str): return None38    c=c.split("(")[0].strip();return re.sub(r"\s+"," ",c).lower().strip() or None39def ti(x):40    try: return int(x)41    except: return np.nan42AMEN={"pool":["swimming pool","piscine","pool"],"waterfront":["waterfront","bord de l'eau","navigable","lakefront","riverfront","au bord de"],"garage":["garage"],"fireplace":["fireplace","foyer","wood stove","poêle"],"central_air":["central air","air conditionné","climatisation","a/c","heat-pump","thermopompe","air exchanger"],"finished_bsmt":["totally finished","finished basement","sous-sol fini","basement"],"near_water":["near water","waterfront","navigable"],"cul_de_sac":["cul-de-sac","cul de sac"],"golf":["golf"],"ski":["ski"],"near_park":["park","parc"],"near_school":["school","école","cegep","university","université","elementary","high school"],"near_highway":["highway","autoroute"],"transit":["public transit","public transportation","transport"],"no_rear_neighbor":["no backyard neighbors","sans voisin arrière"]}43def sd(s):44    s=s.lower()45    if "ft" in s:46        f=re.search(r"([\d.]+)\s*ft",s);i=re.search(r"([\d.]+)\s*in",s)47        return (float(f.group(1)) if f else 0)+(float(i.group(1))/12 if i else 0)48    m=re.search(r"([\d.,]+)\s*m",s)49    if m:50        try: return float(m.group(1).replace(",","."))*3.2808451        except: return None52    return None53def ra(d):54    if not d or "x" not in d: return None55    a,_,b=d.partition("x");la,lb=sd(a),sd(b);return la*lb if (la and lb and 1<la<200 and 1<lb<200) else None56RF={"r_office":["office"],"r_family":["family room"],"r_workshop":["workshop"],"r_winecellar":["wine cellar"],"r_sunroom":["sunroom","solarium"],"r_mezzanine":["mezzanine"],"r_library":["library"],"r_laundry":["laundry"]}57def pr(rooms):58    o={"room_area":np.nan,"n_rooms":0,"has_primary_bed":0,"has_basement":0,"n_levels_r":0}59    for k in RF: o[k]=060    if not rooms: return o61    ar=0.0;lv=set()62    for r in rooms:63        nm=(r.get("name") or "").lower();l=(r.get("level") or "").lower();o["n_rooms"]+=164        if l: lv.add(l)65        if "basement" in l or "sous-sol" in l: o["has_basement"]=166        if "primary bedroom" in nm: o["has_primary_bed"]=167        for k,kw in RF.items():68            if any(w in nm for w in kw): o[k]=169        a=ra(r.get("dimensions"))70        if a: ar+=a71    o["room_area"]=ar if ar>0 else np.nan;o["n_levels_r"]=len(lv);return o72data=json.load(open("data/processed/maisons_avec_prix.json",encoding="utf-8"));rows=[]73for d in data:74    p=d.get("property") or {};ad=d.get("address") or {};ft=d.get("features") or []75    blob=(" ".join(ft)+" "+(d.get("description") or "")+" "+(d.get("title") or "")).lower()76    yb=ti(p.get("yearBuilt"));age=(CUR_YEAR-yb) if (not pd.isna(yb) and 1700<=yb<=CUR_YEAR) else np.nan77    pt=p.get("buildingType") or p.get("type");st=p.get("storeys") or p.get("levels")78    try: st=float(str(st).replace(",","."))79    except: st=np.nan80    uid=f"{d.get('source')}:{d.get('listingId')}"81    rec={"uid":uid,"price":d.get("priceNumber"),"source":d.get("source"),"sqft":parse_area(p.get("squareFeet")),"lot_sqft":parse_area(p.get("lotSize")),"beds":ti(p.get("bedrooms")),"baths":ti(p.get("bathrooms")),"parking":ti(p.get("parkingSpaces")),"storeys":st,"age":age,"ptype":(pt or "Unknown").strip(),"city":cc(ad.get("city"))}82    for n,kw in AMEN.items(): rec[n]=int(any(k in blob for k in kw))83    rec["n_images"]=len(d.get("images") or []);rec["virtual_tour"]=int(bool(d.get("virtualTourUrl")))84    rec.update(pr(d.get("rooms")));rec.update(gfeat(uid));rows.append(rec)85df=pd.DataFrame(rows)86df=df[df["price"].notna()];df=df[(df["price"]>=50_000)&(df["price"]<=15_000_000)]87df.loc[(df["sqft"]<300)|(df["sqft"]>20_000),"sqft"]=np.nan88df.loc[(df["lot_sqft"]<200)|(df["lot_sqft"]>5_000_000),"lot_sqft"]=np.nan89df.loc[df["beds"]>15,"beds"]=np.nan;df.loc[df["baths"]>12,"baths"]=np.nan90df["ppsf"]=df["price"]/df["sqft"];lo,hi=df["ppsf"].quantile([0.005,0.995])91df=df[~(df["ppsf"].notna()&((df["ppsf"]<lo)|(df["ppsf"]>hi)))]92ov=df[df["sqft"].notna()&df["room_area"].notna()&(df["room_area"]>200)];ratio=(ov["sqft"]/ov["room_area"]).median()93re_=df["room_area"]*ratio;ok=df["room_area"].notna()&(re_.between(300,20000))94df["sqft_final"]=df["sqft"];fill=df["sqft_final"].isna()&ok;df.loc[fill,"sqft_final"]=re_[fill]95df["has_sqft"]=df["sqft_final"].notna().astype(int);df["age2"]=df["age"]**2;df["n_images_c"]=df["n_images"].clip(upper=60)96vc=df["city"].value_counts();keep=set(vc[vc>=20].index);df["city_fe"]=df["city"].where(df["city"].isin(keep),"__other__").fillna("__other__")97tc=df["ptype"].value_counts();df["ptype_fe"]=df["ptype"].where(df["ptype"].isin(set(tc[tc>=30].index)),"Other")98df=df.reset_index(drop=True);print("N=",len(df))99100amen=list(AMEN.keys());rv=["n_rooms","n_levels_r","has_primary_bed","has_basement","r_office","r_family","r_workshop","r_winecellar","r_sunroom","r_mezzanine","r_library","r_laundry"]101gv=["g_cond","g_hardwood","g_quartz_granite","g_view_water"]+["g_"+b for b in BOOL]102num=["sqft_final","has_sqft","lot_sqft","beds","baths","parking","storeys","age","age2","n_images_c","virtual_tour"]+amen+rv+gv103y=df["ln_price"]=np.log(df["price"]);y=y.values.astype(np.float32)104# encodage catégoriel -> indices105cats=["city_fe","ptype_fe","source"];maps={};card={}106for c in cats:107    u=sorted(df[c].astype(str).unique());m={v:i for i,v in enumerate(u)};maps[c]=m;card[c]=len(u)108    df[c+"_ix"]=df[c].astype(str).map(m).astype(np.int64)109Xnum=df[num].astype(np.float32).values110Xcat=df[[c+"_ix" for c in cats]].values.astype(np.int64)111112class Net(nn.Module):113    def __init__(self,ncard,nnum):114        super().__init__()115        self.embs=nn.ModuleList()116        tot=0117        for c in ncard:118            d=min(50,(c+1)//2); d=max(d,2); self.embs.append(nn.Embedding(c,d)); tot+=d119        self.bn=nn.BatchNorm1d(nnum)120        self.net=nn.Sequential(121            nn.Linear(tot+nnum,256),nn.ReLU(),nn.BatchNorm1d(256),nn.Dropout(0.3),122            nn.Linear(256,128),nn.ReLU(),nn.BatchNorm1d(128),nn.Dropout(0.2),123            nn.Linear(128,64),nn.ReLU(),nn.Dropout(0.1),124            nn.Linear(64,1))125    def forward(self,xc,xn):126        e=[emb(xc[:,i]) for i,emb in enumerate(self.embs)]127        x=torch.cat(e+[self.bn(xn)],1);return self.net(x).squeeze(1)128129def train_predict(tr,te):130    # imputation médiane (train) + standardisation131    med=np.nanmedian(Xnum[tr],axis=0);med=np.where(np.isnan(med),0,med)132    Xn=Xnum.copy()133    for j in range(Xn.shape[1]):134        col=Xn[:,j];mask=np.isnan(col);col[mask]=med[j];Xn[:,j]=col135    mu=Xn[tr].mean(0);sg=Xn[tr].std(0)+1e-6;Xn=(Xn-mu)/sg136    ym=y[tr].mean();ys=y[tr].std()+1e-6;yz=(y-ym)/ys137    # split interne train/val pour early stopping138    ntr=len(tr);perm=np.random.permutation(ntr);vcut=int(ntr*0.9)139    tri=tr[perm[:vcut]];vai=tr[perm[vcut:]]140    def T(a,dt): return torch.tensor(a,device=dev,dtype=dt)141    model=Net([card[c] for c in cats],Xn.shape[1]).to(dev)142    opt=torch.optim.Adam(model.parameters(),lr=1e-3,weight_decay=1e-5)143    lossf=nn.MSELoss()144    Xc_t=T(Xcat,torch.long);Xn_t=T(Xn,torch.float32);y_t=T(yz,torch.float32)145    best=1e9;bstate=None;patience=0146    for ep in range(150):147        model.train();idx=np.random.permutation(tri)148        for i in range(0,len(idx),512):149            b=idx[i:i+512]150            opt.zero_grad();p=model(Xc_t[b],Xn_t[b]);l=lossf(p,y_t[b]);l.backward();opt.step()151        model.eval()152        with torch.no_grad():153            vp=model(Xc_t[vai],Xn_t[vai]);vl=lossf(vp,y_t[vai]).item()154        if vl<best-1e-4: best=vl;bstate={k:v.clone() for k,v in model.state_dict().items()};patience=0155        else:156            patience+=1157            if patience>=12: break158    model.load_state_dict(bstate);model.eval()159    with torch.no_grad():160        pz=model(Xc_t[te],Xn_t[te]).cpu().numpy()161    return pz*ys+ym162163kf=KFold(n_splits=5,shuffle=True,random_state=42)164pred=np.zeros_like(y)165for f,(tr,te) in enumerate(kf.split(Xnum)):166    pred[te]=train_predict(tr,te);print(f"  fold {f+1} fait")167r2=r2_score(y,pred);err=np.abs(np.exp(pred-y)-1)168print("\n=== Réseau de neurones (embeddings + MLP) — 5-fold CV OOS ===")169print(f"R² OOS = {r2:.4f}   |   erreur médiane = {np.median(err)*100:.1f}%   |   ±15% = {np.mean(err<=.15)*100:.0f}%   ±25% = {np.mean(err<=.25)*100:.0f}%")170print("\nRappel (mêmes folds) : OLS 0.628 | RF 0.678 | HistGBM 0.743")171