#!/usr/bin/env python3 # -*- coding: utf-8 -*- """Réseau de neurones tabulaire ROBUSTE : embeddings + ResNet-MLP, ensemble multi-seed, Huber loss, AdamW + cosine, grad-clip, early stopping. 5-fold CV out-of-sample, comparable aux autres modèles (seed=42).""" import json, re, warnings import numpy as np, pandas as pd import torch, torch.nn as nn from sklearn.model_selection import KFold from sklearn.metrics import r2_score warnings.filterwarnings("ignore") CUR_YEAR=2026 dev="mps" if torch.backends.mps.is_available() else "cpu" print("device:",dev) SEEDS=[42,1,7] # ensemble par fold D_MAIN=256; N_BLOCKS=3; D_HIDDEN=512 # ======== reconstruction du dataset (identique v4) ======== specs={} for l in open("data/processed/specs.jsonl",encoding="utf-8"): r=json.loads(l) if r.get("specs"): specs[r["uid"]]=r["specs"] COND={"a_renover":0,"original":1,"bon":2,"renove":3,"refait_neuf":4,"neuf":5,"inconnu":2} BOOL=["turnkey","recently_renovated","garage","pool","waterfront","finished_basement","fireplace","central_ac","new_construction","income_property","renovated_kitchen","renovated_bathroom","large_lot","quiet_area"] def gfeat(uid): 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}) if not s: return o for b in BOOL: o["g_"+b]=int(bool(s.get(b))) o["g_cond"]=COND.get(s.get("condition","inconnu"),2) fl=s.get("flooring") or [];o["g_hardwood"]=int("bois_franc" in fl) o["g_quartz_granite"]=int(s.get("countertop") in ("quartz","granit","marbre"));o["g_view_water"]=int(s.get("view")=="eau");return o def parse_area(s): if not s or not isinstance(s,str): return np.nan t=s.lower();m=("m2" in t) or ("m²" in t);n=re.sub(r"[^0-9.,\s]","",t).strip().replace(" ","").replace(",","") try: v=float(n) except: return np.nan return (v*10.7639 if m else v) if v>0 else np.nan def cc(c): if not c or not isinstance(c,str): return None c=c.split("(")[0].strip();return re.sub(r"\s+"," ",c).lower().strip() or None def ti(x): try: return int(x) except: return np.nan AMEN={"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"]} def sd(s): s=s.lower() if "ft" in s: f=re.search(r"([\d.]+)\s*ft",s);i=re.search(r"([\d.]+)\s*in",s) return (float(f.group(1)) if f else 0)+(float(i.group(1))/12 if i else 0) m=re.search(r"([\d.,]+)\s*m",s) if m: try: return float(m.group(1).replace(",","."))*3.28084 except: return None return None def ra(d): if not d or "x" not in d: return None a,_,b=d.partition("x");la,lb=sd(a),sd(b);return la*lb if (la and lb and 10 else np.nan;o["n_levels_r"]=len(lv);return o data=json.load(open("data/processed/maisons_avec_prix.json",encoding="utf-8"));rows=[] for d in data: p=d.get("property") or {};ad=d.get("address") or {};ft=d.get("features") or [] blob=(" ".join(ft)+" "+(d.get("description") or "")+" "+(d.get("title") or "")).lower() yb=ti(p.get("yearBuilt"));age=(CUR_YEAR-yb) if (not pd.isna(yb) and 1700<=yb<=CUR_YEAR) else np.nan pt=p.get("buildingType") or p.get("type");st=p.get("storeys") or p.get("levels") try: st=float(str(st).replace(",",".")) except: st=np.nan uid=f"{d.get('source')}:{d.get('listingId')}" 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"))} for n,kw in AMEN.items(): rec[n]=int(any(k in blob for k in kw)) rec["n_images"]=len(d.get("images") or []);rec["virtual_tour"]=int(bool(d.get("virtualTourUrl"))) rec.update(pr(d.get("rooms")));rec.update(gfeat(uid));rows.append(rec) df=pd.DataFrame(rows) df=df[df["price"].notna()];df=df[(df["price"]>=50_000)&(df["price"]<=15_000_000)] df.loc[(df["sqft"]<300)|(df["sqft"]>20_000),"sqft"]=np.nan df.loc[(df["lot_sqft"]<200)|(df["lot_sqft"]>5_000_000),"lot_sqft"]=np.nan df.loc[df["beds"]>15,"beds"]=np.nan;df.loc[df["baths"]>12,"baths"]=np.nan df["ppsf"]=df["price"]/df["sqft"];lo,hi=df["ppsf"].quantile([0.005,0.995]) df=df[~(df["ppsf"].notna()&((df["ppsf"]hi)))] ov=df[df["sqft"].notna()&df["room_area"].notna()&(df["room_area"]>200)];ratio=(ov["sqft"]/ov["room_area"]).median() re_=df["room_area"]*ratio;ok=df["room_area"].notna()&(re_.between(300,20000)) df["sqft_final"]=df["sqft"];fill=df["sqft_final"].isna()&ok;df.loc[fill,"sqft_final"]=re_[fill] df["has_sqft"]=df["sqft_final"].notna().astype(int);df["age2"]=df["age"]**2;df["n_images_c"]=df["n_images"].clip(upper=60) vc=df["city"].value_counts();keep=set(vc[vc>=20].index);df["city_fe"]=df["city"].where(df["city"].isin(keep),"__other__").fillna("__other__") tc=df["ptype"].value_counts();df["ptype_fe"]=df["ptype"].where(df["ptype"].isin(set(tc[tc>=30].index)),"Other") df=df.reset_index(drop=True);print("N=",len(df)) amen=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"] gv=["g_cond","g_hardwood","g_quartz_granite","g_view_water"]+["g_"+b for b in BOOL] num=["sqft_final","has_sqft","lot_sqft","beds","baths","parking","storeys","age","age2","n_images_c","virtual_tour"]+amen+rv+gv # indicateurs de manquant (robustesse) pour les colonnes à NaN missnum=["sqft_final","lot_sqft","beds","baths","storeys","age","age2"] for c in missnum: df["miss_"+c]=df[c].isna().astype(np.float32) num=num+["miss_"+c for c in missnum] y=np.log(df["price"].values).astype(np.float32) cats=["city_fe","ptype_fe","source"];card={} for c in cats: u=sorted(df[c].astype(str).unique());m={v:i for i,v in enumerate(u)};card[c]=len(u) df[c+"_ix"]=df[c].astype(str).map(m).astype(np.int64) Xnum=df[num].astype(np.float32).values Xcat=df[[c+"_ix" for c in cats]].values.astype(np.int64) class ResBlock(nn.Module): def __init__(self,d,dh,p): super().__init__() self.bn=nn.BatchNorm1d(d);self.l1=nn.Linear(d,dh);self.act=nn.GELU() self.dp1=nn.Dropout(p);self.l2=nn.Linear(dh,d);self.dp2=nn.Dropout(p) def forward(self,x): z=self.bn(x);z=self.act(self.l1(z));z=self.dp1(z);z=self.l2(z);z=self.dp2(z);return x+z class ResNetTab(nn.Module): def __init__(self,ncard,nnum): super().__init__() self.embs=nn.ModuleList();tot=0 for c in ncard: d=max(2,min(64,round(1.6*c**0.56)));self.embs.append(nn.Embedding(c,d));tot+=d self.embdp=nn.Dropout(0.1) self.bn0=nn.BatchNorm1d(nnum) self.proj=nn.Linear(tot+nnum,D_MAIN) self.blocks=nn.ModuleList([ResBlock(D_MAIN,D_HIDDEN,0.2) for _ in range(N_BLOCKS)]) self.head=nn.Sequential(nn.BatchNorm1d(D_MAIN),nn.GELU(),nn.Linear(D_MAIN,1)) def forward(self,xc,xn): e=[emb(xc[:,i]) for i,emb in enumerate(self.embs)] x=torch.cat([self.embdp(torch.cat(e,1)),self.bn0(xn)],1) x=self.proj(x) for b in self.blocks: x=b(x) return self.head(x).squeeze(1) def train_one(tr,te,seed): torch.manual_seed(seed);np.random.seed(seed) med=np.nanmedian(Xnum[tr],axis=0);med=np.where(np.isnan(med),0,med) Xn=Xnum.copy() for j in range(Xn.shape[1]): col=Xn[:,j];col[np.isnan(col)]=med[j];Xn[:,j]=col mu=Xn[tr].mean(0);sg=Xn[tr].std(0)+1e-6;Xn=(Xn-mu)/sg ym=y[tr].mean();ys=y[tr].std()+1e-6;yz=(y-ym)/ys ntr=len(tr);perm=np.random.permutation(ntr);vc_=int(ntr*0.9) tri=tr[perm[:vc_]];vai=tr[perm[vc_:]] def T(a,dt): return torch.tensor(a,device=dev,dtype=dt) model=ResNetTab([card[c] for c in cats],Xn.shape[1]).to(dev) opt=torch.optim.AdamW(model.parameters(),lr=2e-3,weight_decay=1e-4) EPOCHS=200 sched=torch.optim.lr_scheduler.CosineAnnealingLR(opt,T_max=EPOCHS) lossf=nn.SmoothL1Loss(beta=1.0) # Huber : robuste aux queues Xc_t=T(Xcat,torch.long);Xn_t=T(Xn,torch.float32);y_t=T(yz,torch.float32) best=1e9;bstate=None;pat=0 for ep in range(EPOCHS): model.train();idx=np.random.permutation(tri) for i in range(0,len(idx),512): b=idx[i:i+512] opt.zero_grad();p=model(Xc_t[b],Xn_t[b]);l=lossf(p,y_t[b]) l.backward();torch.nn.utils.clip_grad_norm_(model.parameters(),5.0);opt.step() sched.step() model.eval() with torch.no_grad(): vp=model(Xc_t[vai],Xn_t[vai]);vl=((vp-y_t[vai])**2).mean().item() if vl=20: break model.load_state_dict(bstate);model.eval() with torch.no_grad(): pz=model(Xc_t[te],Xn_t[te]).cpu().numpy() return pz*ys+ym kf=KFold(n_splits=5,shuffle=True,random_state=42) pred=np.zeros_like(y) for f,(tr,te) in enumerate(kf.split(Xnum)): ps=np.zeros(len(te),dtype=np.float64) for sd_ in SEEDS: ps+=train_one(tr,te,sd_) pred[te]=ps/len(SEEDS) # moyenne d'ensemble r2f=r2_score(y[te],pred[te]);print(f" fold {f+1}: R²={r2f:.4f} (ensemble de {len(SEEDS)})") r2=r2_score(y,pred);err=np.abs(np.exp(pred-y)-1) print("\n=== NN ROBUSTE (ResNet-tab, ensemble multi-seed) — 5-fold CV OOS ===") print(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}%") print("\nRappel (mêmes folds): OLS 0.628 | RF 0.678 | NN simple 0.711 | HistGBM 0.743") np.save("data/processed/nn_robust_oos.npy",pred)