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