SPB Git forge

spb/uqo-eval

Public
55commits 2branches 0releases
134.4 MBsize
maindefault branch
yesterdaylast push
TypeScript 90.1% JavaScript 3.2% Python 3.2% CSS 1.8% HTML 1.8%
11.7 KB · 268 lines typescript
Raw Blame History
1// UQO Éval — modélisation hédonique : de l'échantillon à la matrice de design.2import { allMunicipalities } from "../municipalites";3import type { Obs } from "./data";4import { zeros, type Mat } from "./linalg";5import { LIMITS, type HedonicSpec, type VarKey } from "./spec";67export type ColKind = "intercept" | "cont" | "dummy" | "fe_time" | "fe_geo" | "trend";89export interface Column {10  name: string; // identifiant stable (ex. ln_aire, type=condo, t=2024-T2, g=Gatineau)11  label: { fr: string; en: string };12  kind: ColKind;13  var?: VarKey | "age2";14  level?: string;15  base?: string; // niveau de référence (dummies, effets fixes)16  log?: boolean;17  n?: number; // effectif du niveau (dummies)18}1920export interface Design {21  X: Mat;22  y: Float64Array;23  cols: Column[];24  obs: Obs[];25  dropped: { reason: { fr: string; en: string }; n: number }[];26  clusters: Int32Array; // identifiant de grappe (zone géo) par observation27  nClusters: number;28  timeLevels: { level: string; n: number; base: boolean }[];29  geoLevels: { level: string; n: number; base: boolean }[];30  medians: Record<string, number>; // médianes des variables continues brutes (prédicteur)31  contVars: { var: VarKey | "age2"; col: number; log: boolean }[];32  center: { lat: number; lng: number };33}3435const LABELS: Record<string, { fr: string; en: string }> = {36  aire: { fr: "Aire d'étages (m²)", en: "Floor area (m²)" },37  terrain: { fr: "Terrain (m²)", en: "Lot (m²)" },38  age: { fr: "Âge (ans)", en: "Age (years)" },39  age2: { fr: "Âge² (÷100)", en: "Age² (÷100)" },40  nb_logements: { fr: "Nb logements", en: "Dwellings" },41  nb_etages: { fr: "Nb étages", en: "Storeys" },42  dist_centre: { fr: "Distance au centre (km)", en: "Distance to centre (km)" },43  valeur_role: { fr: "Valeur au rôle ($)", en: "Assessed value ($)" },44  bedrooms: { fr: "Chambres", en: "Bedrooms" },45  bathrooms: { fr: "Salles de bain", en: "Bathrooms" },46  type: { fr: "Type", en: "Type" },47  genre: { fr: "Genre", en: "Style" },48  lien: { fr: "Lien", en: "Link" },49};5051const CONT_VARS: VarKey[] = ["aire", "terrain", "age", "nb_logements", "nb_etages", "dist_centre", "valeur_role", "bedrooms", "bathrooms"];52const CAT_VARS: VarKey[] = ["type", "genre", "lien"];5354function contValue(o: Obs, v: VarKey, center: Map<string, { lat: number; lng: number }>): number | null {55  switch (v) {56    case "aire":57      return o.aire;58    case "terrain":59      return o.terrain;60    case "age":61      return o.annee ? o.year - o.annee : null;62    case "nb_logements":63      return o.nb_logements != null && o.nb_logements > 0 ? o.nb_logements : null;64    case "nb_etages":65      return o.nb_etages != null && o.nb_etages > 0 ? o.nb_etages : null;66    case "dist_centre": {67      const c = o.muni ? center.get(o.muni.toLowerCase()) : undefined;68      if (!c) return null;69      const dLat = (o.lat - c.lat) * 111;70      const dLng = (o.lng - c.lng) * 111 * Math.cos((c.lat * Math.PI) / 180);71      return Math.max(0.05, Math.sqrt(dLat * dLat + dLng * dLng));72    }73    case "valeur_role":74      return o.valeur_role;75    case "bedrooms":76      return o.bedrooms != null && o.bedrooms >= 0 ? o.bedrooms : null;77    case "bathrooms":78      return o.bathrooms != null && o.bathrooms >= 0 ? o.bathrooms : null;79    default:80      return null;81  }82}8384function catValue(o: Obs, v: VarKey): string {85  const raw = v === "type" ? o.type : v === "genre" ? o.genre : o.lien;86  return raw && raw.trim() ? raw.trim() : "inconnu";87}8889function geoKey(o: Obs, spec: HedonicSpec): string {90  switch (spec.fe.geo) {91    case "muni":92      return o.muni ?? "?";93    case "arrond":94      return o.arrond ? `${o.muni ?? "?"} · ${o.arrond}` : (o.muni ?? "?");95    case "voisinage":96      return o.voisinage ? `${o.muni ?? "?"} · UV ${o.voisinage}` : `${o.muni ?? "?"} · UV ?`;97    case "grid1":98    case "grid2":99    case "grid5": {100      const km = spec.fe.geo === "grid1" ? 1 : spec.fe.geo === "grid2" ? 2 : 5;101      const dLat = km / 111;102      const dLng = km / (111 * Math.cos((o.lat * Math.PI) / 180));103      const clat = (Math.floor(o.lat / dLat) + 0.5) * dLat;104      const clng = (Math.floor(o.lng / dLng) + 0.5) * dLng;105      return `${km} km @ ${clat.toFixed(3)}, ${clng.toFixed(3)}`;106    }107    default:108      return o.muni ?? "?";109  }110}111112function timeKey(o: Obs, spec: HedonicSpec): string {113  return spec.fe.time === "year" ? String(o.year) : spec.fe.time === "quarter" ? o.quarter : o.month;114}115116/** Niveaux d'une variable catégorielle : base = plus fréquent, niveaux rares → « autre », plafond. */117function levelize(values: string[], maxLevels: number, minCount: number, otherLabel: string) {118  const counts = new Map<string, number>();119  for (const v of values) counts.set(v, (counts.get(v) ?? 0) + 1);120  const sorted = [...counts.entries()].sort((a, b) => b[1] - a[1]);121  const keep = new Set(sorted.filter(([, n], i) => n >= minCount && i < maxLevels - 1).map(([l]) => l));122  const mapped = values.map((v) => (keep.has(v) ? v : otherLabel));123  const c2 = new Map<string, number>();124  for (const v of mapped) c2.set(v, (c2.get(v) ?? 0) + 1);125  const levels = [...c2.entries()].sort((a, b) => b[1] - a[1]);126  return { mapped, levels: levels.map(([level, n], i) => ({ level, n, base: i === 0 })), base: levels[0]?.[0] ?? otherLabel };127}128129export function buildDesign(obs0: Obs[], spec: HedonicSpec): Design {130  const dropped: Design["dropped"] = [];131  const centerMap = new Map<string, { lat: number; lng: number }>();132  for (const m of allMunicipalities()) centerMap.set(m.nom.toLowerCase(), { lat: m.lat, lng: m.lng });133134  const vars = spec.vars.filter((v, i, a) => a.indexOf(v) === i);135  const cont = CONT_VARS.filter((v) => vars.includes(v));136  const cats = CAT_VARS.filter((v) => vars.includes(v));137  const logSet = new Set(spec.logVars.filter((v) => cont.includes(v)));138  const useAge2 = spec.ageSquared && cont.includes("age");139  const useTrend = vars.includes("trend_xy");140141  // 1. observations complètes pour les variables continues retenues142  let obs = obs0;143  for (const v of cont) {144    const before = obs.length;145    obs = obs.filter((o) => {146      const x = contValue(o, v, centerMap);147      return x != null && Number.isFinite(x) && (!logSet.has(v) || x > 0);148    });149    if (before - obs.length) dropped.push({ reason: { fr: `${LABELS[v].fr} manquante`, en: `${LABELS[v].en} missing` }, n: before - obs.length });150  }151  const n = obs.length;152  if (n < LIMITS.minN) throw new Error(`échantillon trop petit après nettoyage (${n} < ${LIMITS.minN}) — élargissez la zone, la période ou retirez des variables`);153154  // 2. colonnes155  const cols: Column[] = [{ name: "const", label: { fr: "Constante", en: "Intercept" }, kind: "intercept" }];156  const contVars: Design["contVars"] = [];157  const medians: Record<string, number> = {};158  for (const v of cont) {159    const lg = logSet.has(v);160    contVars.push({ var: v, col: cols.length, log: lg });161    cols.push({ name: lg ? `ln_${v}` : v, label: lg ? { fr: `ln ${LABELS[v].fr}`, en: `ln ${LABELS[v].en}` } : LABELS[v], kind: "cont", var: v, log: lg });162    const vals = obs.map((o) => contValue(o, v, centerMap)!).sort((a, b) => a - b);163    medians[v] = vals[Math.floor(vals.length / 2)];164  }165  if (useAge2) {166    contVars.push({ var: "age2", col: cols.length, log: false });167    cols.push({ name: "age2", label: LABELS.age2, kind: "cont", var: "age2" });168  }169  const meanLat = obs.reduce((s, o) => s + o.lat, 0) / n;170  const meanLng = obs.reduce((s, o) => s + o.lng, 0) / n;171  if (useTrend) {172    for (const [name, fr, en] of [173      ["trend_x", "Tendance est-ouest (×10 km)", "East-west trend (×10 km)"],174      ["trend_y", "Tendance nord-sud (×10 km)", "North-south trend (×10 km)"],175      ["trend_x2", "Tendance x²", "Trend x²"],176      ["trend_y2", "Tendance y²", "Trend y²"],177      ["trend_xy", "Tendance x·y", "Trend x·y"],178    ] as const)179      cols.push({ name, label: { fr, en }, kind: "trend", var: "trend_xy" });180  }181  const catLevels: { v: VarKey; mapped: string[]; base: string; first: number }[] = [];182  for (const v of cats) {183    const lv = levelize(obs.map((o) => catValue(o, v)), 12, Math.max(5, Math.round(n * 0.005)), "autre");184    const first = cols.length;185    for (const l of lv.levels) if (!l.base) cols.push({ name: `${v}=${l.level}`, label: { fr: `${LABELS[v].fr} : ${l.level}`, en: `${LABELS[v].en}: ${l.level}` }, kind: "dummy", var: v, level: l.level, base: lv.base, n: l.n });186    catLevels.push({ v, mapped: lv.mapped, base: lv.base, first });187  }188  let timeLevels: Design["timeLevels"] = [];189  let timeMapped: string[] = [];190  let timeBase = "";191  if (spec.fe.time !== "none") {192    const lv = levelize(obs.map((o) => timeKey(o, spec)), 80, 1, "autre");193    // base temporelle = période la plus ancienne (indice = 100 au départ)194    const chrono = [...lv.levels].sort((a, b) => a.level.localeCompare(b.level));195    timeBase = chrono[0].level;196    timeLevels = chrono.map((l) => ({ ...l, base: l.level === timeBase }));197    timeMapped = lv.mapped;198    for (const l of timeLevels) if (!l.base) cols.push({ name: `t=${l.level}`, label: { fr: `Période ${l.level}`, en: `Period ${l.level}` }, kind: "fe_time", level: l.level, base: timeBase, n: l.n });199  }200  let geoLevels: Design["geoLevels"] = [];201  let geoMapped: string[] = obs.map((o) => o.muni ?? "?");202  let geoBase = "";203  if (spec.fe.geo !== "none") {204    const lv = levelize(obs.map((o) => geoKey(o, spec)), LIMITS.maxGeoLevels, 3, "autres zones");205    geoLevels = lv.levels;206    geoMapped = lv.mapped;207    geoBase = lv.base;208    for (const l of geoLevels) if (!l.base) cols.push({ name: `g=${l.level}`, label: { fr: `Zone ${l.level}`, en: `Zone ${l.level}` }, kind: "fe_geo", level: l.level, base: geoBase, n: l.n });209  }210  if (cols.length > LIMITS.maxCols) throw new Error(`trop de colonnes (${cols.length} > ${LIMITS.maxCols}) — réduisez la granularité des effets fixes`);211212  // 3. remplissage213  const k = cols.length;214  const X = zeros(n, k);215  const y = new Float64Array(n);216  const colIndex = new Map(cols.map((c, i) => [c.name, i]));217  for (let i = 0; i < n; i++) {218    const o = obs[i];219    const off = i * k;220    X.a[off] = 1;221    for (const cv of contVars) {222      if (cv.var === "age2") {223        const a = contValue(o, "age", centerMap)!;224        X.a[off + cv.col] = (a * a) / 100;225      } else {226        const x = contValue(o, cv.var, centerMap)!;227        X.a[off + cv.col] = cv.log ? Math.log(x) : x;228      }229    }230    if (useTrend) {231      const dx = ((o.lng - meanLng) * 111 * Math.cos((meanLat * Math.PI) / 180)) / 10;232      const dy = ((o.lat - meanLat) * 111) / 10;233      X.a[off + colIndex.get("trend_x")!] = dx;234      X.a[off + colIndex.get("trend_y")!] = dy;235      X.a[off + colIndex.get("trend_x2")!] = dx * dx;236      X.a[off + colIndex.get("trend_y2")!] = dy * dy;237      X.a[off + colIndex.get("trend_xy")!] = dx * dy;238    }239    for (const cl of catLevels) {240      const lvl = cl.mapped[i];241      if (lvl !== cl.base) {242        const j = colIndex.get(`${cl.v}=${lvl}`);243        if (j != null) X.a[off + j] = 1;244      }245    }246    if (timeMapped.length && timeMapped[i] !== timeBase) {247      const j = colIndex.get(`t=${timeMapped[i]}`);248      if (j != null) X.a[off + j] = 1;249    }250    if (spec.fe.geo !== "none" && geoMapped[i] !== geoBase) {251      const j = colIndex.get(`g=${geoMapped[i]}`);252      if (j != null) X.a[off + j] = 1;253    }254    y[i] = spec.depvar === "lnprice" ? Math.log(o.price) : o.price;255  }256257  // 4. grappes (zone géo ; sinon municipalité)258  const clusterIds = new Map<string, number>();259  const clusters = new Int32Array(n);260  for (let i = 0; i < n; i++) {261    const key = geoMapped[i];262    if (!clusterIds.has(key)) clusterIds.set(key, clusterIds.size);263    clusters[i] = clusterIds.get(key)!;264  }265266  return { X, y, cols, obs, dropped, clusters, nClusters: clusterIds.size, timeLevels, geoLevels, medians, contVars, center: { lat: meanLat, lng: meanLng } };267}268