SPB Git

spb/wp26_uqo

Public
6.0 KB · 119 lines python
1# Author: Simon-Pierre Boucher — simon-pierre.boucher@uqo.ca2"""CLEARLY LABELLED SYNTHETIC DATA. Generates a fake Quebec-like transaction table with the exact3column contract of the analytic layer (`analytic_schema.ANALYTIC_COLUMNS`) so that the whole pipeline4(features, comparables, baselines, agents, evaluation, tables) runs without proprietary data or API keys.56The data-generating process is a hedonic log-price model with municipality effects, a monthly market7index, spatial smoothing and i.i.d. noise. Nothing in it is calibrated to real Quebec prices beyond8rough orders of magnitude. Never report synthetic results as empirical findings.9"""10from __future__ import annotations1112import math13from datetime import date, timedelta1415import numpy as np16import polars as pl1718from ..config import SEED, SYNTHETIC1920REGIONS = {21    "Montréal": (45.52, -73.62, 1.00, "metro"),22    "Capitale-Nationale": (46.82, -71.23, 0.80, "metro"),23    "Outaouais": (45.45, -75.72, 0.78, "metro"),24    "Montérégie": (45.40, -73.30, 0.85, "suburban"),25    "Laurentides": (45.80, -74.00, 0.75, "suburban"),26    "Estrie": (45.40, -71.90, 0.62, "rural"),27    "Mauricie": (46.35, -72.55, 0.55, "rural"),28    "Bas-Saint-Laurent": (48.45, -68.52, 0.45, "rural"),29}30CLASSES = ["house", "condo", "plex"]313233def generate(n: int = 20_000, seed: int = SEED, start: date = date(2021, 1, 1), end: date = date(2026, 10, 7)) -> pl.DataFrame:34    rng = np.random.default_rng(seed)35    regs = list(REGIONS)36    reg_p = np.array([0.30, 0.14, 0.10, 0.18, 0.10, 0.07, 0.06, 0.05])37    region = rng.choice(regs, size=n, p=reg_p)38    n_mun = 639    mun_idx = rng.integers(0, n_mun, size=n)40    mun_eff = {(r, k): rng.normal(0, 0.15) for r in regs for k in range(n_mun)}41    cls = rng.choice(CLASSES, size=n, p=[0.62, 0.22, 0.16])42    days = (end - start).days43    t = rng.integers(0, days + 1, size=n)44    sale_date = np.array([start + timedelta(days=int(d)) for d in t])45    month_i = t / 30.4446    # market index: boom 2021-22, dip 2023, recovery 2024-26, + seasonal47    idx = 0.012 * np.minimum(month_i, 18) - 0.004 * np.clip(month_i - 18, 0, 12) + 0.004 * np.clip(month_i - 30, 0, None)48    idx += 0.015 * np.sin(2 * math.pi * (month_i % 12) / 12)49    area = np.where(cls == "condo", rng.lognormal(math.log(85), 0.3, n), np.where(cls == "plex", rng.lognormal(math.log(220), 0.3, n), rng.lognormal(math.log(140), 0.3, n)))50    land = np.where(cls == "condo", np.nan, rng.lognormal(math.log(600), 0.6, n))51    yb = np.clip(rng.normal(1980, 22, n).round(), 1850, 2026).astype(int)52    storeys = np.where(cls == "condo", 1.0, rng.choice([1.0, 1.5, 2.0], size=n, p=[0.45, 0.2, 0.35]))53    n_units = np.where(cls == "plex", rng.choice([2, 3, 4, 5], size=n, p=[0.5, 0.3, 0.15, 0.05]), 1)54    lat = np.array([REGIONS[r][0] for r in region]) + rng.normal(0, 0.08, n) + 0.03 * mun_idx55    lng = np.array([REGIONS[r][1] for r in region]) + rng.normal(0, 0.10, n) + 0.03 * mun_idx56    # spatial field: smooth function of coordinates57    spatial = 0.10 * np.sin(lat * 40) * np.cos(lng * 25)58    reg_level = np.array([math.log(REGIONS[r][2]) for r in region])59    mun = np.array([mun_eff[(r, k)] for r, k in zip(region, mun_idx)])60    cls_eff = np.where(cls == "condo", -0.05, np.where(cls == "plex", 0.25, 0.0))61    logp = (62        math.log(420_000) + reg_level + mun + cls_eff + idx + spatial63        + 0.72 * (np.log(area) - math.log(140)) + np.where(np.isnan(land), 0, 0.08 * (np.log(np.nan_to_num(land, nan=600)) - math.log(600)))64        - 0.0025 * (2026 - yb) + 0.05 * (storeys - 1) + 0.12 * np.log(n_units)65        + rng.normal(0, 0.14, n)66    )67    price = np.round(np.exp(logp), -3)68    # assessment roll: value ~ price at the roll reference date (2 yrs stale) with noise, ratio ~ 1.2-1.4 in 2024-2669    roll_total = np.round(price * np.exp(-idx + 0.02 - 0.3 * np.clip((month_i - 36) / 36, 0, 1)) * rng.lognormal(0, 0.12, n), -2)70    roll_land = np.round(roll_total * np.where(cls == "condo", 0.12, rng.uniform(0.2, 0.45, n)), -2)71    # repeat sales: ~8 % of parcels sell twice72    parcel = np.array([f"S{rng.integers(0, int(n * 0.92)):07d}" for _ in range(n)])73    mun_name = np.array([f"{r[:4].upper()}-{k:02d}" for r, k in zip(region, mun_idx)])74    df = pl.DataFrame(75        {76            "tx_id": [f"SYN{i:07d}" for i in range(n)],77            "parcel_id": parcel,78            "date": [d.isoformat() for d in sale_date],79            "price": price,80            "lat": np.round(lat, 5),81            "lng": np.round(lng, 5),82            "region_name": region,83            "mun_code": [f"{abs(hash(m)) % 90000 + 10000}" for m in mun_name],84            "mun_name": mun_name,85            "borough_name": [None] * n,86            "urban_class": [REGIONS[r][3] for r in region],87            "property_class": cls,88            "subtype": np.where(cls == "condo", "integrated", rng.choice(["detached", "semi", "row"], size=n, p=[0.75, 0.15, 0.10])),89            "floor_area_m2": np.round(area, 1),90            "land_area_m2": np.round(land, 1),91            "year_built": yb,92            "storeys": storeys,93            "n_units": n_units,94            "roll_land_value": roll_land,95            "roll_building_value": roll_total - roll_land,96            "roll_total_value": roll_total,97            "roll_reference_date": ["2022-07-01"] * n,98            "new_construction": (yb >= np.array([d.year for d in sale_date])),99            "exclude_reason": [None] * n,100        }101    ).with_columns(pl.col("date").str.to_date("%Y-%m-%d"))102    return df.sort("date")103104105def write_default(n: int = 20_000) -> pl.DataFrame:106    SYNTHETIC.mkdir(parents=True, exist_ok=True)107    df = generate(n)108    df.write_parquet(SYNTHETIC / "synthetic_transactions.parquet")109    (SYNTHETIC / "README.md").write_text(110        "# SYNTHETIC DATA — not real transactions\n\nGenerated by `agentic_valuation.data.synthetic.generate` "111        f"(seed {SEED}, n={n}). Column contract identical to the analytic layer. Use only for tests and smoke runs.\n"112    )113    return df114115116if __name__ == "__main__":117    d = write_default()118    print(d.shape, d["date"].min(), d["date"].max(), d["price"].median())119
Files · b780d9effbc0d688f848faf14e38219f6088143b
Menu

Theme