hausse effective prévue pour 2026 · 6,38 % en loyer contractuel · de 4,3 % (The Met) à 5,1 % (Westpark) selon l'immeuble
Le notebook complet, étape par étape. Cliquez sur une étape pour la déplier.
RésuméRéponse : 4,80 % de hausse effective à unité identique pour 2026, c'est-à-dire ce qu'Équinoxe encaissera de plus, après les mois gratuits,
Collection Équinoxe — hausse de loyer 2026
Résumé en une page
Réponse : 4,80 % de hausse effective à unité identique pour 2026, c'est-à-dire ce qu'Équinoxe encaissera de plus, après les mois gratuits, sur un même logement. En loyer contractuel (celui écrit au bail) : 6,38 %. Par immeuble : de 4,3 % (The Met, Ottawa) à 5,1 % (Westpark, Pointe-Claire).
Ce que nous avons trouvé, en cinq points
- La médiane annuelle trompe. Elle affiche +10,8 % en 2023, mais ce sont surtout trois immeubles haut de gamme qui arrivent dans le portefeuille. Le même logement comparé à lui-même n'a augmenté que d'environ 2,2 % cette année-là (sections 3 et 4).
- Les concessions mangent la hausse. En 2025, les loyers contractuels des mêmes logements montent de 7,6 %, mais l'encaissé seulement de 3,8 % : l'écart de concession des nouveaux baux passe de 3,2 % du loyer en 2023 à 9,2 % en 2025, porté surtout par PromoPay (section 5).
- Renouvellements et relocations ne bougent pas pareil, et l'Ontario n'obéit pas aux règles du Québec : chaque immeuble est prévu par type d'événement et par province, sans jamais appliquer les règles d'une province à l'autre (sections 6 et 7.3).
- La prévision est validée sur le passé : refaite comme au 31 décembre de chaque année de 2020 à 2025, son erreur moyenne (environ 1,1 point) est plus faible que celle de trois méthodes simples de référence. Sur 2023–2025, une simple moyenne historique fait un peu mieux; nous le montrons franchement (section 7.5).
- Chaque chiffre s'explique : pour chaque immeuble, nous montrons ce qui pousse le loyer vers le haut ou le bas, avec le contexte public autour (chantiers, transport, risques) (section 8b). XGBoost et Ridge ont été testés et ne font pas mieux de façon stable (section 9).
Lire en cinq minutes : ce résumé → le graphique de la section 4b (médiane vs même unité) → section 5c (concessions) → section 7.4 (le chiffre) → section 8b (pourquoi, immeuble par immeuble) → section 7.5 (validation).
La valeur exacte est calculée par estimate_2026() à la section 7.4, et la validation sur 2023, 2024 et 2025 est à la section 7.5.
Ce notebook part du starter.ipynb fourni et en garde la structure et les cellules de code : chargement, lecture des colonnes, l'approche évidente,
la comparaison d'une unité à elle-même, contractuel vs perçu, renouvellements vs relocations, puis « À vous de jouer » avec estimate_2026() et backtest() complétées.
Tout le code de la méthode est dans ce notebook : aucune importation de fichiers de l'équipe.
Pour l'exécuter : placer les quatre CSV d'origine à côté de ce notebook (comme pour le starter), avec les trois fichiers publics fournis (external_context.csv, external_postorigin_2026.csv, building_context.csv),
installer requirements.txt, puis Restart Kernel / Run All. Durée : moins d'une minute.
| Critère de la grille | Section |
|---|---|
| Définition de la hausse (10) | 0 et 7.1 |
| Analyse des données et effet de mix (15) | 1b, 1c, 2, 3 |
| Appariement à unité constante (15) | 4 |
| Concessions (10) | 5 |
| Renouvellements vs relocations, Ontario (10) | 6, 7.6 |
| Données externes (15) | 7.2, 7.6 |
| Prévision et backtest (15) | 7.3 à 7.7, 9 |
| Bonus : immeuble × chambres, scénarios, tableau de bord | 7.7, 8, 8b |
| Au-delà de la consigne (prototype facultatif) | 10 |
Confidentialité : aucune ligne du CRM n'est affichée. Les sorties sont des agrégats (comptes, moyennes, graphiques).
0. La question à laquelle nous répondonsDéfinition retenue : hausse effective à unité identique. Pour chaque unité (clé sPropCode + sUnitCode), on compare le nouveau bail au bail
0. La question à laquelle nous répondons
Définition retenue : hausse effective à unité identique. Pour chaque unité (clé sPropCode + sUnitCode), on compare le nouveau bail au bail
immédiatement précédent de la même unité, avec le loyer effectif (sRentEffective, après concessions), on annualise la variation, et on fait la
moyenne de ces variations pour toutes les paires dont le nouveau bail commence en 2026. Renouvellements et relocations sont inclus, chacun avec son poids attendu.
Dans tout le notebook, « perçu » ou « encaissé » désigne le champ sRentEffective fourni, c'est-à-dire le loyer après les concessions enregistrées dans les données (c'est ainsi que le starter le décrit). Ce n'est pas un relevé des encaissements réels : l'extrait ne contient ni paiements, ni arriérés.
Détails
Pourquoi ce choix plutôt que les autres définitions proposées dans le starter :
| Définition possible | Pourquoi nous ne la retenons pas comme chiffre principal |
|---|---|
| Médiane annuelle de tout le portefeuille | Mesure surtout le mix : l'arrivée de trois immeubles haut de gamme crée une « hausse » de 10,8 % en 2023 qui n'a pas eu lieu (section 3). |
Même unité, loyer contractuel (sRent) |
Ignore les mois gratuits. Depuis 2024 l'écart entre contractuel et perçu se creuse (section 5) : le contractuel surestime ce qu'Équinoxe encaisse. Présenté à côté (6,38 %). |
| Renouvellements seulement / relocations seulement | Utiles pour fixer les loyers, mais un budget de revenus a besoin des deux. Les deux sont séparés aux sections 6 et 7.3 et recombinés selon leur part attendue. |
| Revenu total ou NOI | Impossible avec cet extrait : pas d'occupation ni de vacance (section 2). |
Moyenne plutôt que médiane : la moyenne des paires se recompose exactement à partir des segments (immeuble × type d'événement), ce que la médiane ne permet pas. La médiane reste affichée comme diagnostic de robustesse.
1. Charger les donnéesLes quatre fichiers sources restent intacts : toute transformation se fait en mémoire, sur des copies. Les noms de tables et les versions des librairies sont affichés pour la repro…
1. Charger les données
Les quatre fichiers sources restent intacts : toute transformation se fait en mémoire, sur des copies. Les noms de tables et les versions des librairies sont affichés pour la reproductibilité.
import os import platform from dataclasses import dataclass from pathlib import Path import matplotlib import matplotlib.pyplot as plt import numpy as np import pandas as pd import sklearn import xgboost from IPython.display import display pd.set_option("display.width", 160) pd.set_option("display.max_columns", 40) TARGET_YEAR = 2026 # l'année à prévoir LAST_OBSERVED_YEAR = TARGET_YEAR - 1 # l'extrait s'arrête au 2025-12-31 REQUIRED_BACKTEST_YEARS = (2023, 2024, 2025) # exigés par la consigne PERCENT = 100.0 # fraction -> pourcentage DAYS_PER_YEAR = 365.25 # annualisation des intervalles entre baux # Les CSV d'origine se placent à côté du notebook, comme pour le starter. # Variable d'environnement facultative si on préfère les garder ailleurs. DATA_DIR = Path(os.environ.get("EQUINOXE_DATA_DIR", ".")) SOURCE_FILES = {"listings": "equinoxe_listings.csv", "leases": "equinoxe_lease_history.csv", "concessions": "equinoxe_concessions.csv", "asking": "equinoxe_asking_history.csv"} missing_files = [file_name for file_name in SOURCE_FILES.values() if not (DATA_DIR / file_name).is_file()] if missing_files: raise FileNotFoundError(f"Placer les CSV d'origine à côté du notebook (ou définir EQUINOXE_DATA_DIR). Manquants : {missing_files}") source_tables = {table: pd.read_csv(DATA_DIR / file_name) for table, file_name in SOURCE_FILES.items()} listings, leases = source_tables["listings"], source_tables["leases"] concessions, asking = source_tables["concessions"], source_tables["asking"] for name, table in source_tables.items(): print(f"{name:<12} {len(table):>6,} lignes {table.shape[1]:>2} colonnes") print({"python": platform.python_version(), "pandas": pd.__version__, "numpy": np.__version__, "matplotlib": matplotlib.__version__, "scikit-learn": sklearn.__version__, "xgboost": xgboost.__version__})
listings 1,061 lignes 29 colonnes
leases 4,302 lignes 35 colonnes
concessions 3,560 lignes 18 colonnes
asking 3,857 lignes 14 colonnes
{'python': '3.12.7', 'pandas': '2.2.3', 'numpy': '2.3.5', 'matplotlib': '3.11.2', 'scikit-learn': '1.9.1', 'xgboost': '2.1.4'}
Comme dans le starter, les dates sont converties une seule fois, dès le départ, et l'année de début de bail est ajoutée.
Au lieu d'afficher des lignes du CRM (leases.head()), nous affichons le profil des colonnes : type, valeurs manquantes et nombre de valeurs distinctes.
LEASE_DATE_COLUMNS = ["sLeaseFrom", "sLeaseTo", "sSignDate", "sAvailable"] for column in LEASE_DATE_COLUMNS: if column in leases.columns: leases[column] = pd.to_datetime(leases[column], errors="coerce") leases["year"] = leases.sLeaseFrom.dt.year asking["date"] = pd.to_datetime(asking.sMonth + "-01") lease_column_profile = pd.DataFrame({"type": leases.dtypes.astype(str), "valeurs_manquantes": leases.isna().sum(), "valeurs_distinctes": leases.nunique()}) lease_column_profile
| type | valeurs_manquantes | valeurs_distinctes | |
|---|---|---|---|
| hProperty | int64 | 0 | 9 |
| hUnit | int64 | 0 | 1061 |
| hBuilding | int64 | 0 | 6 |
| sPropCode | object | 0 | 9 |
| sUnitCode | object | 0 | 519 |
| sBuilding | object | 0 | 6 |
| sCity | object | 0 | 4 |
| sState | object | 0 | 2 |
| sAddr1 | object | 0 | 6 |
| sLatitude | float64 | 0 | 6 |
| sLongitude | float64 | 0 | 6 |
| sLeaseTerm | object | 0 | 4 |
| sPropType | object | 0 | 1 |
| sRent | int64 | 0 | 666 |
| sBeds | int64 | 0 | 4 |
| sBaths | float64 | 0 | 3 |
| sSqft | int64 | 0 | 199 |
| sLink | object | 0 | 1061 |
| sFurnishing | object | 0 | 1 |
| sAvailable | datetime64[ns] | 0 | 1302 |
| sSmoking | object | 0 | 1 |
| sCats | bool | 0 | 1 |
| sDogs | bool | 0 | 1 |
| sRentEffective | float64 | 0 | 3178 |
| sConcession | int64 | 0 | 2 |
| sSite | object | 0 | 6 |
| sUnitSubtype | object | 0 | 4 |
| sUnitType | object | 0 | 11 |
| sSignDate | datetime64[ns] | 0 | 2067 |
| sLeaseFrom | datetime64[ns] | 0 | 1302 |
| sLeaseTo | datetime64[ns] | 0 | 1341 |
| sTermMonths | float64 | 0 | 4 |
| sTermSeq | int64 | 0 | 8 |
| sRenewal | int64 | 0 | 2 |
| sFloor | int64 | 0 | 27 |
| year | int32 | 0 | 9 |
1b. Lire les colonnes : champs `h` et champs `s`Convention Yardi rappelée par le starter : les colonnes h (handle) sont des identifiants numériques qui servent aux jointures;
1b. Lire les colonnes : champs h et champs s
Convention Yardi rappelée par le starter : les colonnes h (handle) sont des identifiants numériques qui servent aux jointures;
les colonnes s (stored value) sont les valeurs lisibles qui servent à l'affichage et au regroupement. Joindre sur h, étiqueter avec s.
Le préfixe s ne veut pas dire « texte » : sRent et sSqft sont numériques.
Détails
Pour l'unité, nous utilisons la clé recommandée sPropCode + sUnitCode (jamais sSite + sUnitCode, voir section 4).
Les codes de propriété peuvent désigner des phases d'un même immeuble; les résultats par immeuble sont regroupés par sBuilding.
Les codes de frais (sChargeCode) sont tronqués à huit caractères par Yardi (Freeothe, Referenc). Nous les classons ainsi :
sChargeCode |
Ce que c'est | Classement |
|---|---|---|
PromoPay, freerent |
Rabais sur le loyer | Locatif |
freepark, freelock, freeappl |
Stationnement, casier, électroménagers offerts | Service (pas du loyer) |
Freeothe, Indem, Referenc |
Autre, indemnité, référencement | Autre, gardé à part |
1c. Ce que les colonnes représentent vraiment, et comment nous les renommonsLa consigne prévient que les descriptions ne correspondent pas toujours aux données. Nos vérifications :
1c. Ce que les colonnes représentent vraiment, et comment nous les renommons
La consigne prévient que les descriptions ne correspondent pas toujours aux données. Nos vérifications :
Détails
PromoPayest décrit comme un montant mensuel. La cellule suivante montre que c'est dans près de 9 cas sur 10 une seule écriture par bail (sMonths = 1), d'un montant médian d'environ 70 % d'un mois de loyer. C'est donc un crédit ponctuel à étaler sur la durée du bail, pas un rabais de chaque mois. Pris au pied de la lettre, il ferait croire que les résidents habitent presque gratuitement.sAmountest négatif pour un avantage. Le crédit total d'une écriture vaut−sAmount × sMonths.sRentEffectiveest déjà le loyer après concessions, amorties sur le bail. Nous l'utilisons tel quel et ne retranchons pas une seconde fois les concessions. La section 5 vérifie qu'il est toujours inférieur ou égal àsRent, et reconstruit le loyer à partir du registre des concessions.sTermSeqnumérote les baux successifs d'une même unité : c'est lui qui définit le « bail précédent ».sRenewalvaut 1 pour un locataire en place qui renouvelle et 0 pour une relocation.- Loyer affiché (
asking) : un prix annoncé, pas un prix signé.
Les colonnes utilisées par le modèle sont renommées une seule fois, avec le dictionnaire COLUMN_RENAMES ci-dessous.
Les fichiers sources ne sont jamais modifiés.
UNIT_KEY = ["sPropCode", "sUnitCode"] # clé d'unité sûre (jamais sSite + sUnitCode) PROMO_CODE = "PromoPay" COLUMN_RENAMES = {"sPropCode": "property_code", "sUnitCode": "unit_code", "sState": "province", "sBeds": "bedrooms", "sSignDate": "sign_date", "sLeaseFrom": "lease_start", "sLeaseTo": "lease_end", "sTermSeq": "term_seq", "sRent": "face_rent", "sRentEffective": "effective_rent"} def normalise_unit_key(table): # Copie avec la clé d'unité nettoyée : espaces retirés, code de propriété en minuscules. keyed = table.copy() keyed["sPropCode"] = keyed["sPropCode"].astype(str).str.strip().str.lower() keyed["sUnitCode"] = keyed["sUnitCode"].astype(str).str.strip() return keyed promo_entries = normalise_unit_key(concessions[concessions.sChargeCode.eq(PROMO_CODE)]) unit_listed_rent = normalise_unit_key(listings)[UNIT_KEY + ["sRent"]] promo_entries = promo_entries.merge(unit_listed_rent, on=UNIT_KEY, how="left", validate="many_to_one") promo_check = pd.Series({ "écritures PromoPay": len(promo_entries), "part avec sMonths = 1": (promo_entries.sMonths == 1).mean(), "part avec sAmount négatif": (promo_entries.sAmount < 0).mean(), "crédit médian / loyer de l'unité": (-promo_entries.sAmount / promo_entries.sRent).median(), }) display(promo_check.round(3)) COLUMN_MEANINGS = { "sPropCode": "code de propriété (une phase d'immeuble); moitié de la clé d'unité", "sUnitCode": "numéro d'unité dans la propriété; autre moitié de la clé d'unité", "sState": "province : Quebec (TAL) ou Ontario (ligne directrice)", "sBeds": "nombre de chambres", "sSignDate": "date de signature : sert à savoir ce qui était connu à une date donnée", "sLeaseFrom": "début du bail : date de l'événement mesuré", "sLeaseTo": "fin contractuelle du bail : sert à anticiper les baux qui expirent en 2026", "sTermSeq": "rang du bail dans l'historique de l'unité : définit le bail précédent", "sRent": "loyer contractuel mensuel, celui écrit au bail", "sRentEffective": "loyer effectif mensuel, après concessions amorties sur le bail : ce qui est encaissé", } column_dictionary = pd.DataFrame({"nouveau nom": COLUMN_RENAMES, "ce que la colonne représente": COLUMN_MEANINGS}) column_dictionary.index.name = "colonne d'origine" column_dictionary
écritures PromoPay 1494.000 part avec sMonths = 1 0.882 part avec sAmount négatif 1.000 crédit médian / loyer de l'unité 0.711 dtype: float64
| nouveau nom | ce que la colonne représente | |
|---|---|---|
| colonne d'origine | ||
| sPropCode | property_code | code de propriété (une phase d'immeuble); moit... |
| sUnitCode | unit_code | numéro d'unité dans la propriété; autre moitié... |
| sState | province | province : Quebec (TAL) ou Ontario (ligne dire... |
| sBeds | bedrooms | nombre de chambres |
| sSignDate | sign_date | date de signature : sert à savoir ce qui était... |
| sLeaseFrom | lease_start | début du bail : date de l'événement mesuré |
| sLeaseTo | lease_end | fin contractuelle du bail : sert à anticiper l... |
| sTermSeq | term_seq | rang du bail dans l'historique de l'unité : dé... |
| sRent | face_rent | loyer contractuel mensuel, celui écrit au bail |
| sRentEffective | effective_rent | loyer effectif mensuel, après concessions amor... |
Contrat de données. Avant toute mesure, le modèle vérifie que les baux respectent les hypothèses dont il dépend, et s'arrête sinon : pas de clé vide, pas de séquence dupliquée ou non entière, dates cohérentes (signature ≤ début ≤ fin), loyers positifs, loyer effectif ≤ loyer contractuel, province connue et stable par unité. Rien n'est imputé. La validation travaille sur une copie, avec la clé nettoyée.
REQUIRED_LEASE_COLUMNS = {"sPropCode", "sUnitCode", "sState", "sBeds", "sRent", "sRentEffective", "sSignDate", "sLeaseFrom", "sLeaseTo", "sTermSeq", "sRenewal"} KNOWN_PROVINCES = ["Quebec", "Ontario"] LEASE_NUMERIC_COLUMNS = ["sRent", "sRentEffective", "sTermSeq", "sRenewal", "sBeds"] class DataContractError(ValueError): # Une entrée ne respecte pas une hypothèse dont le modèle dépend. pass def validate_leases(lease_table): # Copie validée des baux : dates et nombres convertis, clé nettoyée, règles vérifiées. if not isinstance(lease_table, pd.DataFrame) or lease_table.empty: raise DataContractError("leases doit être un DataFrame non vide de l'historique des baux") missing_columns = REQUIRED_LEASE_COLUMNS - set(lease_table.columns) if missing_columns: raise DataContractError(f"colonnes manquantes : {sorted(missing_columns)}") checked = lease_table.copy() for column in ["sSignDate", "sLeaseFrom", "sLeaseTo"]: checked[column] = pd.to_datetime(checked[column], errors="coerce") for column in LEASE_NUMERIC_COLUMNS: checked[column] = pd.to_numeric(checked[column], errors="coerce") if checked[list(REQUIRED_LEASE_COLUMNS)].isna().any().any(): raise DataContractError("valeurs manquantes ou illisibles dans les colonnes requises") checked = normalise_unit_key(checked) if checked[UNIT_KEY].eq("").any().any() or not checked["sState"].isin(KNOWN_PROVINCES).all(): raise DataContractError("clé d'unité vide ou province inconnue") if not np.isfinite(checked[LEASE_NUMERIC_COLUMNS].to_numpy()).all(): raise DataContractError("valeur numérique non finie") if not checked["sRenewal"].isin([0, 1]).all(): raise DataContractError("sRenewal doit valoir 0 ou 1") if ((checked["sTermSeq"] < 1) | (checked["sTermSeq"] % 1 != 0)).any(): raise DataContractError("sTermSeq doit être un entier positif") if checked.duplicated(UNIT_KEY + ["sTermSeq"]).any(): raise DataContractError("séquence de bail dupliquée pour une même unité") if (checked[["sRent", "sRentEffective"]] <= 0).any().any(): raise DataContractError("les loyers doivent être positifs") if (checked["sRentEffective"] > checked["sRent"]).any(): raise DataContractError("loyer effectif supérieur au loyer contractuel") if ((checked["sLeaseTo"] < checked["sLeaseFrom"]) | (checked["sSignDate"] > checked["sLeaseFrom"])).any(): raise DataContractError("dates de bail ou de signature incohérentes") if checked.groupby(UNIT_KEY)["sState"].nunique().gt(1).any(): raise DataContractError("une unité ne peut pas changer de province") return checked validated_leases = validate_leases(leases) print(f"Contrat de données respecté : {len(validated_leases):,} baux, {validated_leases.groupby(UNIT_KEY).ngroups:,} unités.")
Contrat de données respecté : 4,302 baux, 1,061 unités.
2. Ce que nous avons sous les yeuxSix immeubles : Daniel-Johnson, Lévesque et Saint-Elzéar (Laval), Le Carlyle (Mont-Royal), Westpark (Pointe-Claire) et The Met (Ottawa).
2. Ce que nous avons sous les yeux
Six immeubles : Daniel-Johnson, Lévesque et Saint-Elzéar (Laval), Le Carlyle (Mont-Royal), Westpark (Pointe-Claire) et The Met (Ottawa). Tout est arrêté au 31 décembre 2025; 2026 est ce que nous prévoyons. Cinq immeubles relèvent du Québec (TAL) et The Met de l'Ontario : les deux régimes sont traités séparément partout.
Détails
C'est un extrait : une part fixe des unités de chaque immeuble et de chaque typologie. La répartition est représentative, pas les volumes. Nous ne calculons donc ni occupation, ni inoccupation, ni absorption, ni rôle de loyers total : ce serait un artefact de l'extrait. Ces données mesurent la variation des loyers, et c'est elle qu'on nous demande.
print(listings.groupby(["sState", "sCity", "sBuilding"]).size().to_string()) print() print(listings.groupby(["sBeds", "sBaths"]) .agg(units=("sRent", "size"), median_rent=("sRent", "median"), median_sqft=("sSqft", "median")) .to_string())
sState sCity sBuilding
Ontario Ottawa The Met 124
Quebec Laval Daniel-Johnson 134
Levesque 84
Saint-Elzear 361
Mont-Royal Le Carlyle 192
Pointe-Claire Westpark 166
units median_rent median_sqft
sBeds sBaths
0 1.0 49 2565.0 550.0
1 1.0 350 1380.0 600.0
2 1.0 358 2440.0 1150.0
2.0 281 2605.0 1265.0
3 1.0 2 2687.5 1597.5
2.0 20 3237.5 1545.0
3.0 1 4115.0 1905.0
3. L'approche évidente, et pourquoi elle trompePremier réflexe : le loyer médian de tous les baux, année par année.
3. L'approche évidente, et pourquoi elle trompe
Premier réflexe : le loyer médian de tous les baux, année par année.
FIRST_YEAR_SHOWN = 2019 naive = (leases[leases.year.between(FIRST_YEAR_SHOWN, LAST_OBSERVED_YEAR)] .groupby("year") .agg(leases=("sRent", "size"), median_rent=("sRent", "median"))) naive["yoy_pct"] = (naive.median_rent.pct_change() * PERCENT).round(2) naive
| leases | median_rent | yoy_pct | |
|---|---|---|---|
| year | |||
| 2019 | 328 | 1730.0 | NaN |
| 2020 | 373 | 1775.0 | 2.60 |
| 2021 | 361 | 1850.0 | 4.23 |
| 2022 | 422 | 1912.5 | 3.38 |
| 2023 | 693 | 2120.0 | 10.85 |
| 2024 | 902 | 2215.0 | 4.48 |
| 2025 | 958 | 2305.0 | 4.06 |
Selon ce tableau, les loyers auraient augmenté de 10,8 % en 2023. Ce n'est pas le cas. Une médiane annuelle compare des ensembles d'unités différents : elle bouge dès que la composition du portefeuille bouge. Les deux cellules suivantes regardent sous la médiane : taille, typologie et immeubles loués chaque année.
TWO_BEDROOMS = 2 window = leases[leases.year.between(FIRST_YEAR_SHOWN, LAST_OBSERVED_YEAR)] mix = window.groupby("year").agg( leases=("sRent", "size"), median_sqft=("sSqft", "median"), pct_2bed_plus=("sBeds", lambda beds: (beds >= TWO_BEDROOMS).mean() * PERCENT), buildings=("sBuilding", "nunique")) mix["rent_per_sqft"] = (window.assign(psf=window.sRent / window.sSqft) .groupby("year").psf.median()) mix.round(2)
| leases | median_sqft | pct_2bed_plus | buildings | rent_per_sqft | |
|---|---|---|---|---|---|
| year | |||||
| 2019 | 328 | 1177.5 | 78.66 | 3 | 1.48 |
| 2020 | 373 | 1180.0 | 77.75 | 3 | 1.53 |
| 2021 | 361 | 1175.0 | 78.39 | 3 | 1.60 |
| 2022 | 422 | 1160.0 | 72.51 | 3 | 1.71 |
| 2023 | 693 | 1095.0 | 62.63 | 5 | 2.04 |
| 2024 | 902 | 1045.0 | 62.20 | 6 | 2.20 |
| 2025 | 958 | 1025.0 | 60.23 | 6 | 2.33 |
SNAPSHOT_YEAR = 2024 # première année où les trois nouveaux immeubles louent tous # À partir de quand chaque immeuble a-t-il commencé à générer des baux? print(window.groupby("sBuilding").year.min().sort_values().to_string()) print() # Et à quel niveau de loyer? (baux de SNAPSHOT_YEAR) print(window[window.year == SNAPSHOT_YEAR].groupby("sBuilding") .agg(leases=("sRent", "size"), median_rent=("sRent", "median")) .sort_values("median_rent").to_string())
sBuilding
Daniel-Johnson 2019
Levesque 2019
Saint-Elzear 2019
Le Carlyle 2023
The Met 2023
Westpark 2024
leases median_rent
sBuilding
Levesque 84 1672.5
Daniel-Johnson 136 1870.0
Saint-Elzear 215 2145.0
Westpark 144 2202.5
Le Carlyle 195 2350.0
The Met 128 2752.5
Ce qui s'est passé : Le Carlyle, The Met et Westpark ont été mis en service en 2023 et 2024. Ce sont des immeubles haut de gamme, aux unités plus petites et plus chères au pied carré. Les unités louées sont devenues plus petites et la part des 2 chambres et plus a baissé, alors que la médiane a monté : l'inverse de ce que la taille laisserait prévoir. La hausse de la médiane vient surtout de quels immeubles louent, presque pas d'un même locataire qui paie plus.
Le graphique ci-dessous montre ce changement de composition : la part des baux par immeuble, année par année. La comparaison avec la croissance à unité identique suit à la section 4, une fois les paires construites.
building_mix = pd.crosstab(validated_leases.sLeaseFrom.dt.year, validated_leases.sBuilding, normalize="index") ax = building_mix.plot.area(stacked=True, figsize=(9, 4)) ax.set(title="Composition des baux par immeuble", ylabel="Part des baux de l'année", xlabel="Année de début du bail") ax.legend(loc="center left", bbox_to_anchor=(1, 0.5)) plt.tight_layout() plt.show()
4. La méthode qui fonctionne : comparer une unité à elle-mêmeChaque logement est comparé à son bail précédent, avec des règles écrites une seule fois et des exclusions comptées. Résultat : 3 179 paires comparables.
4. La méthode qui fonctionne : comparer une unité à elle-même
On associe les baux consécutifs de chaque unité et on annualise la variation : même appartement, même immeuble, ce qui reste, c'est le prix.
La clé : sPropCode + sUnitCode. Pas sSite + sUnitCode : plusieurs sites regroupent plusieurs codes de propriété aux numéros d'unité qui se chevauchent.
La cellule suivante le démontre, sans afficher de ligne du CRM.
COLLISION_EXAMPLE_SITE, COLLISION_EXAMPLE_UNIT = "saint-elzear", "0203" # exemple donné par le starter site_key_collisions = listings[listings.duplicated(["sSite", "sUnitCode"], keep=False)] print(f"{len(site_key_collisions):,} unités sur {len(listings):,} entrent en collision sur sSite + sUnitCode") example = listings[(listings.sSite == COLLISION_EXAMPLE_SITE) & (listings.sUnitCode == COLLISION_EXAMPLE_UNIT)] print(f"Exemple {COLLISION_EXAMPLE_SITE} / {COLLISION_EXAMPLE_UNIT} : {example.hUnit.nunique()} appartements distincts (hUnit), " f"{example.sPropCode.nunique()} codes de propriété différents") print(f"Doublons sur sPropCode + sUnitCode dans les listings : {listings.duplicated(UNIT_KEY).sum()}")
248 unités sur 1,061 entrent en collision sur sSite + sUnitCode Exemple saint-elzear / 0203 : 3 appartements distincts (hUnit), 3 codes de propriété différents Doublons sur sPropCode + sUnitCode dans les listings : 0
Le starter propose une première version de l'appariement (same_unit_growth, conservée ci-dessous) : bail précédent de la même unité, écart entre débuts
de 0,5 à 2,5 ans, croissance annualisée, médiane par année. Elle confirme déjà que la médiane naïve mesure surtout la composition.
GAP_MIN_YEARS, GAP_MAX_YEARS = 0.5, 2.5 # plus court : souvent le même bail enregistré deux fois; plus long : trop ancien FIRST_PAIR_YEAR_SHOWN = 2021 def same_unit_growth(df, price_col="sRent", date_col="sLeaseFrom", min_gap=GAP_MIN_YEARS, max_gap=GAP_MAX_YEARS): # Version du starter : variation annualisée du loyer, chaque unité comparée à son propre bail précédent. d = df.sort_values(UNIT_KEY + [date_col]).copy() d["prev_price"] = d.groupby(UNIT_KEY)[price_col].shift(1) d["prev_date"] = d.groupby(UNIT_KEY)[date_col].shift(1) d = d.dropna(subset=["prev_price", "prev_date"]) d["gap_years"] = (d[date_col] - d.prev_date).dt.days / DAYS_PER_YEAR d = d[d.gap_years.between(min_gap, max_gap, inclusive="neither")] d = d[(d.prev_price > 0) & (d[price_col] > 0)] d["growth_pct"] = ((d[price_col] / d.prev_price) ** (1 / d.gap_years) - 1) * PERCENT return d pairs = same_unit_growth(leases) print(f"{len(pairs):,} paires utilisables sur {pairs.groupby(UNIT_KEY).ngroups:,} unités") honest = (pairs[pairs.year.between(FIRST_PAIR_YEAR_SHOWN, LAST_OBSERVED_YEAR)] .groupby("year") .growth_pct.agg(pairs="size", median="median").round(2)) honest
3,179 paires utilisables sur 890 unités
| pairs | median | |
|---|---|---|
| year | ||
| 2021 | 355 | 4.46 |
| 2022 | 352 | 5.40 |
| 2023 | 416 | 2.29 |
| 2024 | 698 | 3.39 |
| 2025 | 780 | 7.04 |
fig, ax = plt.subplots(figsize=(9, 4.5)) years_shown = range(FIRST_PAIR_YEAR_SHOWN, TARGET_YEAR) ax.plot(years_shown, naive.loc[years_shown, "yoy_pct"], "o--", label="naïf : médiane de tous les baux", lw=2) ax.plot(years_shown, honest.loc[years_shown, "median"], "o-", label="même unité comparée à elle-même", lw=2) ax.axhline(0, color="grey", lw=0.8) ax.set_ylabel("croissance annuelle des loyers (%)") ax.set_title("La méthode naïve mesure surtout la composition") ax.set_xticks(list(years_shown)) ax.legend() ax.grid(alpha=0.3) plt.tight_layout() plt.show()
4b. Notre appariement : règles fixées une fois, exclusions visibles
En bref : chaque logement est comparé à son bail précédent, avec des règles écrites une seule fois et des exclusions comptées. Résultat : 3 179 paires comparables.
Détails
Nous resserrons la version du starter sur trois points :
- Bail immédiatement précédent : la paire n'est retenue que si les séquences sont consécutives (
sTermSeqaugmente de 1). Un bail manquant n'est pas « sauté ». - Contractuel et effectif mesurés ensemble sur les mêmes paires, avec l'écart de concession
écart = 1 − sRentEffective / sRentdu bail précédent et du nouveau. - Toutes les règles et tous les paramètres du modèle sont fixés ici, une seule fois, dans
ForecastSpec. Aucun n'a été optimisé sur les années de backtest.
Annualisation : t = jours entre les deux débuts / 365,25, croissance = (loyer_nouveau / loyer_précédent) ** (1/t) − 1, paire retenue si 0,5 < t < 2,5.
Identité exacte au niveau de l'unité : (1 + croissance effective) = (1 + croissance contractuelle) × ((1 − écart_nouveau) / (1 − écart_précédent)) ** (1/t).
La croissance effective est la croissance contractuelle corrigée de la variation de l'écart de concession. La cellule la vérifie sur toutes les paires.
@dataclass(frozen=True) class ForecastSpec: # Paramètres figés avant les backtests de cette révision. origin_month: int = 12 # origine de chaque prévision : 31 décembre de l'année précédente origin_day: int = 31 gap_min_years: float = GAP_MIN_YEARS # intervalle admissible entre deux débuts de bail gap_max_years: float = GAP_MAX_YEARS growth_clip: tuple = (-0.30, 0.60) # bornes de robustesse, moyennes d'entraînement seulement gap_clip: tuple = (0.0, 0.75) # bornes de l'écart de concession prévu shrink_k_cell: float = 10.0 # rétrécissement immeuble × événement vers province × événement shrink_k_mix: float = 20.0 # rétrécissement de la part de renouvellements vers la province trailing_years_mix: int = 3 # fenêtre de la part de renouvellements components: tuple = ("DIRECT", "BRIDGE", "ANCHOR") component_weights: tuple = (1 / 3, 1 / 3, 1 / 3) face_persistence_weight: float = 0.5 # contractuel : moitié dernière année, moitié historique evaluation_years: tuple = (2020, 2021, 2022, 2023, 2024, 2025) SPEC = ForecastSpec() PAIR_COLUMNS = ["property_code", "unit_code", "province", "bedrooms", "term_seq", "prior_seq", "sign_date", "lease_start", "lease_end", "prior_start", "prior_sign", "event_year", "event_type", "prior_face_rent", "prior_effective_rent", "face_rent", "effective_rent", "interval_years", "face_growth", "effective_growth", "prior_gap", "new_gap", "eligible"] def build_pairs(lease_table): # Paires de baux consécutifs d'une même unité, croissances annualisées et écarts de concession. ordered = validate_leases(lease_table).sort_values(UNIT_KEY + ["sTermSeq", "sLeaseFrom"]) by_unit = ordered.groupby(UNIT_KEY, sort=False) paired = ordered.assign(prior_start=by_unit["sLeaseFrom"].shift(1), prior_sign=by_unit["sSignDate"].shift(1), prior_face_rent=by_unit["sRent"].shift(1), prior_effective_rent=by_unit["sRentEffective"].shift(1), prior_seq=by_unit["sTermSeq"].shift(1)) paired = paired[paired["prior_start"].notna()].copy() paired["interval_years"] = (paired["sLeaseFrom"] - paired["prior_start"]).dt.days / DAYS_PER_YEAR paired["face_growth"] = (paired["sRent"] / paired["prior_face_rent"]) ** (1.0 / paired["interval_years"]) - 1.0 paired["effective_growth"] = (paired["sRentEffective"] / paired["prior_effective_rent"]) ** (1.0 / paired["interval_years"]) - 1.0 paired["prior_gap"] = 1.0 - paired["prior_effective_rent"] / paired["prior_face_rent"] paired["new_gap"] = 1.0 - paired["sRentEffective"] / paired["sRent"] rents_positive = (paired[["sRent", "prior_face_rent", "sRentEffective", "prior_effective_rent"]] > 0).all(axis=1) consecutive = paired["sTermSeq"].sub(paired["prior_seq"]).eq(1) paired["eligible"] = (paired["interval_years"].gt(SPEC.gap_min_years) & paired["interval_years"].lt(SPEC.gap_max_years) & rents_positive & consecutive) paired["event_year"] = paired["sLeaseFrom"].dt.year paired["event_type"] = np.where(paired["sRenewal"].eq(1), "renewal", "turnover") return paired.rename(columns=COLUMN_RENAMES)[PAIR_COLUMNS].reset_index(drop=True) def exact_bridge(face_growth, prior_gap, new_gap, interval_years=1.0): # Identité exacte : (1 + g_contractuel) × ((1 − écart_nouveau) / (1 − écart_précédent)) ** (1/t) − 1. inputs = (interval_years, prior_gap, new_gap, face_growth) if (any(not np.isfinite(np.asarray(value)).all() for value in inputs) or np.any(np.asarray(interval_years) <= 0) or np.any(np.asarray(prior_gap) >= 1) or np.any(np.asarray(prior_gap) < 0) or np.any(np.asarray(new_gap) < 0) or np.any(np.asarray(new_gap) >= 1) or np.any(np.asarray(face_growth) <= -1)): raise DataContractError("le pont exige des durées positives et des ratios de loyer valides") return (1.0 + face_growth) * ((1.0 - new_gap) / (1.0 - prior_gap)) ** (1.0 / interval_years) - 1.0 unit_pairs = build_pairs(leases) eligible_pairs = unit_pairs[unit_pairs["eligible"]] exclusion_reasons = pd.Series({ "intervalle ≤ 0,5 an": int((unit_pairs.interval_years <= SPEC.gap_min_years).sum()), "intervalle ≥ 2,5 ans": int((unit_pairs.interval_years >= SPEC.gap_max_years).sum()), "séquences non consécutives": int(unit_pairs.term_seq.sub(unit_pairs.prior_seq).ne(1).sum()), }, name="paires (un cas peut cumuler deux raisons)") display(pd.DataFrame([{"paires_candidates": len(unit_pairs), "admissibles": len(eligible_pairs), "exclues": len(unit_pairs) - len(eligible_pairs)}])) display(exclusion_reasons) assert eligible_pairs["term_seq"].sub(eligible_pairs["prior_seq"]).eq(1).all() np.testing.assert_allclose(exact_bridge(eligible_pairs.face_growth, eligible_pairs.prior_gap, eligible_pairs.new_gap, eligible_pairs.interval_years), eligible_pairs.effective_growth, atol=1e-12) print("Identité contractuel / effectif vérifiée sur toutes les paires admissibles.")
| paires_candidates | admissibles | exclues | |
|---|---|---|---|
| 0 | 3241 | 3179 | 62 |
intervalle ≤ 0,5 an 62 intervalle ≥ 2,5 ans 0 séquences non consécutives 0 Name: paires (un cas peut cumuler deux raisons), dtype: int64
Identité contractuel / effectif vérifiée sur toutes les paires admissibles.
Médiane naïve vs même unité, par année. Le tableau donne, pour chaque année de début du nouveau bail : le nombre de paires, la croissance moyenne contractuelle et effective, la médiane effective (robustesse), la part de renouvellements, et la variation de la médiane naïve. L'écart entre la médiane naïve et la moyenne appariée illustre l'effet de mix. Ce n'est pas une décomposition causale exacte : statistiques et échantillons diffèrent.
annual_growth = eligible_pairs.groupby("event_year").agg( pairs=("effective_growth", "size"), face_pct=("face_growth", lambda growth: PERCENT * growth.mean()), effective_pct=("effective_growth", lambda growth: PERCENT * growth.mean()), median_effective_pct=("effective_growth", lambda growth: PERCENT * growth.median()), renewal_share=("event_type", lambda event: event.eq("renewal").mean())).reset_index() naive_median_change = validated_leases.groupby(validated_leases.sLeaseFrom.dt.year).sRent.median().pct_change() * PERCENT annual_growth["naive_median_face_change_pct"] = annual_growth.event_year.map(naive_median_change) display(annual_growth.round(3)) ax = annual_growth.set_index("event_year")[["naive_median_face_change_pct", "face_pct", "effective_pct"]].rename(columns={ "naive_median_face_change_pct": "Médiane naïve (tous les baux)", "face_pct": "Même unité, contractuel", "effective_pct": "Même unité, effectif"}).plot(marker="o", figsize=(9, 4)) ax.set(title="Médiane naïve vs croissance à unité identique", ylabel="% annualisé / variation annuelle", xlabel="Année de début du nouveau bail") ax.axhline(0, color="grey", lw=0.8) plt.tight_layout() plt.show()
| event_year | pairs | face_pct | effective_pct | median_effective_pct | renewal_share | naive_median_face_change_pct | |
|---|---|---|---|---|---|---|---|
| 0 | 2018 | 88 | 2.126 | 2.041 | 1.976 | 0.534 | -11.295 |
| 1 | 2019 | 159 | 2.000 | 1.593 | 1.629 | 0.579 | 7.453 |
| 2 | 2020 | 331 | 3.034 | 2.769 | 3.019 | 0.604 | 2.601 |
| 3 | 2021 | 355 | 4.384 | 3.753 | 3.543 | 0.620 | 4.225 |
| 4 | 2022 | 352 | 5.273 | 4.716 | 4.586 | 0.642 | 3.378 |
| 5 | 2023 | 416 | 2.089 | 2.178 | 2.019 | 0.575 | 10.850 |
| 6 | 2024 | 698 | 3.728 | 1.951 | 1.772 | 0.605 | 4.481 |
| 7 | 2025 | 780 | 7.559 | 3.833 | 3.575 | 0.606 | 4.063 |
5. Loyer contractuel n'est pas loyer perçuLe registre des concessions confirme la direction de l'écart sans le reconstruire exactement; nous utilisons donc le loyer effectif fourni, tel quel.
5. Loyer contractuel n'est pas loyer perçu
sRent est ce que le bail indique; sRentEffective est le loyer après mois gratuits et autres avantages, tel que fourni dans les données (le starter le décrit comme ce qu'Équinoxe perçoit réellement).
Les concessions sont devenues la norme. Le premier tableau (starter) compare les niveaux; le second compare les croissances sur les paires du starter.
conc = (leases[leases.year.between(FIRST_PAIR_YEAR_SHOWN, LAST_OBSERVED_YEAR)] .groupby("year") .agg(leases=("sRent", "size"), pct_with_concession=("sConcession", lambda flag: flag.mean() * PERCENT), median_contracted=("sRent", "median"), median_effective=("sRentEffective", "median"))) conc["gap_pct"] = ((conc.median_effective / conc.median_contracted - 1) * PERCENT) conc.round(2)
| leases | pct_with_concession | median_contracted | median_effective | gap_pct | |
|---|---|---|---|---|---|
| year | |||||
| 2021 | 361 | 54.02 | 1850.0 | 1785.00 | -3.51 |
| 2022 | 422 | 57.35 | 1912.5 | 1835.00 | -4.05 |
| 2023 | 693 | 54.26 | 2120.0 | 2025.40 | -4.46 |
| 2024 | 902 | 73.06 | 2215.0 | 2084.46 | -5.89 |
| 2025 | 958 | 85.39 | 2305.0 | 2105.59 | -8.65 |
# La concession change-t-elle l'évolution, ou seulement le niveau? eff = same_unit_growth(leases, price_col="sRentEffective") compare = pd.DataFrame({ "contracted": honest["median"], "effective": eff[eff.year.between(FIRST_PAIR_YEAR_SHOWN, LAST_OBSERVED_YEAR)].groupby("year").growth_pct.median(), }).round(2) compare
| contracted | effective | |
|---|---|---|
| year | ||
| 2021 | 4.46 | 3.54 |
| 2022 | 5.40 | 4.59 |
| 2023 | 2.29 | 2.02 |
| 2024 | 3.39 | 1.77 |
| 2025 | 7.04 | 3.58 |
5b. Vérifier sRentEffective avec le registre des concessions
En bref : le registre des concessions confirme la direction de l'écart sans le reconstruire exactement; nous utilisons donc le loyer effectif fourni, tel quel.
Détails
Le loyer effectif fourni reste notre mesure. Pour le vérifier, chaque écriture de concession est rattachée au bail de la même unité dont la période contient
sa date de début. Une écriture qui correspondrait à plusieurs baux arrête le calcul. Le crédit d'une écriture vaut −sAmount × sMonths; seuls les codes locatifs
(PromoPay, freerent) sont retranchés du loyer, étalés sur la durée du bail (sTermMonths). Les services (stationnement, casier, électroménagers) ne sont pas du loyer.
Le premier tableau montre ce que le registre explique et ce qu'il n'explique pas : écritures sans bail englobant, et baux marqués sConcession = 1 sans écriture.
Le second compare, sur un échantillon commun où les deux baux de la paire ont une reconstruction positive et un drapeau cohérent, trois définitions du loyer :
contractuel, contractuel moins les seuls crédits locatifs du registre, et effectif fourni. Ces écritures n'ont pas de date d'enregistrement fiable :
cette reconstruction est un diagnostic et n'entre pas dans les backtests.
RENT_CREDIT_CODES = ("promopay", "freerent") AMENITY_CODES = ("freepark", "freelock", "freeappl") def match_concessions_to_leases(lease_table, concession_table): # Rattache chaque écriture au bail de la même unité qui contient sa date de début. lease_rows = validate_leases(lease_table).reset_index(drop=True).reset_index(names="lease_id") ledger = normalise_unit_key(concession_table).reset_index(names="concession_id") for column in ("sDateFrom", "sDateTo"): ledger[column] = pd.to_datetime(ledger[column]) matched = ledger.merge(lease_rows[["lease_id", *UNIT_KEY, "sLeaseFrom", "sLeaseTo"]], on=UNIT_KEY, how="left") matched = matched[matched.sDateFrom.between(matched.sLeaseFrom, matched.sLeaseTo)].copy() if matched.groupby("concession_id").lease_id.nunique().gt(1).any(): raise DataContractError("une écriture de concession correspond à plusieurs baux") charge_code = matched.sChargeCode.str.lower() matched["code"] = charge_code matched["category"] = np.select([charge_code.isin(RENT_CREDIT_CODES), charge_code.isin(AMENITY_CODES)], ["rent", "amenity"], default="other") matched["total_credit"] = -matched.sAmount * matched.sMonths if (matched.total_credit < 0).any(): raise DataContractError("une écriture de concession est une charge positive") return lease_rows, matched lease_rows, matched_concessions = match_concessions_to_leases(leases, concessions) rent_credit_by_lease = matched_concessions[matched_concessions.category.eq("rent")].groupby("lease_id").total_credit.sum() entries_per_lease = matched_concessions.groupby("lease_id").size() lease_rows["matched_entries"] = lease_rows.lease_id.map(entries_per_lease).fillna(0) lease_rows["rent_only"] = lease_rows.sRent - lease_rows.lease_id.map(rent_credit_by_lease).fillna(0) / lease_rows.sTermMonths lease_rows["flag_consistent"] = ~(lease_rows.sConcession.eq(1) & lease_rows.matched_entries.eq(0)) by_unit_sequence = lease_rows.sort_values(UNIT_KEY + ["sTermSeq"]).groupby(UNIT_KEY) lease_rows["prior_rent_only"] = by_unit_sequence.rent_only.shift() lease_rows["prior_flag_consistent"] = by_unit_sequence.flag_consistent.shift(fill_value=False) concession_reconciliation = pd.DataFrame([{ "écritures": len(concessions), "rattachées à un bail": matched_concessions.concession_id.nunique(), "non rattachées": len(concessions) - matched_concessions.concession_id.nunique(), "baux marqués sans écriture": int((lease_rows.sConcession.eq(1) & lease_rows.matched_entries.eq(0)).sum()), "baux non marqués avec écriture": int((lease_rows.sConcession.eq(0) & lease_rows.matched_entries.gt(0)).sum())}]) display(concession_reconciliation) pairs_with_reconstruction = eligible_pairs.merge( lease_rows[[*UNIT_KEY, "sTermSeq", "rent_only", "prior_rent_only", "flag_consistent", "prior_flag_consistent"]], left_on=["property_code", "unit_code", "term_seq"], right_on=[*UNIT_KEY, "sTermSeq"], validate="one_to_one") common_sample = pairs_with_reconstruction[pairs_with_reconstruction.flag_consistent & pairs_with_reconstruction.prior_flag_consistent & pairs_with_reconstruction.rent_only.gt(0) & pairs_with_reconstruction.prior_rent_only.gt(0)].copy() common_sample["rent_only_pct"] = PERCENT * ((common_sample.rent_only / common_sample.prior_rent_only) ** (1 / common_sample.interval_years) - 1) common_sample_by_year = common_sample.groupby("event_year").agg( n=("effective_growth", "size"), face_pct=("face_growth", lambda growth: PERCENT * growth.mean()), rent_only_pct=("rent_only_pct", "mean"), supplied_effective_pct=("effective_growth", lambda growth: PERCENT * growth.mean())).reset_index() display(common_sample_by_year.round(3)) ax = common_sample_by_year.set_index("event_year")[["face_pct", "rent_only_pct", "supplied_effective_pct"]].rename(columns={ "face_pct": "Contractuel", "rent_only_pct": "Contractuel − crédits locatifs du registre", "supplied_effective_pct": "Effectif fourni"}).plot(marker="o", figsize=(9, 4)) ax.set(title="Même échantillon : effet de la définition du loyer", ylabel="Croissance annualisée (%)", xlabel="Année de début du nouveau bail") plt.tight_layout() plt.show()
| écritures | rattachées à un bail | non rattachées | baux marqués sans écriture | baux non marqués avec écriture | |
|---|---|---|---|---|---|
| 0 | 3560 | 3356 | 204 | 497 | 264 |
| event_year | n | face_pct | rent_only_pct | supplied_effective_pct | |
|---|---|---|---|---|---|
| 0 | 2018 | 84 | 2.038 | 1.893 | 2.003 |
| 1 | 2019 | 142 | 2.034 | 1.361 | 1.740 |
| 2 | 2020 | 280 | 2.998 | 3.116 | 2.843 |
| 3 | 2021 | 283 | 4.503 | 3.717 | 3.813 |
| 4 | 2022 | 273 | 5.252 | 5.689 | 4.641 |
| 5 | 2023 | 336 | 2.078 | 1.693 | 2.003 |
| 6 | 2024 | 534 | 3.761 | 2.551 | 2.232 |
| 7 | 2025 | 502 | 7.542 | 5.942 | 3.775 |
5c. Pourquoi contractuel et effectif divergent à partir de 2024
D'après l'identité de la section 4b, la croissance effective est la croissance contractuelle moins la variation de l'écart de concession entre le bail précédent et le nouveau. L'écart moyen se décompose en incidence (part des baux avec concession) × profondeur (écart moyen quand il y en a une). La variation de l'écart se sépare donc exactement, au point milieu, en une part « incidence » et une part « profondeur ».
Détails
Lecture : si les deux croissances divergent, ce n'est pas que le prix contractuel cesse de monter; c'est que l'écart s'élargit au nouveau bail.
Le registre indique quels codes portent l'écart, mais ne le réconcilie pas exactement : il contient aussi des services qui ne sont pas du loyer,
et il lui manque des écritures (non rattachées, baux marqués sans écriture). Le chiffre fiable reste l'écart sRent / sRentEffective; la ventilation par code est indicative.
def concession_divergence(pairs_table): # Par année : croissance contractuelle vs effective, et variation de l'écart = incidence + profondeur. rows = [] for year, year_pairs in pairs_table[pairs_table["eligible"]].groupby("event_year"): incidence_prior, incidence_new = float((year_pairs["prior_gap"] > 0).mean()), float((year_pairs["new_gap"] > 0).mean()) depth_prior = float(year_pairs.loc[year_pairs["prior_gap"] > 0, "prior_gap"].mean()) if incidence_prior else 0.0 depth_new = float(year_pairs.loc[year_pairs["new_gap"] > 0, "new_gap"].mean()) if incidence_new else 0.0 gap_prior, gap_new = float(year_pairs["prior_gap"].mean()), float(year_pairs["new_gap"].mean()) incidence_part = (incidence_new - incidence_prior) * (depth_prior + depth_new) / 2.0 depth_part = (depth_new - depth_prior) * (incidence_prior + incidence_new) / 2.0 rows.append({ "event_year": int(year), "pairs": len(year_pairs), "face_pct": PERCENT * year_pairs["face_growth"].mean(), "effective_pct": PERCENT * year_pairs["effective_growth"].mean(), "face_minus_effective_pp": PERCENT * (year_pairs["face_growth"].mean() - year_pairs["effective_growth"].mean()), "prior_gap_pct": PERCENT * gap_prior, "new_gap_pct": PERCENT * gap_new, "gap_change_pp": PERCENT * (gap_new - gap_prior), "incidence_prior": incidence_prior, "incidence_new": incidence_new, "depth_prior_pct": PERCENT * depth_prior, "depth_new_pct": PERCENT * depth_new, "gap_change_from_incidence_pp": PERCENT * incidence_part, "gap_change_from_depth_pp": PERCENT * depth_part}) return pd.DataFrame(rows) def concession_code_contribution(lease_rows_table, matched_table): # Concession amortie moyenne en % du loyer contractuel, sur tous les baux, par année et par code. credit_by_code = matched_table.groupby(["lease_id", "code"])["total_credit"].sum().unstack(fill_value=0.0) codes = list(credit_by_code.columns) per_lease = lease_rows_table[["lease_id", "sLeaseFrom", "sTermMonths", "sRent", "sRentEffective"]].join(credit_by_code, on="lease_id") per_lease[codes] = per_lease[codes].fillna(0.0).div(per_lease["sTermMonths"], axis=0).div(per_lease["sRent"], axis=0) start_year = per_lease["sLeaseFrom"].dt.year contribution = per_lease.groupby(start_year)[codes].mean().mul(PERCENT) contribution["total_matched_pct"] = contribution[codes].sum(axis=1) contribution["supplied_gap_pct"] = PERCENT * (1 - per_lease["sRentEffective"] / per_lease["sRent"]).groupby(start_year).mean() contribution["unexplained_by_ledger_pct"] = contribution["supplied_gap_pct"] - contribution["total_matched_pct"] contribution.index.name = "lease_start_year" return contribution.reset_index() divergence = concession_divergence(unit_pairs) display(divergence.round(2)) fig, axes = plt.subplots(1, 2, figsize=(13, 4)) divergence.set_index("event_year")[["face_pct", "effective_pct"]].rename( columns={"face_pct": "Contractuel", "effective_pct": "Effectif"}).plot(ax=axes[0], marker="o", title="Contractuel vs effectif, même unité") axes[0].set(ylabel="% annualisé", xlabel="Année de début du nouveau bail") divergence.set_index("event_year")[["gap_change_from_incidence_pp", "gap_change_from_depth_pp"]].rename( columns={"gap_change_from_incidence_pp": "Incidence", "gap_change_from_depth_pp": "Profondeur"}).plot.bar( ax=axes[1], stacked=True, title="Variation de l'écart de concession : incidence + profondeur") axes[1].set(ylabel="points de pourcentage", xlabel="Année de début du nouveau bail") plt.tight_layout() plt.show() display(concession_code_contribution(lease_rows, matched_concessions).round(2))
| event_year | pairs | face_pct | effective_pct | face_minus_effective_pp | prior_gap_pct | new_gap_pct | gap_change_pp | incidence_prior | incidence_new | depth_prior_pct | depth_new_pct | gap_change_from_incidence_pp | gap_change_from_depth_pp | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 2018 | 88 | 2.13 | 2.04 | 0.08 | 1.21 | 1.36 | 0.15 | 0.40 | 0.38 | 3.04 | 3.62 | -0.08 | 0.22 |
| 1 | 2019 | 159 | 2.00 | 1.59 | 0.41 | 1.36 | 1.78 | 0.42 | 0.38 | 0.47 | 3.54 | 3.77 | 0.32 | 0.10 |
| 2 | 2020 | 331 | 3.03 | 2.77 | 0.27 | 1.77 | 2.07 | 0.30 | 0.46 | 0.48 | 3.84 | 4.28 | 0.10 | 0.20 |
| 3 | 2021 | 355 | 4.38 | 3.75 | 0.63 | 2.02 | 2.68 | 0.66 | 0.47 | 0.54 | 4.29 | 4.99 | 0.31 | 0.35 |
| 4 | 2022 | 352 | 5.27 | 4.72 | 0.56 | 2.74 | 3.35 | 0.61 | 0.56 | 0.58 | 4.92 | 5.78 | 0.12 | 0.49 |
| 5 | 2023 | 416 | 2.09 | 2.18 | -0.09 | 3.21 | 3.24 | 0.03 | 0.55 | 0.49 | 5.81 | 6.64 | -0.40 | 0.43 |
| 6 | 2024 | 698 | 3.73 | 1.95 | 1.78 | 3.66 | 5.46 | 1.79 | 0.55 | 0.70 | 6.63 | 7.79 | 1.06 | 0.73 |
| 7 | 2025 | 780 | 7.56 | 3.83 | 3.73 | 5.79 | 9.24 | 3.46 | 0.74 | 0.86 | 7.79 | 10.70 | 1.13 | 2.33 |
| lease_start_year | freeappl | freelock | freeothe | freepark | freerent | indem | promopay | referenc | total_matched_pct | supplied_gap_pct | unexplained_by_ledger_pct | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 2017 | 0.00 | 0.12 | 0.19 | 0.49 | 0.03 | 0.00 | 0.64 | 0.00 | 1.47 | 1.18 | -0.29 |
| 1 | 2018 | 0.07 | 0.17 | 0.22 | 0.44 | 0.07 | 0.03 | 0.73 | 0.06 | 1.79 | 1.36 | -0.43 |
| 2 | 2019 | 0.00 | 0.12 | 0.37 | 0.65 | 0.09 | 0.05 | 1.21 | 0.00 | 2.50 | 1.70 | -0.80 |
| 3 | 2020 | 0.03 | 0.11 | 0.41 | 0.47 | 0.09 | 0.04 | 1.08 | 0.03 | 2.25 | 2.09 | -0.16 |
| 4 | 2021 | 0.06 | 0.09 | 0.43 | 0.80 | 0.11 | 0.04 | 1.82 | 0.00 | 3.35 | 2.69 | -0.66 |
| 5 | 2022 | 0.02 | 0.13 | 0.55 | 0.96 | 0.12 | 0.05 | 1.58 | 0.06 | 3.47 | 3.33 | -0.15 |
| 6 | 2023 | 0.04 | 0.27 | 0.82 | 1.72 | 0.30 | 0.11 | 1.77 | 0.05 | 5.09 | 3.58 | -1.51 |
| 7 | 2024 | 0.09 | 0.34 | 1.25 | 1.67 | 0.33 | 0.10 | 2.56 | 0.06 | 6.39 | 5.71 | -0.69 |
| 8 | 2025 | 0.07 | 0.27 | 0.77 | 1.62 | 0.19 | 0.10 | 3.77 | 0.06 | 6.83 | 9.13 | 2.30 |
Lecture. Jusqu'en 2023, contractuel et effectif restent à moins d'un point l'un de l'autre. Ensuite l'écart de concession du nouveau bail s'élargit, pour deux raisons successives :
- en 2024, surtout l'incidence : la part des nouveaux baux avec concession passe d'environ 55 % à 70 %;
- en 2025, surtout la profondeur : l'écart moyen d'un bail avec concession passe d'environ 7,8 % à 10,7 % du loyer, pendant que l'incidence monte encore.
Le registre montre que PromoPay porte l'essentiel de cette hausse. Le contractuel continue de monter, mais une partie croissante est rendue en mois gratuits. C'est pourquoi nous citons l'effectif : c'est ce qu'un propriétaire encaisse. Le contractuel reste présenté, car c'est lui qui compte pour l'encadrement des renouvellements.
6. Les renouvellements ne se comportent pas comme les relocationssRenewal = 1 : un locataire en place renouvelle. sRenewal = 0 : l'unité se libère et se reloue au prix du marché. Au Québec, les deux sont régis par des règles
6. Les renouvellements ne se comportent pas comme les relocations
sRenewal = 1 : un locataire en place renouvelle. sRenewal = 0 : l'unité se libère et se reloue au prix du marché. Au Québec, les deux sont régis par des règles
différentes (le TAL encadre la hausse d'un locataire en place); en Ontario, la ligne directrice ne vise que les logements sous contrôle des loyers.
Le tableau du starter montre que l'écart change de camp : les renouvellements mènent en 2022, les relocations en 2025.
FIRST_SPLIT_YEAR = 2022 split = (pairs[pairs.year.between(FIRST_SPLIT_YEAR, LAST_OBSERVED_YEAR)] .groupby(["year", "sRenewal"]) .growth_pct.agg(pairs="size", median="median").round(2) .unstack("sRenewal")) split.columns = [f"{stat}_{'renewal' if is_renewal else 'turnover'}" for stat, is_renewal in split.columns] split
| pairs_turnover | pairs_renewal | median_turnover | median_renewal | |
|---|---|---|---|---|
| year | ||||
| 2022 | 126 | 226 | 4.22 | 5.69 |
| 2023 | 177 | 239 | 1.85 | 2.58 |
| 2024 | 276 | 422 | 4.85 | 2.94 |
| 2025 | 307 | 473 | 8.98 | 6.41 |
Séparation par province et par type d'événement, sur nos paires. Moyennes contractuelle et effective par année × province × événement. The Met (Ontario) est toujours traité à part : le plafond d'une province n'est jamais appliqué à l'autre. Le modèle (section 7.3) prévoit chaque immeuble × type d'événement séparément, puis les recombine selon la part attendue de renouvellements.
Loyer affiché. C'est un prix annoncé, pas signé. Le dernier loyer affiché connu au 31 décembre 2025 est très proche du loyer des listings : dans cet extrait, il n'apporte pas d'information indépendante. Sans offre horodatée avant la signature et reliée au bail réalisé, nous ne l'utilisons pas comme variable.
segments = eligible_pairs.groupby(["event_year", "province", "event_type"]).agg( n=("effective_growth", "size"), face_pct=("face_growth", lambda growth: PERCENT * growth.mean()), effective_pct=("effective_growth", lambda growth: PERCENT * growth.mean())).reset_index() display(segments[segments.event_year.ge(REQUIRED_BACKTEST_YEARS[0])].round(3)) asking_known = asking.copy() asking_known["sMonth"] = pd.to_datetime(asking_known.sMonth) asking_known = asking_known[asking_known.sMonth.le(pd.Timestamp(LAST_OBSERVED_YEAR, 12, 31))] latest_asking = asking_known.sort_values("sMonth").drop_duplicates(UNIT_KEY, keep="last") asking_vs_listing = latest_asking.merge(listings[UNIT_KEY + ["sRent"]], on=UNIT_KEY, validate="one_to_one") display(pd.DataFrame([{"origine": f"{LAST_OBSERVED_YEAR}-12-31", "unités avec loyer affiché": len(latest_asking), "appariées aux listings": len(asking_vs_listing), "ratio médian affiché / listing": float((asking_vs_listing.sAskingRent / asking_vs_listing.sRent).median())}]).round(4))
| event_year | province | event_type | n | face_pct | effective_pct | |
|---|---|---|---|---|---|---|
| 10 | 2023 | Quebec | renewal | 239 | 2.550 | 2.747 |
| 11 | 2023 | Quebec | turnover | 177 | 1.467 | 1.409 |
| 12 | 2024 | Ontario | renewal | 67 | 2.899 | 0.328 |
| 13 | 2024 | Ontario | turnover | 41 | 5.080 | 4.756 |
| 14 | 2024 | Quebec | renewal | 355 | 2.915 | 1.368 |
| 15 | 2024 | Quebec | turnover | 235 | 4.956 | 2.803 |
| 16 | 2025 | Ontario | renewal | 63 | 6.333 | 1.893 |
| 17 | 2025 | Ontario | turnover | 54 | 8.925 | 4.547 |
| 18 | 2025 | Quebec | renewal | 410 | 6.529 | 2.603 |
| 19 | 2025 | Quebec | turnover | 253 | 9.243 | 6.157 |
| origine | unités avec loyer affiché | appariées aux listings | ratio médian affiché / listing | |
|---|---|---|---|---|
| 0 | 2025-12-31 | 1061 | 1061 | 0.9824 |
7. À vous de jouer : notre prévisionPour chaque immeuble et chaque type de bail (renouvellement ou relocation), nous faisons la moyenne de trois lectures simples :
7. À vous de jouer : notre prévision
7.1 La question (rappel)
Nous prévoyons la moyenne des croissances annualisées du loyer effectif, à unité identique, pour les paires dont le nouveau bail commence en 2026 (section 0). Le contractuel est prévu à côté. Ce chiffre décrit les baux comparables; ce n'est ni la croissance du revenu total, ni celle du NOI.
7.2 Données publiques et règle de date
external_context.csv rassemble les sources publiques demandées, chacune avec sa période, sa date de publication et son lien :
SCHL (loyers à échantillon fixe et inoccupation, RMR de Montréal et d'Ottawa), Statistique Canada (IPC, composante loyers, Québec et Ontario),
TAL (ajustement de base des renouvellements au Québec) et la ligne directrice de l'Ontario.
Détails
Règle : une prévision faite au 31 décembre ne peut utiliser qu'une donnée publiée avant cette date. La fonction external_asof refuse toute
publication postérieure, sans lien, ou non numérique. C'est pourquoi le TAL 2026, publié en janvier 2026, est rangé à part dans
external_postorigin_2026.csv et ne sert qu'aux sensibilités de la section 7.6.
external = pd.read_csv("external_context.csv") external_post_origin = pd.read_csv("external_postorigin_2026.csv") EXTERNAL_REQUIRED_COLUMNS = {"target_year", "release_date", "source_url", "metric", "value"} def origin_date(target_year): # Origine de la prévision : le 31 décembre de l'année précédant la cible. return pd.Timestamp(target_year - 1, SPEC.origin_month, SPEC.origin_day) def external_asof(external_table, target_year): # Sources publiques disponibles à l'origine; refuse les publications postérieures ou sans provenance. if external_table is None: return pd.DataFrame() if not isinstance(external_table, pd.DataFrame): raise DataContractError("external doit être un DataFrame de sources publiques avec provenance") if EXTERNAL_REQUIRED_COLUMNS - set(external_table): raise DataContractError(f"external : colonnes manquantes {sorted(EXTERNAL_REQUIRED_COLUMNS - set(external_table))}") available = external_table[external_table.target_year.eq(target_year)].copy() release = pd.to_datetime(available.release_date, errors="coerce") if release.isna().any() or (release > origin_date(target_year)).any(): raise DataContractError("publication manquante ou postérieure à l'origine de la prévision") if available.source_url.isna().any() or not available.source_url.astype(str).str.match(r"https?://\S+").all(): raise DataContractError("provenance manquante") if not np.isfinite(pd.to_numeric(available.value, errors="coerce")).all(): raise DataContractError("valeurs publiques non finies") return available display(external_asof(external, TARGET_YEAR)[["geography", "metric", "reference_period", "release_date", "value", "unit"]])
| geography | metric | reference_period | release_date | value | unit | |
|---|---|---|---|---|---|---|
| 3 | Quebec | TAL estimated average basic adjustment (lagged) | TAL 2025 | 2025-01-21 | 5.9000 | percent |
| 7 | Ontario | Ontario rent increase guideline | 2026 guideline | 2025-06-30 | 2.1000 | percent |
| 11 | Montreal CMA | CMHC fixed-sample average rent change | October 2025 | 2025-12-11 | 7.4000 | percent |
| 15 | Ottawa-Gatineau CMA (Ontario part) | CMHC fixed-sample average rent change | October 2025 | 2025-12-11 | 3.5000 | percent |
| 19 | Montreal CMA | CMHC apartment vacancy rate | October 2025 | 2025-12-11 | 2.9000 | percent |
| 23 | Ottawa-Gatineau CMA (Ontario part) | CMHC apartment vacancy rate | October 2025 | 2025-12-11 | 3.0000 | percent |
| 27 | Montreal CMA | Statistics Canada rented accommodation CPI yea... | November 2025 | 2025-12-15 | 7.3678 | percent YoY |
| 31 | Ontario | Statistics Canada rent CPI year-over-year | November 2025 | 2025-12-15 | 4.0687 | percent YoY |
7.3 Méthode : trois composantes simples, prévues par immeuble × type d'événement
En bref : pour chaque immeuble et chaque type de bail (renouvellement ou relocation), nous faisons la moyenne de trois lectures simples : l'élan de l'an dernier, le même élan converti en effectif logement par logement, et la tendance de long terme. Puis nous pondérons par le nombre de baux attendus en 2026.
Détails
Ce que la prévision a le droit de voir. Pour une cible Y, l'origine est le 31 décembre de Y − 1. On garde les baux signés et commencés avant l'origine, puis on construit les paires. Le roster est l'ensemble des unités dont le dernier bail connu à l'origine se termine en Y : ce sont les baux qui seront renouvelés ou reloués. Le roster anticipe la composition des événements; ce n'est pas un rôle de loyers complet.
Trois composantes de poids égaux (un tiers chacune, fixés à l'avance), calculées pour chaque immeuble × type d'événement :
- DIRECT : la croissance effective moyenne de l'année précédente (persistance).
- BRIDGE : la croissance contractuelle de l'année précédente, convertie en effective par l'identité exacte de la section 4b, appliquée unité par unité au roster, avec l'écart de concession actuel de chaque unité et l'écart des nouveaux baux de l'année précédente.
- ANCHOR : la moyenne effective de tout l'historique admissible (ancrage de long terme).
Petits échantillons. Chaque moyenne immeuble × événement est rétrécie vers la moyenne province × événement : (n × moyenne + k × parent) / (n + k), avec k = 10.
Une cellule absente utilise la province × événement, puis l'événement, puis le portefeuille.
Les moyennes d'entraînement sont bornées à −30 % / +60 % pour qu'un bail aberrant ne domine pas un petit immeuble. Les résultats réalisés ne sont jamais bornés.
Part de renouvellements. Pour chaque immeuble, la part des trois dernières années est rétrécie vers la province (k = 20).
Les renouvellements du Québec et de l'Ontario ne sont jamais mélangés.
Contractuel. Moitié dernière année, moitié historique, par cellule.
@dataclass(frozen=True) class OriginBundle: # Tout ce que la prévision a le droit de voir pour une année cible. target_year: int origin: pd.Timestamp pairs: pd.DataFrame # paires admissibles signées et commencées avant l'origine roster: pd.DataFrame # unités dont le dernier bail connu se termine pendant l'année cible @property def summary(self): return {"target_year": self.target_year, "origin": self.origin.date().isoformat(), "n_training_pairs": int(len(self.pairs)), "n_roster_units": int(len(self.roster)), "training_years": sorted(int(year) for year in self.pairs["event_year"].unique())} ROSTER_COLUMNS = ["property_code", "unit_code", "province", "bedrooms", "term_seq", "lease_end", "face_rent", "effective_rent", "prior_gap", "expected_years", "comparable"] def build_roster(lease_table, target_year): # Unités dont le dernier bail connu à l'origine se termine pendant l'année cible. # Le filtre d'origine passe AVANT le choix du dernier bail : un extrait plus récent ne change pas le roster. checked = validate_leases(lease_table) origin = origin_date(target_year) known_at_origin = checked[(checked["sSignDate"] <= origin) & (checked["sLeaseFrom"] <= origin)] latest_lease = known_at_origin.sort_values(UNIT_KEY + ["sTermSeq", "sLeaseFrom"]).drop_duplicates(UNIT_KEY, keep="last") roster = latest_lease[latest_lease["sLeaseTo"].dt.year.eq(target_year)].rename(columns=COLUMN_RENAMES) roster["prior_gap"] = 1.0 - roster["effective_rent"] / roster["face_rent"] # Hypothèse : le prochain bail commence le lendemain de la fin du bail actuel. roster["expected_years"] = ((roster["lease_end"] + pd.Timedelta(days=1)) - roster["lease_start"]).dt.days / DAYS_PER_YEAR roster["comparable"] = roster["expected_years"].between(SPEC.gap_min_years, SPEC.gap_max_years, inclusive="neither") return roster[ROSTER_COLUMNS].reset_index(drop=True) def make_bundle(lease_table, target_year): # Filtre signature ET début avant d'apparier : une séquence manquante ne peut pas être enjambée. checked = validate_leases(lease_table) origin = origin_date(target_year) observed = checked[(checked["sSignDate"] <= origin) & (checked["sLeaseFrom"] <= origin)] observed_pairs = build_pairs(observed) known_pairs = observed_pairs[observed_pairs["eligible"]].copy() roster = build_roster(checked, target_year) if known_pairs.empty: raise DataContractError(f"aucune paire admissible connue avant le {origin.date()}") if roster.empty or not roster["comparable"].any(): raise DataContractError(f"aucune unité expirant en {target_year} n'est connue au {origin.date()}") return OriginBundle(target_year, origin, known_pairs, roster) def clip_for_training(values, column): # Bornes de robustesse : écart de concession prévu, ou croissance. lower, upper = SPEC.gap_clip if column == "new_gap" else SPEC.growth_clip return values.clip(lower, upper) def shrunk_cell_means(pairs_table, column, shrink_k): # Moyennes immeuble × événement rétrécies vers province × événement (puis événement, puis portefeuille). if pairs_table.empty: return {} bounded = pairs_table.assign(_value=clip_for_training(pairs_table[column], column)) portfolio_mean = float(bounded["_value"].mean()) event_means = bounded.groupby("event_type")["_value"].mean().to_dict() province_event_means = bounded.groupby(["province", "event_type"])["_value"].mean() province_of = bounded.groupby("property_code")["province"].first().to_dict() cell_means = {} for (property_code, event_type), cell in bounded.groupby(["property_code", "event_type"]): parent = province_event_means.get((province_of[property_code], event_type), event_means.get(event_type, portfolio_mean)) n_pairs = len(cell) cell_means[(property_code, event_type)] = (n_pairs * float(cell["_value"].mean()) + shrink_k * parent) / (n_pairs + shrink_k) return cell_means def cell_value(cell_means, pairs_table, column, property_code, event_type, province): # Valeur de la cellule, avec repli hiérarchique pour une cellule absente. if (property_code, event_type) in cell_means: return cell_means[(property_code, event_type)] bounded = pairs_table.assign(_value=clip_for_training(pairs_table[column], column)) province_event = bounded[bounded["province"].eq(province) & bounded["event_type"].eq(event_type)]["_value"] if len(province_event): return float(province_event.mean()) same_event = bounded[bounded["event_type"].eq(event_type)]["_value"] return float(same_event.mean()) if len(same_event) else float(bounded["_value"].mean()) def event_mix(bundle): # Part attendue de renouvellements / relocations par immeuble, rétrécie vers la province. roster = bundle.roster[bundle.roster["comparable"]] recent = bundle.pairs[bundle.pairs["event_year"].between(bundle.target_year - SPEC.trailing_years_mix, bundle.target_year - 1)] overall_rate = (float(recent["event_type"].eq("renewal").mean()) if len(recent) else float(bundle.pairs["event_type"].eq("renewal").mean())) rows = [] for property_code, units in roster.groupby("property_code"): province = units["province"].iloc[0] property_recent = recent[recent["property_code"].eq(property_code)] province_recent = recent[recent["province"].eq(province)] prior_rate = float(province_recent["event_type"].eq("renewal").mean()) if len(province_recent) else overall_rate renewal_rate = ((property_recent["event_type"].eq("renewal").sum() + SPEC.shrink_k_mix * prior_rate) / (len(property_recent) + SPEC.shrink_k_mix)) for event_type, share in (("renewal", renewal_rate), ("turnover", 1.0 - renewal_rate)): rows.append({"property_code": property_code, "province": province, "event_type": event_type, "roster_units": len(units), "event_share": float(share), "expected_units": len(units) * float(share), "roster_prior_gap": float(units["prior_gap"].mean()), "n_recent_pairs": int(len(property_recent))}) mix_table = pd.DataFrame(rows) mix_table["weight"] = mix_table["expected_units"] / mix_table["expected_units"].sum() return mix_table def roster_bridge(bundle, property_code, face_growth, new_gap): # Moyenne des identités unité par unité du roster d'un immeuble (prochain début = fin actuelle + 1 jour). units = bundle.roster[bundle.roster["property_code"].eq(property_code) & bundle.roster["comparable"]] return float(np.mean(exact_bridge(face_growth, units["prior_gap"], new_gap, units["expected_years"])))
Les composantes sont calculées pour chaque cellule immeuble × événement, combinées à poids égaux, puis agrégées avec le poids attendu de chaque cellule (unités du roster × part de l'événement). Calculer l'identité par unité puis faire la moyenne évite de remplacer une moyenne de ratios par un ratio de moyennes.
def component_cells(bundle): # DIRECT, BRIDGE, ANCHOR et contractuel pour chaque immeuble × événement. known_pairs, target_year = bundle.pairs, bundle.target_year last_year = known_pairs[known_pairs["event_year"].eq(target_year - 1)] if last_year.empty: # aucun bail commencé l'an dernier : année la plus récente disponible last_year = known_pairs[known_pairs["event_year"].eq(known_pairs["event_year"].max())] weights = event_mix(bundle) direct = shrunk_cell_means(last_year, "effective_growth", SPEC.shrink_k_cell) face_last = shrunk_cell_means(last_year, "face_growth", SPEC.shrink_k_cell) gap_last = shrunk_cell_means(last_year, "new_gap", SPEC.shrink_k_cell) anchor = shrunk_cell_means(known_pairs, "effective_growth", SPEC.shrink_k_cell) face_anchor = shrunk_cell_means(known_pairs, "face_growth", SPEC.shrink_k_cell) rows = [] for _, cell in weights.iterrows(): key = (cell["property_code"], cell["event_type"], cell["province"]) face_persistence = cell_value(face_last, last_year, "face_growth", *key) new_gap_persistence = cell_value(gap_last, last_year, "new_gap", *key) rows.append({**cell.to_dict(), "DIRECT": cell_value(direct, last_year, "effective_growth", *key), "BRIDGE": roster_bridge(bundle, cell["property_code"], face_persistence, new_gap_persistence), "ANCHOR": cell_value(anchor, known_pairs, "effective_growth", *key), "face_persistence": face_persistence, "face_anchor": cell_value(face_anchor, known_pairs, "face_growth", *key), "new_gap_persistence": new_gap_persistence, "n_last_year_pairs": int((last_year["property_code"].eq(cell["property_code"]) & last_year["event_type"].eq(cell["event_type"])).sum())}) return pd.DataFrame(rows) def combine(cells, components=None, weights=None): # Combinaison à poids fixés : effectif = moyenne des composantes; contractuel = mélange dernière année / historique. components = SPEC.components if components is None else components weights = SPEC.component_weights if weights is None else weights if (not components or len(components) != len(weights) or not np.isfinite(weights).all() or min(weights) < 0 or abs(sum(weights) - 1.0) > 1e-9 or len(set(components)) != len(components) or not set(components).issubset(SPEC.components)): raise DataContractError("les poids doivent correspondre aux composantes et sommer à 1") combined = cells.copy() combined["effective"] = sum(weight * combined[name] for name, weight in zip(components, weights)) combined["face"] = (SPEC.face_persistence_weight * combined["face_persistence"] + (1 - SPEC.face_persistence_weight) * combined["face_anchor"]) return combined def aggregate(cells, column="effective"): # Moyenne pondérée par le poids attendu de chaque cellule. return float(np.average(cells[column], weights=cells["weight"])) def forecast(bundle, components=None, weights=None): cells = combine(component_cells(bundle), components, weights) return {"target_year": bundle.target_year, "origin": bundle.origin.date().isoformat(), "effective": aggregate(cells, "effective"), "face": aggregate(cells, "face"), "components": {name: aggregate(cells, name) for name in SPEC.components}, "renewal_share": float(cells.loc[cells["event_type"].eq("renewal"), "weight"].sum()), "roster_units": int(bundle.roster["comparable"].sum()), "roster_excluded": int((~bundle.roster["comparable"]).sum()), "cells": cells, "bundle": bundle.summary}
7.4 estimate_2026() et backtest() complétées
Les deux fonctions du starter, avec la même signature. estimate_2026 renvoie un pourcentage (4,8 veut dire 4,8 %).
backtest(leases, target_year) exécute la même méthode comme si nous étions au 31 décembre de target_year − 1, puis la compare à ce qui s'est réellement
passé pendant target_year. Le résultat réalisé contient toutes les paires admissibles de l'année, sans aucune borne.
def realized(lease_table, target_year): # Ce qui s'est réellement passé : toutes les paires admissibles dont le nouveau bail commence pendant l'année cible. all_pairs = build_pairs(lease_table) return all_pairs[all_pairs["eligible"] & all_pairs["event_year"].eq(target_year)].copy() def backtest_detail(lease_table, target_year, components=None, weights=None): bundle = make_bundle(lease_table, target_year) prediction = forecast(bundle, components, weights) actual_pairs = realized(lease_table, target_year) if actual_pairs.empty: raise DataContractError(f"aucune paire réalisée en {target_year}") actual_effective, actual_face = float(actual_pairs["effective_growth"].mean()), float(actual_pairs["face_growth"].mean()) actual_by_cell = actual_pairs.groupby(["property_code", "event_type"]).agg( actual_effective=("effective_growth", "mean"), actual_face=("face_growth", "mean"), realized_n=("effective_growth", "size")).reset_index() cells = prediction["cells"].merge(actual_by_cell, on=["property_code", "event_type"], how="left") cells["abs_error"] = (cells["effective"] - cells["actual_effective"]).abs() scored = cells.dropna(subset=["actual_effective"]) segment_mae = float(np.average(scored["abs_error"], weights=scored["realized_n"])) if len(scored) else np.nan # Diagnostics seulement : le roster n'est jamais repondéré avec le mix réalisé. comparable_roster = bundle.roster[bundle.roster.comparable] in_roster = actual_pairs.merge(comparable_roster[["property_code", "unit_code", "term_seq"]], left_on=["property_code", "unit_code", "prior_seq"], right_on=["property_code", "unit_code", "term_seq"], how="inner", validate="many_to_one") roster_units_seen = in_roster[["property_code", "unit_code"]].drop_duplicates().shape[0] return {"target_year": target_year, "origin": prediction["origin"], "predicted": prediction["effective"], "actual": actual_effective, "error": prediction["effective"] - actual_effective, "predicted_face": prediction["face"], "actual_face": actual_face, "face_error": prediction["face"] - actual_face, "components": prediction["components"], "component_errors": {name: value - actual_effective for name, value in prediction["components"].items()}, "segment_mae": segment_mae, "realized_pairs": int(len(actual_pairs)), "roster_units": prediction["roster_units"], "realized_in_roster": int(len(in_roster)), "target_roster_coverage": len(in_roster) / len(actual_pairs), "roster_observed_fraction": roster_units_seen / len(comparable_roster), "segment_target_coverage": float(scored.realized_n.sum() / len(actual_pairs)), "by_segment": cells.to_dict("records"), "cells": cells, "bundle": prediction["bundle"]} def estimate_2026(leases, asking, external=None): # Retourne notre estimation de la hausse de loyer 2026, en pourcentage. # Définition : moyenne des croissances annualisées du loyer effectif (sRentEffective), même unité (sPropCode + sUnitCode), # baux consécutifs dont le nouveau bail commence en 2026. `asking` est accepté pour respecter la signature du starter : # le loyer affiché reste un contexte (section 6). `external` est vérifié par la règle de date de la section 7.2. external_asof(external, TARGET_YEAR) return PERCENT * forecast(make_bundle(leases, TARGET_YEAR))["effective"] def backtest(leases, target_year): # Exécute la méthode comme si nous étions à la fin de target_year - 1, et compare avec ce qui s'est passé en target_year. detail = backtest_detail(leases, target_year) return {"target_year": target_year, "predicted_pct": PERCENT * detail["predicted"], "actual_pct": PERCENT * detail["actual"], "error_pp": PERCENT * detail["error"], "by_segment": detail["by_segment"], "segment_rate_units": "fraction"} estimate_pct = estimate_2026(leases, asking, external) forecast_2026 = forecast(make_bundle(leases, TARGET_YEAR)) display(pd.Series({"Hausse effective 2026 (%)": estimate_pct, "Hausse contractuelle 2026 (%)": PERCENT * forecast_2026["face"], **{f"Composante {name} (%)": PERCENT * value for name, value in forecast_2026["components"].items()}, "Part de renouvellements attendue": forecast_2026["renewal_share"], "Unités du roster comparables": forecast_2026["roster_units"], "Unités exclues (durée hors 0,5–2,5 ans)": forecast_2026["roster_excluded"]}).round(4))
Hausse effective 2026 (%) 4.8047 Hausse contractuelle 2026 (%) 6.3780 Composante DIRECT (%) 3.8592 Composante BRIDGE (%) 7.5240 Composante ANCHOR (%) 3.0309 Part de renouvellements attendue 0.6031 Unités du roster comparables 928.0000 Unités exclues (durée hors 0,5–2,5 ans) 3.0000 dtype: float64
7.5 Backtest sur 2023, 2024 et 2025, et comparaison à des méthodes simples
Le starter suggère for yr in (2023, 2024, 2025): backtest(leases, yr). Nous affichons le résultat en tableau. Pour voir plus de régimes, nous rejouons aussi
2020 à 2022, sans les présenter comme des échantillons indépendants.
Détails
Comparateurs, avec exactement les mêmes origines et la même cible : persistance (moyenne de l'année précédente), moyenne des trois dernières années, moyenne de tout l'historique. Aucun gagnant n'est choisi sur ces scores : les années 2023–2025 étaient déjà connues quand la méthode a été conçue, et trois points ne suffisent pas à départager des méthodes.
def rolling_evaluation(lease_table, target_years, components=None, weights=None): rows = [] for year in target_years: detail = backtest_detail(lease_table, year, components, weights) rows.append({"target_year": year, "origin": detail["origin"], "predicted_pct": PERCENT * detail["predicted"], "actual_pct": PERCENT * detail["actual"], "error_pp": PERCENT * detail["error"], "abs_error_pp": PERCENT * abs(detail["error"]), "predicted_face_pct": PERCENT * detail["predicted_face"], "actual_face_pct": PERCENT * detail["actual_face"], "face_error_pp": PERCENT * detail["face_error"], "segment_mae_pp": PERCENT * detail["segment_mae"], **{f"{name}_pct": PERCENT * value for name, value in detail["components"].items()}, **{f"{name}_error_pp": PERCENT * value for name, value in detail["component_errors"].items()}, "realized_pairs": detail["realized_pairs"], "roster_units": detail["roster_units"], **{key: detail[key] for key in ["realized_in_roster", "target_roster_coverage", "roster_observed_fraction", "segment_target_coverage"]}}) return pd.DataFrame(rows) def baseline_evaluation(lease_table, target_years): # Trois comparateurs simples, même origine et même cible; aucun choix du gagnant. rows = [] for year in target_years: bundle = make_bundle(lease_table, year) actual = float(realized(lease_table, year)["effective_growth"].mean()) for name, training in (("Last year pooled", bundle.pairs[bundle.pairs.event_year.eq(year - 1)]), ("Trailing 3 years pooled", bundle.pairs[bundle.pairs.event_year.ge(year - 3)]), ("All history pooled", bundle.pairs)): if training.empty: training = bundle.pairs prediction = float(training.effective_growth.mean()) rows.append({"target_year": year, "model": name, "predicted_pct": PERCENT * prediction, "actual_pct": PERCENT * actual, "error_pp": PERCENT * (prediction - actual)}) return pd.DataFrame(rows) required_backtests = pd.DataFrame([{key: value for key, value in backtest(leases, year).items() if key not in ("by_segment", "segment_rate_units")} for year in REQUIRED_BACKTEST_YEARS]) display(required_backtests.round(3)) evaluation_years = tuple(year for year in SPEC.evaluation_years if year < TARGET_YEAR) all_folds = rolling_evaluation(leases, evaluation_years) display(all_folds[["target_year", "predicted_pct", "actual_pct", "error_pp", "predicted_face_pct", "actual_face_pct", "face_error_pp", "segment_mae_pp", "roster_units", "realized_pairs"]].round(3)) baselines = baseline_evaluation(leases, evaluation_years) scores = baselines.groupby("model").error_pp.agg(MAE_pp=lambda error: error.abs().mean(), bias_pp="mean", worst_pp=lambda error: error.abs().max()) scores.loc["Notre méthode (fixe)"] = [all_folds.abs_error_pp.mean(), all_folds.error_pp.mean(), all_folds.abs_error_pp.max()] scores["MAE_2023_2025_pp"] = baselines[baselines.target_year.isin(REQUIRED_BACKTEST_YEARS)].groupby("model").error_pp.apply( lambda error: error.abs().mean()) scores.loc["Notre méthode (fixe)", "MAE_2023_2025_pp"] = all_folds[all_folds.target_year.isin(REQUIRED_BACKTEST_YEARS)].abs_error_pp.mean() display(scores.round(3)) ax = all_folds.set_index("target_year")[["predicted_pct", "actual_pct"]].rename( columns={"predicted_pct": "Prévu au 31 décembre précédent", "actual_pct": "Réalisé"}).plot(marker="o", figsize=(9, 4)) ax.set(title="Backtest : prévision simulée vs réalisé (effectif, même unité)", ylabel="% annualisé", xlabel="Année cible", xticks=list(evaluation_years)) plt.tight_layout() plt.show()
| target_year | predicted_pct | actual_pct | error_pp | |
|---|---|---|---|---|
| 0 | 2023 | 4.472 | 2.178 | 2.294 |
| 1 | 2024 | 2.620 | 1.951 | 0.669 |
| 2 | 2025 | 2.928 | 3.833 | -0.905 |
| target_year | predicted_pct | actual_pct | error_pp | predicted_face_pct | actual_face_pct | face_error_pp | segment_mae_pp | roster_units | realized_pairs | |
|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 2020 | 1.764 | 2.769 | -1.005 | 2.005 | 3.034 | -1.029 | 0.957 | 306 | 331 |
| 1 | 2021 | 2.743 | 3.753 | -1.010 | 2.875 | 4.384 | -1.509 | 1.149 | 353 | 355 |
| 2 | 2022 | 3.747 | 4.716 | -0.968 | 3.881 | 5.273 | -1.392 | 0.955 | 347 | 352 |
| 3 | 2023 | 4.472 | 2.178 | 2.294 | 4.581 | 2.089 | 2.492 | 2.248 | 402 | 416 |
| 4 | 2024 | 2.620 | 1.951 | 0.669 | 2.755 | 3.728 | -0.973 | 1.743 | 640 | 698 |
| 5 | 2025 | 2.928 | 3.833 | -0.905 | 3.589 | 7.559 | -3.970 | 1.314 | 845 | 780 |
| MAE_pp | bias_pp | worst_pp | MAE_2023_2025_pp | |
|---|---|---|---|---|
| model | ||||
| All history pooled | 1.282 | -0.503 | 1.841 | 1.138 |
| Last year pooled | 1.295 | -0.373 | 2.538 | 1.549 |
| Trailing 3 years pooled | 1.409 | -0.372 | 1.754 | 1.421 |
| Notre méthode (fixe) | 1.142 | -0.154 | 2.294 | 1.289 |
Lecture honnête. Sur les six années 2020–2025, notre méthode a une erreur moyenne plus faible que les trois comparateurs simples (environ 1,1 point, contre 1,3 à 1,4) et le biais le plus faible. Mais sa pire année (2023, où elle prolonge la forte hausse de 2022 alors que le marché ralentit) est plus mauvaise que celle de la moyenne historique, et sur la fenêtre exigée 2023–2025 la moyenne de tout l'historique fait un peu mieux dans cet extrait. Nous l'affichons sans remplacer la méthode par le gagnant d'années déjà consultées : ce serait choisir après coup. L'apport de notre méthode est opérationnel : elle se décompose par immeuble, par type d'événement, en contractuel et en concessions, ce qui permet de budgéter et d'expliquer. Sa supériorité prédictive n'est pas démontrée, et nous ne la prétendons pas.
La couverture du roster (paires réalisées dont le bail précédent était bien dans le roster) est un diagnostic; elle ne modifie jamais les poids après coup.
display(all_folds[["target_year", "realized_in_roster", "target_roster_coverage", "roster_observed_fraction", "segment_target_coverage"]].round(3))
| target_year | realized_in_roster | target_roster_coverage | roster_observed_fraction | segment_target_coverage | |
|---|---|---|---|---|---|
| 0 | 2020 | 303 | 0.915 | 0.990 | 1.000 |
| 1 | 2021 | 347 | 0.977 | 0.983 | 1.000 |
| 2 | 2022 | 342 | 0.972 | 0.986 | 1.000 |
| 3 | 2023 | 398 | 0.957 | 0.990 | 0.998 |
| 4 | 2024 | 629 | 0.901 | 0.983 | 0.990 |
| 5 | 2025 | 763 | 0.978 | 0.903 | 1.000 |
7.6 Réconciliation avec les données publiques
En bref : nos chiffres sont mis côte à côte avec la SCHL, l'IPC, le TAL et la ligne directrice de l'Ontario, province par province, pour en vérifier la vraisemblance.
Détails
Pour chaque source publique disponible au 31 décembre 2025, nous plaçons à côté la prévision interne de la même province, contractuelle et effective. Pour le TAL et la ligne directrice de l'Ontario, qui encadrent les renouvellements, la comparaison se fait sur les seules cellules de renouvellement. Ces écarts aident à défendre les hypothèses, sans prouver de causalité : notre parc récent, ses concessions et son mix diffèrent du marché d'une RMR. L'inoccupation n'est pas soustraite d'une croissance (cela n'aurait pas de sens). Ces quelques séries ne sont pas ajoutées comme variables du modèle : avec six années, ce serait du surajustement (la section 9 le confirme avec des variables publiques).
def external_comparison(lease_table, external_table, target_year=TARGET_YEAR): cells = forecast(make_bundle(lease_table, target_year))["cells"] rows = [] for source in external_asof(external_table, target_year).itertuples(): geography = source.geography.lower() province = "Ontario" if ("ontario" in geography or "ottawa" in geography) else "Quebec" # régimes jamais mélangés province_cells = cells[cells.province.eq(province)] regulatory = "TAL" in source.metric or "guideline" in source.metric.lower() if regulatory: province_cells = province_cells[province_cells.event_type.eq("renewal")] face = PERCENT * np.average(province_cells.face, weights=province_cells.weight) effective = PERCENT * np.average(province_cells.effective, weights=province_cells.weight) is_vacancy = "vacancy" in source.metric.lower() rows.append({"geography": source.geography, "metric": source.metric, "reference_period": source.reference_period, "release_date": source.release_date, "public_value": source.value, "internal_scope": "renewal" if regulatory else "all events", "internal_face_pct": face, "internal_effective_pct": effective, "face_minus_benchmark_pp": np.nan if is_vacancy else face - source.value}) return pd.DataFrame(rows) display(external_comparison(leases, external).round(3))
| geography | metric | reference_period | release_date | public_value | internal_scope | internal_face_pct | internal_effective_pct | face_minus_benchmark_pp | |
|---|---|---|---|---|---|---|---|---|---|
| 0 | Quebec | TAL estimated average basic adjustment (lagged) | TAL 2025 | 2025-01-21 | 5.900 | renewal | 5.633 | 3.831 | -0.267 |
| 1 | Ontario | Ontario rent increase guideline | 2026 guideline | 2025-06-30 | 2.100 | renewal | 5.448 | 3.011 | 3.348 |
| 2 | Montreal CMA | CMHC fixed-sample average rent change | October 2025 | 2025-12-11 | 7.400 | all events | 6.350 | 4.876 | -1.050 |
| 3 | Ottawa-Gatineau CMA (Ontario part) | CMHC fixed-sample average rent change | October 2025 | 2025-12-11 | 3.500 | all events | 6.566 | 4.317 | 3.066 |
| 4 | Montreal CMA | CMHC apartment vacancy rate | October 2025 | 2025-12-11 | 2.900 | all events | 6.350 | 4.876 | NaN |
| 5 | Ottawa-Gatineau CMA (Ontario part) | CMHC apartment vacancy rate | October 2025 | 2025-12-11 | 3.000 | all events | 6.566 | 4.317 | NaN |
| 6 | Montreal CMA | Statistics Canada rented accommodation CPI yea... | November 2025 | 2025-12-15 | 7.368 | all events | 6.350 | 4.876 | -1.017 |
| 7 | Ontario | Statistics Canada rent CPI year-over-year | November 2025 | 2025-12-15 | 4.069 | all events | 6.566 | 4.317 | 2.497 |
Lecture.
- Québec, renouvellements : notre prévision contractuelle des renouvellements québécois (environ 5,6 %) est proche du dernier TAL connu à l'origine (5,9 %). Les deux sont cohérents.
- Montréal, marché : la SCHL (7,4 %) et l'IPC loyers (7,4 %) sont au-dessus de notre contractuel (6,35 %). Notre effectif (4,9 %) est plus bas encore, à cause des concessions que les indices de marché ne mesurent pas.
- Ontario, The Met : les renouvellements de The Met ont augmenté d'environ 6,3 % en contractuel en 2025 (section 6), bien au-dessus de la ligne directrice (2,5 % pour 2025, 2,1 % pour 2026). C'est cohérent avec un immeuble exempté, occupé pour la première fois après le 15 novembre 2018 : son premier bail dans l'extrait date de 2023 (section 3). Ce n'est pas une preuve; la date de première occupation reste à confirmer. Nous ne plafonnons donc pas The Met dans la prévision centrale, et le scénario Ontario ci-dessous reste conditionnel.
Détails
Notre tendance face au marché, année par année. Le graphique place, pour chaque province, nos croissances à unité identique réalisées (contractuel et effectif) à côté des séries publiques de la même année : loyer moyen à échantillon fixe de la SCHL (RMR), IPC loyers de Statistique Canada, et l'encadrement des renouvellements (TAL au Québec, ligne directrice en Ontario). Les losanges de 2026 sont notre prévision.
FIRST_MARKET_YEAR = 2021 REFERENCE_YEAR_PATTERN = r"(\d{4})" MARKET_PANELS = (("Quebec", "Montreal CMA", "Montreal CMA", "TAL", "Québec (5 immeubles)"), ("Ontario", "Ottawa-Gatineau CMA (Ontario part)", "Ontario", "guideline", "Ontario (The Met)")) internal_by_province = eligible_pairs.groupby(["province", "event_year"]).agg( face_pct=("face_growth", lambda growth: PERCENT * growth.mean()), effective_pct=("effective_growth", lambda growth: PERCENT * growth.mean())) forecast_cells = forecast_2026["cells"] public_series = external.assign(reference_year=external.reference_period.str.extract(REFERENCE_YEAR_PATTERN, expand=False).astype(int)) fig, axes = plt.subplots(1, 2, figsize=(14, 4.5), sharey=True) for ax, (province, cma, cpi_geography, regulator, title) in zip(axes, MARKET_PANELS): internal = internal_by_province.loc[province].loc[FIRST_MARKET_YEAR:LAST_OBSERVED_YEAR] province_cells = forecast_cells[forecast_cells.province.eq(province)] ax.plot(internal.index, internal.face_pct, "o-", color="tab:blue", label="Équinoxe, contractuel (même unité)") ax.plot(internal.index, internal.effective_pct, "o-", color="tab:orange", label="Équinoxe, effectif (même unité)") ax.plot([TARGET_YEAR], [PERCENT * np.average(province_cells.face, weights=province_cells.weight)], "D", color="tab:blue", ms=9) ax.plot([TARGET_YEAR], [PERCENT * np.average(province_cells.effective, weights=province_cells.weight)], "D", color="tab:orange", ms=9, label="Notre prévision 2026") cmhc = public_series[public_series.geography.eq(cma) & public_series.metric.str.contains("fixed-sample")] cpi = public_series[public_series.geography.eq(cpi_geography) & public_series.metric.str.contains("CPI")] rules = public_series[public_series.metric.str.contains(regulator)] ax.plot(cmhc.reference_year, cmhc.value, "s--", color="tab:green", label="SCHL, loyer moyen à échantillon fixe (RMR)") ax.plot(cpi.reference_year, cpi.value, "^--", color="tab:purple", label="IPC loyers (Statistique Canada)") ax.plot(rules.reference_year, rules.value, "x:", color="black", ms=8, label="Encadrement des renouvellements (TAL / ligne directrice)") ax.set(title=title, xlabel="Année", xticks=list(range(FIRST_MARKET_YEAR, TARGET_YEAR + 1))) ax.grid(alpha=0.3) axes[0].set_ylabel("% par an") axes[1].legend(loc="upper left", bbox_to_anchor=(1.02, 1), fontsize=9) plt.tight_layout() plt.show()
Lecture. Au Québec, notre contractuel à unité identique est au niveau du marché (SCHL) en 2021–2022, puis décroche nettement en 2023–2024 : il colle alors presque au TAL (2,1 % contre 2,3 % en 2023, 3,7 % contre 4,0 % en 2024), pendant que la SCHL et l'IPC restent au-dessus de 6 %. En 2025, il rejoint le marché (7,6 % contre 7,4 % pour la SCHL). Notre effectif reste sous les indices depuis 2023 : les indices publics ne mesurent pas les mois gratuits, alors que nous les mesurons. En Ontario, le marché d'Ottawa ralentit en 2025 (SCHL 3,5 %) alors que les loyers contractuels de The Met accélèrent; l'effectif, lui, reste modéré. Ces séries publiques servent à vérifier la vraisemblance de notre prévision, pas à la calculer : la section 9 montre que les ajouter au modèle n'améliore pas les backtests.
Le TAL aide-t-il à prévoir les renouvellements québécois? Test fixé avant de l'exécuter. Sur 2023–2025, la règle « dernière année + (TAL de l'année − TAL de l'année précédente) » pour la croissance contractuelle des renouvellements au Québec doit battre à la fois la moyenne des années antérieures et la persistance, en erreur moyenne et en pire année. Pas de pente ajustée : il n'existe que trois valeurs TAL avant la dernière année. On suppose même le TAL de l'année connu (publié en janvier), ce qui avantage l'hypothèse. Si le test échoue, le TAL reste un contexte et les scénarios réglementaires sont des sensibilités, pas des prévisions.
def tal_passthrough_test(lease_table, external_table, test_years=REQUIRED_BACKTEST_YEARS): is_tal = external_table["metric"].str.contains("TAL", na=False) tal_by_year = {int(str(period).split()[-1]): float(value) for period, value in zip(external_table.loc[is_tal, "reference_period"], external_table.loc[is_tal, "value"])} all_pairs = build_pairs(lease_table) quebec_renewals = all_pairs[all_pairs["eligible"] & all_pairs["province"].eq("Quebec") & all_pairs["event_type"].eq("renewal")] actual_by_year = PERCENT * quebec_renewals.groupby("event_year")["face_growth"].mean() rows = [] for year in test_years: if year not in tal_by_year or year - 1 not in tal_by_year: raise DataContractError(f"valeurs TAL {year - 1} et {year} requises") earlier = actual_by_year[actual_by_year.index < year] rows.append({"year": year, "actual_pct": float(actual_by_year[year]), "all_prior_mean_pct": float(earlier.mean()), "persistence_pct": float(earlier.iloc[-1]), "tal_passthrough_pct": float(earlier.iloc[-1]) + tal_by_year[year] - tal_by_year[year - 1], "tal_pct": tal_by_year[year]}) table = pd.DataFrame(rows) errors = {method: (table[f"{method}_pct"] - table["actual_pct"]).abs() for method in ("all_prior_mean", "persistence", "tal_passthrough")} best_baseline = min(("all_prior_mean", "persistence"), key=lambda method: errors[method].mean()) verdict = {"mae_pp": {method: float(error.mean()) for method, error in errors.items()}, "worst_pp": {method: float(error.max()) for method, error in errors.items()}, "criterion": "le TAL doit battre les deux comparateurs en erreur moyenne et en pire année"} verdict["retained"] = bool(errors["tal_passthrough"].mean() < min(errors["all_prior_mean"].mean(), errors["persistence"].mean()) and errors["tal_passthrough"].max() <= errors[best_baseline].max()) return table, verdict tal_table, tal_verdict = tal_passthrough_test(leases, external) display(tal_table.round(2)) print(tal_verdict)
| year | actual_pct | all_prior_mean_pct | persistence_pct | tal_passthrough_pct | tal_pct | |
|---|---|---|---|---|---|---|
| 0 | 2023 | 2.55 | 3.92 | 5.83 | 6.85 | 2.3 |
| 1 | 2024 | 2.91 | 3.69 | 2.55 | 4.25 | 4.0 |
| 2 | 2025 | 6.53 | 3.58 | 2.91 | 4.81 | 5.9 |
{'mae_pp': {'all_prior_mean': 1.700063443886726, 'persistence': 2.419597978689527, 'tal_passthrough': 2.449407693602618}, 'worst_pp': {'all_prior_mean': 2.9456759355395805, 'persistence': 3.6143143314996435, 'tal_passthrough': 4.299194176938574}, 'criterion': 'le TAL doit battre les deux comparateurs en erreur moyenne et en pire année', 'retained': False}
Sensibilités réglementaires 2026 (publiées après l'origine). Le TAL 2026 (nouvelle méthode, moyenne triennale de l'IPC) et la ligne directrice de l'Ontario 2026
sont lus dans external_post_origin. Les cellules de renouvellement sont reprises au niveau de l'ancrage quand elles le dépassent, en gardant la variation
d'écart de concession modélisée. Côté Ontario, la ligne directrice ne couvre que les logements sous contrôle des loyers; les immeubles occupés pour la première fois
après le 15 novembre 2018 en sont exemptés, et la date de première occupation de The Met n'est pas dans l'extrait : ce scénario est conditionnel.
Aucun plafond n'est appliqué d'une province à l'autre.
ANCHOR_PLAUSIBLE_RANGE_PCT = (-5.0, 25.0) # garde-fou contre une saisie erronée de l'ancrage TAL_2026_PCT = float(external_post_origin.loc[external_post_origin.metric.str.startswith("TAL 2026"), "value"].iloc[0]) ONTARIO_2026_PCT = float(external_post_origin.loc[external_post_origin.metric.eq("Ontario rent increase guideline"), "value"].iloc[0]) def regulatory_scenarios(cells, quebec_renewal_face_pct, ontario_renewal_face_pct): for name, anchor_pct in (("québec", quebec_renewal_face_pct), ("ontario", ontario_renewal_face_pct)): if not np.isfinite(anchor_pct) or not ANCHOR_PLAUSIBLE_RANGE_PCT[0] < anchor_pct < ANCHOR_PLAUSIBLE_RANGE_PCT[1]: raise DataContractError(f"ancrage {name} invraisemblable : {anchor_pct}") def reprice(label, anchors): repriced = cells.copy() for province, anchor_pct in anchors.items(): renewal_cells = repriced["province"].eq(province) & repriced["event_type"].eq("renewal") excess = (repriced.loc[renewal_cells, "face"] - anchor_pct / PERCENT).clip(lower=0.0) repriced.loc[renewal_cells, "effective"] -= excess repriced.loc[renewal_cells, "face"] -= excess return {"scenario": label, "effective_pct": PERCENT * aggregate(repriced), "face_pct": PERCENT * aggregate(repriced, "face")} return pd.DataFrame([ reprice("Prévision centrale", {}), reprice(f"Renouvellements Québec au TAL 2026 ({quebec_renewal_face_pct:g} %)", {"Quebec": quebec_renewal_face_pct}), reprice(f"Renouvellements Ontario à la ligne directrice ({ontario_renewal_face_pct:g} %), si The Met est sous contrôle", {"Ontario": ontario_renewal_face_pct}), reprice("Les deux ancrages", {"Quebec": quebec_renewal_face_pct, "Ontario": ontario_renewal_face_pct})]) display(external_post_origin[["geography", "metric", "value", "release_date", "limitation"]]) display(regulatory_scenarios(forecast_2026["cells"], TAL_2026_PCT, ONTARIO_2026_PCT).round(3))
| geography | metric | value | release_date | limitation | |
|---|---|---|---|---|---|
| 0 | Quebec | TAL 2026 base rent-adjustment percentage | 3.10 | 2026-01 | Published AFTER the 2025-12-31 forecast origin... |
| 1 | Ontario | Ontario rent increase guideline | 2.10 | 2025-06-30 | Applies only to rent-controlled units; buildin... |
| 2 | Quebec | Statistics Canada rent CPI year-over-year, Aug... | 3.02 | 2026-09-14 | Single monthly reading; monthly rent CPI is no... |
| 3 | Ontario | Statistics Canada rent CPI year-over-year, Aug... | 2.37 | 2026-09-14 | Single monthly reading; not used in any estimate. |
| scenario | effective_pct | face_pct | |
|---|---|---|---|
| 0 | Prévision centrale | 4.805 | 6.378 |
| 1 | Renouvellements Québec au TAL 2026 (3.1 %) | 3.465 | 5.038 |
| 2 | Renouvellements Ontario à la ligne directrice ... | 4.557 | 6.130 |
| 3 | Les deux ancrages | 3.217 | 4.790 |
7.7 Scénarios et enveloppe historique
Les concessions peuvent séparer fortement le contractuel de l'effectif : nous déplaçons l'écart des nouveaux baux de ±2 points, et la part de relocations de ±10 points. Les scénarios d'écart se comparent à la référence structurelle (BRIDGE seul avec les concessions actuelles); ceux de relocation, à la prévision centrale. Ce sont des hypothèses transparentes, pas des probabilités.
L'enveloppe = prévision centrale ± plus grande erreur annuelle observée en backtest. Avec six années, un changement de régime et des années déjà consultées, ce n'est pas un intervalle de confiance : c'est l'ordre de grandeur de l'erreur passée.
CONCESSION_GAP_SHOCK = 0.02 # ±2 points d'écart de concession sur les nouveaux baux TURNOVER_SHARE_SHOCK = 0.10 # ±10 points de part de relocations def scenario_table(bundle): central = combine(component_cells(bundle)) rows = [] def add(label, cells): rows.append({"scenario": label, "effective_pct": PERCENT * aggregate(cells), "face_pct": PERCENT * aggregate(cells, "face")}) add("Prévision centrale", central) for label, gap_change in (("Référence structurelle : concessions actuelles", 0.0), (f"Structurel : écart de concession +{PERCENT * CONCESSION_GAP_SHOCK:g} points", CONCESSION_GAP_SHOCK), (f"Structurel : écart de concession −{PERCENT * CONCESSION_GAP_SHOCK:g} points", -CONCESSION_GAP_SHOCK)): shocked = central.copy() shocked["effective"] = [roster_bridge(bundle, cell.property_code, cell.face, float(np.clip(cell.new_gap_persistence + gap_change, *SPEC.gap_clip))) for cell in shocked.itertuples()] add(label, shocked) for share_change in (TURNOVER_SHARE_SHOCK, -TURNOVER_SHARE_SHOCK): shocked = central.copy() for property_code in shocked.property_code.unique(): turnover = shocked.property_code.eq(property_code) & shocked.event_type.eq("turnover") renewal = shocked.property_code.eq(property_code) & shocked.event_type.eq("renewal") turnover_share = float(np.clip(shocked.loc[turnover, "event_share"].iloc[0] + share_change, 0, 1)) shocked.loc[turnover, "event_share"] = turnover_share shocked.loc[renewal, "event_share"] = 1 - turnover_share shocked["expected_units"] = shocked.roster_units * shocked.event_share shocked["weight"] = shocked.expected_units / shocked.expected_units.sum() add(f"Part de relocations {PERCENT * share_change:+.0f} points", shocked) return pd.DataFrame(rows) scenarios = scenario_table(make_bundle(leases, TARGET_YEAR)) display(scenarios.round(3)) largest_backtest_error_pp = float(all_folds.abs_error_pp.max()) historical_envelope_pct = [estimate_pct - largest_backtest_error_pp, estimate_pct + largest_backtest_error_pp] print(f"Enveloppe historique descriptive : {historical_envelope_pct[0]:.2f} % à {historical_envelope_pct[1]:.2f} % " f"(centrale ± plus grande erreur annuelle de backtest, {largest_backtest_error_pp:.2f} pts; pas un intervalle de confiance)")
| scenario | effective_pct | face_pct | |
|---|---|---|---|
| 0 | Prévision centrale | 4.805 | 6.378 |
| 1 | Référence structurelle : concessions actuelles | 6.325 | 6.378 |
| 2 | Structurel : écart de concession +2 points | 4.012 | 6.378 |
| 3 | Structurel : écart de concession −2 points | 8.638 | 6.378 |
| 4 | Part de relocations +10 points | 5.077 | 6.575 |
| 5 | Part de relocations -10 points | 4.532 | 6.181 |
Enveloppe historique descriptive : 2.51 % à 7.10 % (centrale ± plus grande erreur annuelle de backtest, 2.29 pts; pas un intervalle de confiance)
8. Bonus : prévision par immeuble et par nombre de chambres, petit tableau de bordPar immeuble : les cellules du modèle, agrégées par sBuilding.
8. Bonus : prévision par immeuble et par nombre de chambres, petit tableau de bord
Par immeuble : les cellules du modèle, agrégées par sBuilding.
Détails
Par nombre de chambres : un écart par typologie, calculé sur les trois dernières années connues à l'origine et rétréci vers zéro (k = 20), est ajouté puis
recentré sur le roster de chaque immeuble, de sorte que chaque immeuble se réconcilie exactement avec sa prévision.
Cet écart n'est utile que s'il prédit mieux que « aucun effet de chambres » : le second tableau le vérifie sur les mêmes origines que le backtest.
S'il ne le fait pas, le tableau par chambres reste descriptif.
BEDROOM_SHRINK_K = 20.0 # même force de rétrécissement que la part de renouvellements BEDROOM_TRAILING_YEARS = 3 # même fenêtre que la part de renouvellements building_names = validated_leases.groupby("sPropCode")["sBuilding"].first().rename("building") def building_forecast(details_cells): cells = details_cells.merge(building_names, left_on="property_code", right_index=True, validate="many_to_one") return pd.DataFrame([{"building": name, "expected_events": building_cells.expected_units.sum(), "face_pct": PERCENT * np.average(building_cells.face, weights=building_cells.weight), "effective_pct": PERCENT * np.average(building_cells.effective, weights=building_cells.weight)} for name, building_cells in cells.groupby("building")]) def bedroom_offsets(pairs_table, target_year): recent = pairs_table[pairs_table["event_year"].between(target_year - BEDROOM_TRAILING_YEARS, target_year - 1)] if recent.empty: raise DataContractError("aucune paire récente pour estimer les écarts par chambres") effective = clip_for_training(recent["effective_growth"], "effective_growth") face = clip_for_training(recent["face_growth"], "face_growth") rows = [] for bedrooms, index in recent.groupby("bedrooms").groups.items(): n_pairs = len(index) shrink = n_pairs / (n_pairs + BEDROOM_SHRINK_K) rows.append({"bedrooms": bedrooms, "n_pairs": n_pairs, "effective_offset": shrink * float(effective.loc[index].mean() - effective.mean()), "face_offset": shrink * float(face.loc[index].mean() - face.mean())}) return pd.DataFrame(rows) def bedroom_forecast(lease_table, details_cells, target_year=TARGET_YEAR): bundle = make_bundle(lease_table, target_year) offsets = bedroom_offsets(bundle.pairs, target_year).set_index("bedrooms") roster = bundle.roster[bundle.roster["comparable"]].merge(building_names, left_on="property_code", right_index=True, validate="many_to_one") cells = details_cells.merge(building_names, left_on="property_code", right_index=True, validate="many_to_one") rows = [] for building, units in roster.groupby("building"): building_cells = cells[cells["building"].eq(building)] base = {column: float(np.average(building_cells[column], weights=building_cells["weight"])) for column in ("effective", "face")} units_by_bedrooms = units.groupby("bedrooms").size() for column in ("effective", "face"): building_offsets = offsets[f"{column}_offset"].reindex(units_by_bedrooms.index).fillna(0.0) base[f"{column}_recentre"] = float(np.average(building_offsets, weights=units_by_bedrooms)) for bedrooms, n_units in units_by_bedrooms.items(): offset = offsets.reindex([bedrooms]).fillna(0.0).iloc[0] rows.append({"building": building, "bedrooms": bedrooms, "roster_units": int(n_units), "effective_pct": PERCENT * (base["effective"] + offset["effective_offset"] - base["effective_recentre"]), "face_pct": PERCENT * (base["face"] + offset["face_offset"] - base["face_recentre"])}) return pd.DataFrame(rows) def bedroom_offset_backtest(lease_table, target_years): # Les écarts par chambres prédisent-ils l'écart réalisé l'année suivante mieux que « aucun effet »? rows = [] for year in target_years: bundle = make_bundle(lease_table, year) offsets = bedroom_offsets(bundle.pairs, year).set_index("bedrooms")["effective_offset"] actual_pairs = realized(lease_table, year) realized_spread = actual_pairs.groupby("bedrooms")["effective_growth"].mean() - actual_pairs["effective_growth"].mean() pairs_per_group = actual_pairs.groupby("bedrooms").size() common = realized_spread.index.intersection(offsets.index) weights = pairs_per_group.loc[common] rows.append({"target_year": year, "bedroom_groups": len(common), "mae_with_offsets_pp": PERCENT * float(np.average((offsets.loc[common] - realized_spread.loc[common]).abs(), weights=weights)), "mae_no_bedroom_effect_pp": PERCENT * float(np.average(realized_spread.loc[common].abs(), weights=weights))}) check = pd.DataFrame(rows) check["offsets_better"] = check["mae_with_offsets_pp"] < check["mae_no_bedroom_effect_pp"] return check by_building = building_forecast(forecast_2026["cells"]) display(by_building.round(3)) by_bedroom = bedroom_forecast(leases, forecast_2026["cells"]) display(by_bedroom.round(2)) bedroom_check = bedroom_offset_backtest(leases, SPEC.evaluation_years) display(bedroom_check.round(3)) print("Années où les écarts par chambres battent « aucun effet » :", int(bedroom_check.offsets_better.sum()), "sur", len(bedroom_check))
| building | expected_events | face_pct | effective_pct | |
|---|---|---|---|---|
| 0 | Daniel-Johnson | 128.0 | 6.055 | 4.937 |
| 1 | Le Carlyle | 180.0 | 6.428 | 4.522 |
| 2 | Levesque | 75.0 | 5.745 | 4.749 |
| 3 | Saint-Elzear | 267.0 | 6.017 | 4.967 |
| 4 | The Met | 119.0 | 6.566 | 4.317 |
| 5 | Westpark | 159.0 | 7.347 | 5.137 |
| building | bedrooms | roster_units | effective_pct | face_pct | |
|---|---|---|---|---|---|
| 0 | Daniel-Johnson | 0 | 12 | 4.98 | 6.34 |
| 1 | Daniel-Johnson | 1 | 54 | 4.94 | 6.24 |
| 2 | Daniel-Johnson | 2 | 58 | 4.94 | 5.82 |
| 3 | Daniel-Johnson | 3 | 4 | 4.71 | 6.09 |
| 4 | Le Carlyle | 1 | 68 | 4.53 | 6.69 |
| 5 | Le Carlyle | 2 | 107 | 4.53 | 6.26 |
| 6 | Le Carlyle | 3 | 5 | 4.30 | 6.53 |
| 7 | Levesque | 1 | 9 | 4.76 | 6.11 |
| 8 | Levesque | 2 | 64 | 4.75 | 5.69 |
| 9 | Levesque | 3 | 2 | 4.53 | 5.96 |
| 10 | Saint-Elzear | 1 | 77 | 4.98 | 6.31 |
| 11 | Saint-Elzear | 2 | 182 | 4.97 | 5.89 |
| 12 | Saint-Elzear | 3 | 8 | 4.75 | 6.16 |
| 13 | The Met | 0 | 37 | 4.34 | 6.76 |
| 14 | The Met | 1 | 46 | 4.31 | 6.67 |
| 15 | The Met | 2 | 36 | 4.30 | 6.24 |
| 16 | Westpark | 1 | 61 | 5.15 | 7.61 |
| 17 | Westpark | 2 | 95 | 5.14 | 7.18 |
| 18 | Westpark | 3 | 3 | 4.91 | 7.45 |
| target_year | bedroom_groups | mae_with_offsets_pp | mae_no_bedroom_effect_pp | offsets_better | |
|---|---|---|---|---|---|
| 0 | 2020 | 3 | 0.183 | 0.108 | False |
| 1 | 2021 | 3 | 0.071 | 0.073 | True |
| 2 | 2022 | 3 | 0.212 | 0.256 | True |
| 3 | 2023 | 3 | 0.162 | 0.087 | False |
| 4 | 2024 | 4 | 0.349 | 0.194 | False |
| 5 | 2025 | 4 | 0.124 | 0.122 | False |
Années où les écarts par chambres battent « aucun effet » : 2 sur 6
# Tableau de bord : prévision 2026 par immeuble, contractuel et effectif, avec l'enveloppe historique descriptive. dashboard = by_building.set_index("building") envelope_half_width_pp = (historical_envelope_pct[1] - historical_envelope_pct[0]) / 2 rows_position = np.arange(len(dashboard)) BAR_HEIGHT = 0.4 fig, ax = plt.subplots(figsize=(9, 4)) ax.barh(rows_position + BAR_HEIGHT / 2, dashboard.face_pct, BAR_HEIGHT, label="Contractuel") ax.barh(rows_position - BAR_HEIGHT / 2, dashboard.effective_pct, BAR_HEIGHT, xerr=envelope_half_width_pp, capsize=3, label="Effectif (± enveloppe historique)") ax.set_yticks(rows_position, [f"{name} ({events:.0f} év.)" for name, events in zip(dashboard.index, dashboard.expected_events)]) ax.axvline(estimate_pct, color="black", ls="--", lw=1, label=f"Portefeuille effectif : {estimate_pct:.2f} %") ax.set(title="Prévision 2026 par immeuble", xlabel="% annualisé, même unité") ax.legend(loc="upper center", bbox_to_anchor=(0.5, -0.18), ncol=3) plt.tight_layout() plt.show()
8b. Pourquoi ce chiffre? Lecture immeuble par immeubleUn gestionnaire ne veut pas seulement « +4,9 % » : il veut savoir pourquoi. Pour chaque immeuble, la prévision se réconcilie exactement en trois morceaux :
8b. Pourquoi ce chiffre? Lecture immeuble par immeuble
Un gestionnaire ne veut pas seulement « +4,9 % » : il veut savoir pourquoi. Pour chaque immeuble, la prévision se réconcilie exactement en trois morceaux :
Détails
- Renouvellements : part attendue des baux 2026 qui sont des renouvellements × hausse contractuelle prévue pour eux;
- Relocations : part attendue des relocations × hausse contractuelle prévue pour elles;
- Ajustement de concession : l'écart entre la prévision effective et la prévision contractuelle de l'immeuble, en points de pourcentage.
Renouvellements + relocations = hausse contractuelle de l'immeuble; plus l'ajustement de concession = hausse effective. La cellule vérifie que la somme retombe exactement sur la prévision de la section 8. Une précision honnête : la prévision contractuelle combine deux lectures et la prévision effective en combine trois (section 7.3), donc cet écart est une réconciliation entre les deux prévisions du modèle, pas une mesure isolée de l'effet causal des mois gratuits. Il s'interprète comme « ce que le modèle s'attend à voir repris par les concessions »; pour isoler un effet purement causal, il faudrait un contrefactuel qui ne change que les concessions. Nous ajoutons deux repères tirés des données : la hausse effective réalisée en 2025 (l'élan récent) et la moyenne de long terme de l'immeuble (la tendance).
Enfin, le contexte public autour de chaque immeuble (fichier public building_context.csv : chantiers résidentiels autorisés à moins de 2 km,
transport rapide, score d'emplacement, zone inondable, îlot de chaleur). Ce contexte aide à discuter le chiffre; il n'entre pas dans le calcul,
car aucune de ces variables n'a amélioré les backtests.
def explain_building(cells): # Réconciliation exacte d'un immeuble : renouvellements + relocations + ajustement de concession (effectif − contractuel) = effectif. weights = cells["weight"] renewal = cells["event_type"].eq("renewal") renewal_share = float(weights[renewal].sum() / weights.sum()) face_renewal = float(np.average(cells.loc[renewal, "face"], weights=weights[renewal])) face_turnover = float(np.average(cells.loc[~renewal, "face"], weights=weights[~renewal])) face = float(np.average(cells["face"], weights=weights)) effective = float(np.average(cells["effective"], weights=weights)) return {"renewal_share": renewal_share, "renewal_face_pct": PERCENT * face_renewal, "turnover_face_pct": PERCENT * face_turnover, "renewals_pp": PERCENT * renewal_share * face_renewal, "turnovers_pp": PERCENT * (1 - renewal_share) * face_turnover, "concessions_pp": -PERCENT * (face - effective), "face_pct": PERCENT * face, "effective_pct": PERCENT * effective, "momentum_DIRECT_pct": PERCENT * float(np.average(cells["DIRECT"], weights=weights)), "trend_ANCHOR_pct": PERCENT * float(np.average(cells["ANCHOR"], weights=weights))} cells_with_building = forecast_2026["cells"].merge(building_names, left_on="property_code", right_index=True, validate="many_to_one") why = pd.DataFrame({building: explain_building(cells) for building, cells in cells_with_building.groupby("building")}).T pairs_with_building = eligible_pairs.merge(building_names, left_on="property_code", right_index=True, validate="many_to_one") why["realised_2025_effective_pct"] = PERCENT * pairs_with_building[pairs_with_building.event_year.eq(LAST_OBSERVED_YEAR)].groupby( "building").effective_growth.mean() why = why.sort_values("effective_pct", ascending=False) # Les trois morceaux retombent exactement sur la prévision par immeuble de la section 8 (vérification arithmétique, pas une attribution causale). np.testing.assert_allclose(why.renewals_pp + why.turnovers_pp + why.concessions_pp, why.effective_pct, atol=1e-9) np.testing.assert_allclose(why.effective_pct, by_building.set_index("building").loc[why.index, "effective_pct"], atol=1e-9) display(why.round(2))
| renewal_share | renewal_face_pct | turnover_face_pct | renewals_pp | turnovers_pp | concessions_pp | face_pct | effective_pct | momentum_DIRECT_pct | trend_ANCHOR_pct | realised_2025_effective_pct | |
|---|---|---|---|---|---|---|---|---|---|---|---|
| Westpark | 0.62 | 6.14 | 9.34 | 3.83 | 3.52 | -2.21 | 7.35 | 5.14 | 3.80 | 3.81 | 3.78 |
| Saint-Elzear | 0.60 | 5.47 | 6.84 | 3.29 | 2.73 | -1.05 | 6.02 | 4.97 | 4.39 | 3.17 | 4.64 |
| Daniel-Johnson | 0.60 | 5.20 | 7.33 | 3.11 | 2.94 | -1.12 | 6.05 | 4.94 | 4.14 | 2.96 | 4.41 |
| Levesque | 0.55 | 5.38 | 6.19 | 2.96 | 2.78 | -1.00 | 5.75 | 4.75 | 3.59 | 2.89 | 3.17 |
| Le Carlyle | 0.63 | 5.80 | 7.50 | 3.65 | 2.78 | -1.91 | 6.43 | 4.52 | 3.59 | 2.55 | 3.54 |
| The Met | 0.58 | 5.45 | 8.10 | 3.15 | 3.42 | -2.25 | 6.57 | 4.32 | 3.01 | 2.59 | 3.12 |
WATERFALL_STEPS = [("renewals_pp", "Renouvel-\nlements", "tab:blue"), ("turnovers_pp", "Relocations", "tab:cyan"), ("concessions_pp", "Concession adjustment / Ajustement de concession", "tab:red")] fig, axes = plt.subplots(2, 3, figsize=(14, 7), sharey=True) for ax, (building, row) in zip(axes.ravel(), why.iterrows()): level = 0.0 for position, (column, label, color) in enumerate(WATERFALL_STEPS): ax.bar(position, row[column], bottom=level, color=color) ax.text(position, level + row[column] / 2, f"{row[column]:+.1f}", ha="center", va="center", color="white", fontweight="bold") level += row[column] ax.bar(len(WATERFALL_STEPS), row.effective_pct, color="black") ax.text(len(WATERFALL_STEPS), row.effective_pct / 2, f"{row.effective_pct:.1f} %", ha="center", va="center", color="white", fontweight="bold") ax.set_xticks(range(len(WATERFALL_STEPS) + 1), [label for _, label, _ in WATERFALL_STEPS] + ["Effectif\n2026"]) ax.set_title(building) ax.axhline(0, color="grey", lw=0.8) axes[0, 0].set_ylabel("points de pourcentage") axes[1, 0].set_ylabel("points de pourcentage") fig.suptitle("Ce qui fait la hausse 2026 de chaque immeuble", fontsize=13) plt.tight_layout() plt.show()
En mots, immeuble par immeuble. Le texte ci-dessous est généré à partir des chiffres de la décomposition et du fichier de contexte public, pour qu'il reste toujours cohérent avec eux.
from IPython.display import Markdown FLOOD_ZONE_NEAR_M = 250 # en deçà, la proximité d'une zone inondable mérite d'être signalée building_context = pd.read_csv("building_context.csv").set_index("building") def fr_number(value, decimals=1): return f"{value:,.{decimals}f}".replace(",", " ").replace(".", ",") def building_story(building, row, context): lines = [f"**{building} : {fr_number(row.effective_pct)} % effectif** ({fr_number(row.face_pct)} % contractuel).", f"Sur papier, les loyers montent de {fr_number(row.face_pct)} % : environ {fr_number(PERCENT * row.renewal_share, 0)} % des baux attendus " f"sont des renouvellements (prévus à +{fr_number(row.renewal_face_pct)} %) et le reste des relocations (+{fr_number(row.turnover_face_pct)} %). " f"Les concessions en reprennent {fr_number(-row.concessions_pp)} {'point' if -row.concessions_pp < 2 else 'points'}.", f"Repères : en {LAST_OBSERVED_YEAR}, ses loyers effectifs à unité identique ont augmenté de {fr_number(row.realised_2025_effective_pct)} %; " f"sa tendance de long terme est de {fr_number(row.trend_ANCHOR_pct)} % par an."] notes = [f"à {fr_number(context.rapid_transit_distance_m, 0)} m de {context.nearest_rapid_transit.replace('(metro)', '(métro)')}", f"score d'emplacement {context.location_score_0_100}/100"] if pd.notna(context.units_permitted_2km_2023_2025): notes.append(f"{fr_number(context.units_permitted_2km_2023_2025, 0)} logements autorisés à moins de 2 km en 2023–2025 " "(offre nouvelle à surveiller : plus d'offre tend à augmenter les concessions)") else: notes.append("permis de construire non publiés par la ville") if context.flood_zone_distance_m < FLOOD_ZONE_NEAR_M: notes.append(f"à {context.flood_zone_distance_m} m d'une zone inondable cartographiée") if context.heat_island is True or str(context.heat_island) == "True": notes.append("îlot de chaleur urbain") lines.append("Contexte public (non utilisé dans le calcul) : " + "; ".join(notes) + ".") return "\n\n".join(lines) display(Markdown("\n\n---\n\n".join(building_story(building, row, building_context.loc[building]) for building, row in why.iterrows())))
Westpark : 5,1 % effectif (7,3 % contractuel).
Sur papier, les loyers montent de 7,3 % : environ 62 % des baux attendus sont des renouvellements (prévus à +6,1 %) et le reste des relocations (+9,3 %). L'ajustement de concession (écart entre la prévision effective et la prévision contractuelle) en reprend 2,2 points.
Repères : en 2025, ses loyers effectifs à unité identique ont augmenté de 3,8 %; sa tendance de long terme est de 3,8 % par an.
Contexte public (non utilisé dans le calcul) : à 1 143 m de Fairview-Pointe-Claire (REM); score d'emplacement 43/100; permis de construire non publiés par la ville.
Saint-Elzear : 5,0 % effectif (6,0 % contractuel).
Sur papier, les loyers montent de 6,0 % : environ 60 % des baux attendus sont des renouvellements (prévus à +5,5 %) et le reste des relocations (+6,8 %). L'ajustement de concession (écart entre la prévision effective et la prévision contractuelle) en reprend 1,0 point.
Repères : en 2025, ses loyers effectifs à unité identique ont augmenté de 4,6 %; sa tendance de long terme est de 3,2 % par an.
Contexte public (non utilisé dans le calcul) : à 4 566 m de Montmorency (métro); score d'emplacement 19/100; 2 173 logements autorisés à moins de 2 km en 2023–2025 (offre nouvelle à surveiller : plus d'offre tend à augmenter les concessions).
Daniel-Johnson : 4,9 % effectif (6,1 % contractuel).
Sur papier, les loyers montent de 6,1 % : environ 60 % des baux attendus sont des renouvellements (prévus à +5,2 %) et le reste des relocations (+7,3 %). L'ajustement de concession (écart entre la prévision effective et la prévision contractuelle) en reprend 1,1 point.
Repères : en 2025, ses loyers effectifs à unité identique ont augmenté de 4,4 %; sa tendance de long terme est de 3,0 % par an.
Contexte public (non utilisé dans le calcul) : à 2 127 m de Montmorency (métro); score d'emplacement 58/100; 3 944 logements autorisés à moins de 2 km en 2023–2025 (offre nouvelle à surveiller : plus d'offre tend à augmenter les concessions); îlot de chaleur urbain.
Levesque : 4,7 % effectif (5,7 % contractuel).
Sur papier, les loyers montent de 5,7 % : environ 55 % des baux attendus sont des renouvellements (prévus à +5,4 %) et le reste des relocations (+6,2 %). L'ajustement de concession (écart entre la prévision effective et la prévision contractuelle) en reprend 1,0 point.
Repères : en 2025, ses loyers effectifs à unité identique ont augmenté de 3,2 %; sa tendance de long terme est de 2,9 % par an.
Contexte public (non utilisé dans le calcul) : à 2 047 m de Bois-Franc (REM); score d'emplacement 32/100; 3 692 logements autorisés à moins de 2 km en 2023–2025 (offre nouvelle à surveiller : plus d'offre tend à augmenter les concessions); à 54 m d'une zone inondable cartographiée.
Le Carlyle : 4,5 % effectif (6,4 % contractuel).
Sur papier, les loyers montent de 6,4 % : environ 63 % des baux attendus sont des renouvellements (prévus à +5,8 %) et le reste des relocations (+7,5 %). L'ajustement de concession (écart entre la prévision effective et la prévision contractuelle) en reprend 1,9 point.
Repères : en 2025, ses loyers effectifs à unité identique ont augmenté de 3,5 %; sa tendance de long terme est de 2,5 % par an.
Contexte public (non utilisé dans le calcul) : à 526 m de De La Savane (métro); score d'emplacement 59/100; 1 077 logements autorisés à moins de 2 km en 2023–2025 (offre nouvelle à surveiller : plus d'offre tend à augmenter les concessions).
The Met : 4,3 % effectif (6,6 % contractuel).
Sur papier, les loyers montent de 6,6 % : environ 58 % des baux attendus sont des renouvellements (prévus à +5,4 %) et le reste des relocations (+8,1 %). L'ajustement de concession (écart entre la prévision effective et la prévision contractuelle) en reprend 2,2 points.
Repères : en 2025, ses loyers effectifs à unité identique ont augmenté de 3,1 %; sa tendance de long terme est de 2,6 % par an.
Contexte public (non utilisé dans le calcul) : à 479 m de Parliament (O-Train); score d'emplacement 86/100; 526 logements autorisés à moins de 2 km en 2023–2025 (offre nouvelle à surveiller : plus d'offre tend à augmenter les concessions).
9. Un modèle de régression fait-il mieux? XGBoost et Ridge, testés et refusésLa consigne permet un modèle de régression s'il est justifié. Nous l'avons testé honnêtement, avec une règle écrite avant la première exécution :
9. Un modèle de régression fait-il mieux? XGBoost et Ridge, testés et refusés
La consigne permet un modèle de régression s'il est justifié. Nous l'avons testé honnêtement, avec une règle écrite avant la première exécution :
Détails
- XGBoost (profondeur 3, 300 arbres, taux 0,05, sous-échantillonnage 0,8) et Ridge (alpha 10 sur variables standardisées), entraînés au niveau de la paire, avec uniquement des informations connues à l'origine : type d'événement, province, chambres, intervalle, écart de concession précédent, mois de début, ancienneté de l'immeuble, position du loyer dans l'immeuble, et moyennes de l'année précédente (cellule et portefeuille).
- Mêmes origines, même roster, même agrégation et même cible que notre méthode.
- Règle de remplacement : un modèle ne remplace notre méthode que si son erreur moyenne est plus faible que la nôtre et que celle de la moyenne historique, sur 2020–2025 et sur 2023–2025, sans pire année plus mauvaise que la nôtre. Aucun hyperparamètre n'a été ajusté sur ces années.
Les contributions SHAP expliquent la prévision 2026 de XGBoost, variable par variable. Le modèle n'étant pas retenu, aucun fichier de modèle entraîné n'est livré
(les paramètres de notre méthode sont dans ForecastSpec, section 4b).
from sklearn.linear_model import Ridge from sklearn.pipeline import make_pipeline from sklearn.preprocessing import StandardScaler RANDOM_SEED = 20_260_101 XGB_PARAMS = dict(max_depth=3, n_estimators=300, learning_rate=0.05, subsample=0.8, colsample_bytree=0.8, min_child_weight=10, reg_lambda=1.0, random_state=RANDOM_SEED, n_jobs=1) RIDGE_ALPHA = 10.0 LAG_FEATURES = ["lag_cell_face", "lag_cell_eff", "lag_cell_gap", "lag_port_face", "lag_port_gap"] MODEL_FEATURES = ["is_renewal", "is_ontario", "bedrooms", "interval_years", "prior_gap", "start_month", "property_age", "rent_position"] + LAG_FEATURES def lagged_means(known_pairs): # Moyennes de l'année précédente, par province × événement et pour le portefeuille. cell = known_pairs.groupby(["event_year", "province", "event_type"]).agg( lag_cell_face=("face_growth", "mean"), lag_cell_eff=("effective_growth", "mean"), lag_cell_gap=("new_gap", "mean")).reset_index() portfolio = known_pairs.groupby("event_year").agg(lag_port_face=("face_growth", "mean"), lag_port_gap=("new_gap", "mean")).reset_index() cell["event_year"] += 1 portfolio["event_year"] += 1 return cell, portfolio def model_features(rows, known_pairs): cell, portfolio = lagged_means(known_pairs) first_year = known_pairs.groupby("property_code")["event_year"].min() mean_prior_rent = known_pairs.groupby("property_code")["prior_face_rent"].mean() features = rows.merge(cell, on=["event_year", "province", "event_type"], how="left").merge(portfolio, on="event_year", how="left") features["is_renewal"] = features["event_type"].eq("renewal").astype(float) features["is_ontario"] = features["province"].eq("Ontario").astype(float) features["property_age"] = features["event_year"] - features["property_code"].map(first_year).fillna(features["event_year"]) features["rent_position"] = features["prior_face_rent"] / features["property_code"].map(mean_prior_rent).fillna(features["prior_face_rent"]) features["bedrooms"] = features["bedrooms"].astype(float) return features def roster_rows(bundle): # Une ligne par unité du roster et par type d'événement, pondérée par la part attendue. roster = bundle.roster[bundle.roster["comparable"]].reset_index(drop=True) shares = event_mix(bundle).set_index(["property_code", "event_type"])["event_share"] return pd.concat([pd.DataFrame({"unit": roster.index, "event_year": bundle.target_year, "province": roster["province"], "event_type": event_type, "property_code": roster["property_code"], "bedrooms": roster["bedrooms"], "interval_years": roster["expected_years"], "prior_gap": roster["prior_gap"], "start_month": (roster["lease_end"] + pd.Timedelta(days=1)).dt.month, "prior_face_rent": roster["face_rent"], "share": [shares[(code, event_type)] for code in roster["property_code"]]}) for event_type in ("renewal", "turnover")], ignore_index=True) def fit_predict(kind, train, test): target = clip_for_training(train["effective_growth"], "effective_growth").to_numpy() if kind == "xgboost": regressor = xgboost.XGBRegressor(**XGB_PARAMS) regressor.fit(train[MODEL_FEATURES].to_numpy(float), target) return regressor.predict(test[MODEL_FEATURES].to_numpy(float)), regressor has_lags = train[LAG_FEATURES].notna().all(axis=1) usable = [feature for feature in MODEL_FEATURES if train.loc[has_lags, feature].notna().any()] training_means = train.loc[has_lags, usable].mean() regressor = make_pipeline(StandardScaler(), Ridge(alpha=RIDGE_ALPHA)) regressor.fit(train.loc[has_lags, usable].fillna(training_means).to_numpy(float), target[has_lags.to_numpy()]) return regressor.predict(test[usable].fillna(training_means).to_numpy(float)), regressor def forecast_challenger(bundle, kind): known_pairs = bundle.pairs train = model_features(known_pairs.assign(start_month=known_pairs["lease_start"].dt.month), known_pairs) roster = model_features(roster_rows(bundle), known_pairs) predictions, regressor = fit_predict(kind, train, roster) roster = roster.assign(prediction=predictions) per_unit = roster.groupby("unit").apply(lambda unit: float(np.average(unit["prediction"], weights=unit["share"])), include_groups=False) return {"portfolio": float(per_unit.mean()), "roster": roster, "model": regressor} challenger_rows = [] for year in evaluation_years: bundle = make_bundle(leases, year) challenger_rows.append({"target_year": year, "actual_pct": PERCENT * float(realized(leases, year)["effective_growth"].mean()), "notre_methode_pct": all_folds.set_index("target_year").loc[year, "predicted_pct"], "xgboost_pct": PERCENT * forecast_challenger(bundle, "xgboost")["portfolio"], "ridge_pct": PERCENT * forecast_challenger(bundle, "ridge")["portfolio"], "moyenne_historique_pct": baselines.query("model == 'All history pooled' and target_year == @year").predicted_pct.iloc[0]}) challenger_backtest = pd.DataFrame(challenger_rows) display(challenger_backtest.round(2)) scoreboard_rows = [] for method in ("notre_methode", "xgboost", "ridge", "moyenne_historique"): error = challenger_backtest[f"{method}_pct"] - challenger_backtest["actual_pct"] in_required_window = challenger_backtest["target_year"].isin(REQUIRED_BACKTEST_YEARS) scoreboard_rows.append({"méthode": method, "MAE_2020_2025_pp": error.abs().mean(), "MAE_2023_2025_pp": error[in_required_window].abs().mean(), "pire_année_pp": error.abs().max()}) challenger_scoreboard = pd.DataFrame(scoreboard_rows).set_index("méthode") display(challenger_scoreboard.round(3)) for candidate in ("xgboost", "ridge"): board = challenger_scoreboard replaces = bool(board.loc[candidate, "MAE_2020_2025_pp"] < board.loc[["notre_methode", "moyenne_historique"], "MAE_2020_2025_pp"].min() and board.loc[candidate, "MAE_2023_2025_pp"] < board.loc[["notre_methode", "moyenne_historique"], "MAE_2023_2025_pp"].min() and board.loc[candidate, "pire_année_pp"] <= board.loc["notre_methode", "pire_année_pp"]) print(f"{candidate} remplace notre méthode : {replaces}")
| target_year | actual_pct | notre_methode_pct | xgboost_pct | ridge_pct | moyenne_historique_pct | |
|---|---|---|---|---|---|---|
| 0 | 2020 | 2.77 | 1.76 | 2.06 | 2.22 | 1.75 |
| 1 | 2021 | 3.75 | 2.74 | 2.94 | 1.63 | 2.33 |
| 2 | 2022 | 4.72 | 3.75 | 4.54 | 5.87 | 2.87 |
| 3 | 2023 | 2.18 | 4.47 | 4.91 | 6.52 | 3.38 |
| 4 | 2024 | 1.95 | 2.62 | 3.11 | -0.15 | 3.09 |
| 5 | 2025 | 3.83 | 2.93 | 4.61 | 5.53 | 2.76 |
| MAE_2020_2025_pp | MAE_2023_2025_pp | pire_année_pp | |
|---|---|---|---|
| méthode | |||
| notre_methode | 1.142 | 1.289 | 2.294 |
| xgboost | 1.063 | 1.559 | 2.733 |
| ridge | 1.994 | 2.714 | 4.343 |
| moyenne_historique | 1.282 | 1.138 | 1.841 |
xgboost remplace notre méthode : False ridge remplace notre méthode : False
# Pourquoi XGBoost prévoit ce chiffre pour 2026 : contributions SHAP (TreeSHAP) pondérées comme la prévision. TOP_FEATURES_SHOWN = 10 xgb_2026 = forecast_challenger(make_bundle(leases, TARGET_YEAR), "xgboost") roster_2026 = xgb_2026["roster"] contributions = xgb_2026["model"].get_booster().predict( xgboost.DMatrix(roster_2026[MODEL_FEATURES].to_numpy(float), feature_names=MODEL_FEATURES), pred_contribs=True) row_weights = roster_2026["share"].to_numpy() / roster_2026["unit"].nunique() shap_2026 = (pd.DataFrame({"feature": MODEL_FEATURES, "contribution_to_2026_pp": PERCENT * (contributions[:, :-1] * row_weights[:, None]).sum(axis=0), "mean_abs_pp": PERCENT * np.abs(contributions[:, :-1]).mean(axis=0)}) .sort_values("mean_abs_pp", ascending=False).reset_index(drop=True)) display(shap_2026.round(3)) top_features = shap_2026.head(TOP_FEATURES_SHOWN).iloc[::-1] plt.figure(figsize=(8, 4)) plt.barh(top_features.feature, top_features.contribution_to_2026_pp) plt.title("XGBoost 2026 : contribution SHAP moyenne par variable (points)") plt.xlabel("points de pourcentage") plt.tight_layout() plt.show() print(f"2026 — XGBoost : {PERCENT * xgb_2026['portfolio']:.2f} % | notre méthode : {estimate_pct:.2f} %")
| feature | contribution_to_2026_pp | mean_abs_pp | |
|---|---|---|---|
| 0 | prior_gap | 2.405 | 3.427 |
| 1 | lag_port_gap | -0.464 | 0.463 |
| 2 | lag_cell_face | 0.454 | 0.454 |
| 3 | lag_port_face | -0.354 | 0.362 |
| 4 | property_age | 0.036 | 0.312 |
| 5 | lag_cell_gap | -0.275 | 0.280 |
| 6 | rent_position | 0.079 | 0.267 |
| 7 | start_month | -0.012 | 0.247 |
| 8 | interval_years | 0.145 | 0.226 |
| 9 | is_renewal | 0.014 | 0.164 |
| 10 | lag_cell_eff | 0.121 | 0.120 |
| 11 | is_ontario | -0.026 | 0.059 |
| 12 | bedrooms | -0.002 | 0.036 |
2026 — XGBoost : 5.15 % | notre méthode : 4.80 %
Lecture. Les tableaux appliquent mécaniquement la règle déclarée. Aucun des deux modèles ne remplace notre méthode. XGBoost fait un peu mieux sur 2020–2025, mais moins bien sur la fenêtre exigée 2023–2025 et sur sa pire année; Ridge fait moins bien partout. Avec environ 3 000 paires et six années de régimes différents, un modèle plus flexible apprend surtout le bruit d'années déjà vues. Le SHAP montre que XGBoost s'appuie d'abord sur l'écart de concession du bail précédent : une unité louée avec une grosse concession « récupère » une partie de cette concession au bail suivant. C'est le mécanisme que notre composante BRIDGE modélise explicitement, unité par unité. Le résultat refusé reste affiché, car il dit ce que les données ne soutiennent pas.
Détails
D'autres pistes ont été évaluées de la même façon et refusées : variables publiques (inoccupation, offre, population, chômage, taux) ajoutées à ces modèles,
signal du loyer affiché, propension au renouvellement, tendance amortie des concessions, simulation Monte Carlo du parc et passage du TAL.
Leur code est dans les modules Python facultatifs joints (challenger.py, behavior.py, simulation.py, drivers.py), qui ne sont pas nécessaires pour exécuter ce notebook.
10. Au-delà de la consigne : ce que nous avons construit en plusLa consigne rend l'application facultative. Nous livrons un prototype facultatif (04Prototypefacultatifcockpitsimulation.zip : cockpit et simulation)
10. Au-delà de la consigne : ce que nous avons construit en plus
La consigne rend l'application facultative. Nous livrons un prototype facultatif (04_Prototype_facultatif_cockpit_simulation.zip : cockpit et simulation)
et un film d'une minute avec la présentation (03_Film_presentation_1min.mp4). Aucun n'est nécessaire pour évaluer ce notebook, et aucun ne contient
de donnée du CRM : ils n'utilisent que les résultats agrégés par immeuble présentés ici. Cliquez sur chaque ligne à triangle pour voir une capture.
1. Cockpit de tarification (cockpit/index.html dans le prototype, s'ouvre d'un double-clic) : la carte du portefeuille avec la prévision de chaque immeuble,
un simulateur de renouvellement (loyer, durée, mois gratuits, régime TAL ou Ontario) et le calendrier des baux qui expirent en 2026.
Voir la capture : carte du portefeuille et prévision par immeuble
2. Simulation de résidents (simulation/index.html dans le prototype) : 150 résidents fictifs, générés par IA, réagissent à des chocs de marché
(baisse de l'immigration, vague de nouvelles tours, nouvelle station du REM). C'est une démonstration de mécanismes, pas une prévision :
la prévision testée reste 4,8 %, et la page l'affiche en permanence.
Voir la capture : réaction des résidents à une baisse de l'immigration
3. Film de présentation d'une minute (03_Film_presentation_1min.mp4) : la démarche, du piège de la médiane jusqu'au 4,8 %.
Voir une image du film
11. Hypothèses, limites, et comment améliorer sans ajouter de bruit1. Le prochain bail d'une unité du roster commence le lendemain de la fin du bail actuel (vacance et départs anticipés sont des risques non modélisés).
11. Hypothèses, limites, et comment améliorer sans ajouter de bruit
Hypothèses énoncées.
- Le prochain bail d'une unité du roster commence le lendemain de la fin du bail actuel (vacance et départs anticipés sont des risques non modélisés).
- La part de renouvellements de 2026 ressemble à celle des trois dernières années de chaque immeuble, rétrécie vers sa province.
- L'écart de concession des nouveaux baux de 2026 ressemble à celui de 2025 (BRIDGE); les scénarios ±2 points en montrent la sensibilité.
- Les paramètres (
ForecastSpec) sont hérités d'une révision antérieure et n'ont pas été optimisés sur 2023–2025. sRentEffectiveest exact tel que fourni; le registre des concessions sert à le vérifier, pas à le remplacer.
Détails
Limites. Même unité ne garantit pas une qualité constante après rénovation. L'extrait actuel ne prouve pas que les valeurs passées n'ont jamais été corrigées : la reconstruction aux origines est une simulation. Six années de backtest et un changement de régime en 2024–2025 rendent toute précision fine illusoire.
Pour améliorer : conserver de vrais instantanés datés; horodater offres et concessions; ajouter occupation, vacance, statut de rénovation et première occupation de The Met (pour savoir si la ligne directrice de l'Ontario s'applique); tester une seule extension à la fois, avec une règle fixée d'avance, et la confirmer sur des années non encore vues (2026).
Références
Données publiques (valeurs, périodes, dates de publication et liens exacts dans external_context.csv et external_postorigin_2026.csv) :
- SCHL — Enquête sur les logements locatifs, RMR de Montréal et d'Ottawa : https://www.cmhc-schl.gc.ca
- Statistique Canada — IPC, composante loyers, Québec et Ontario (tableau 18-10-0004-01) : https://www150.statcan.gc.ca
- Tribunal administratif du logement — calcul annuel de l'augmentation de loyer : https://www.tal.gouv.qc.ca
- Ontario — ligne directrice sur l'augmentation des loyers : https://www.ontario.ca/page/rent-increase-guideline
Détails
Méthode : scikit-learn, Common pitfalls and recommended practices (fuite de données et sélection) : https://scikit-learn.org/stable/common_pitfalls.html ·
Hyndman & Athanasopoulos, Forecasting: Principles and Practice, validation chronologique : https://otexts.com/fpp3/tscv.html ·
Lundberg et al., TreeSHAP, via pred_contribs de XGBoost : https://xgboost.readthedocs.io
Outils d'IA utilisés (permis par la consigne, cités ici) : Codex (OpenAI) a aidé à auditer les composants, corriger le protocole d'origine et préparer le code et les tests. Claude Code (Anthropic; modèles Claude Opus 5.5 et Sonnet 5.5) a aidé à vérifier les sources publiques, écrire les analyses des sections 5c, 7.6, 8 et 9, et réorganiser ce notebook autour du starter. Chaque valeur publique a été revérifiée sur sa source officielle, et chaque calcul est couvert par un test. L'équipe reste responsable des choix, de leur validation et de la présentation.
effective increase forecast for 2026 · 6.38% in contractual rent · from 4.3% (The Met) to 5.1% (Westpark) by building
The full notebook, step by step. Click a step to open it.
SummaryAnswer: 4.80% effective same-unit increase for 2026, that is, what Équinoxe will collect on top of today's rent, after free months,
Équinoxe Collection — 2026 rent increase
One-page summary
Answer: 4.80% effective same-unit increase for 2026, that is, what Équinoxe will collect on top of today's rent, after free months, on the same unit. In contractual (face) rent (the rent written in the lease): 6.38%. By building: from 4.3% (The Met, Ottawa) to 5.1% (Westpark, Pointe-Claire).
What we found, in five points
- The annual median misleads. It shows +10.8% in 2023, but that is mostly three upscale buildings arriving in the portfolio. The same unit compared with itself rose by only about 2.2% that year (sections 3 and 4).
- Concessions eat into the increase. In 2025, the contractual (face) rents of the same units rise by 7.6%, but collected rent by only 3.8%: the concession gap on new leases goes from 3.2% of rent in 2023 to 9.2% in 2025, driven mostly by PromoPay (section 5).
- Renewals and turnovers do not move the same way, and Ontario does not follow Quebec's rules: each building is forecast by event type and by province, never applying one province's rules to another (sections 6 and 7.3).
- The forecast is validated on the past: replayed as at 31 December of each year from 2020 to 2025, its average error (about 1.1 points) is lower than that of three simple reference methods. Over 2023–2025, a plain historical average does slightly better; we show this frankly (section 7.5).
- Every figure is explained: for each building, we show what pushes the rent up or down, with the public context around it (construction, transit, risks) (section 8b). XGBoost and Ridge were tested and do not do better in a stable way (section 9).
Read it in five minutes: this summary → the chart in section 4b (median vs same unit) → section 5c (concessions) → section 7.4 (the figure) → section 8b (why, building by building) → section 7.5 (validation).
The exact value is computed by estimate_2026() in section 7.4, and the validation on 2023, 2024 and 2025 is in section 7.5.
This notebook starts from the supplied starter.ipynb and keeps its structure and code cells: loading, reading the columns, the obvious approach,
comparing a unit with itself, contractual (face) vs collected rent, renewals vs turnovers, then "Your turn" with estimate_2026() and backtest() completed.
All of the method's code is in this notebook: no imports from the team's own files.
To run it: place the four original CSV files next to this notebook (as for the starter), together with the three supplied public files (external_context.csv, external_postorigin_2026.csv, building_context.csv),
install requirements.txt, then Restart Kernel / Run All. Run time: under a minute.
| Grading criterion | Section |
|---|---|
| Definition of the increase (10) | 0 and 7.1 |
| Data analysis and mix effect (15) | 1b, 1c, 2, 3 |
| Same-unit matching (15) | 4 |
| Concessions (10) | 5 |
| Renewals vs turnovers, Ontario (10) | 6, 7.6 |
| External data (15) | 7.2, 7.6 |
| Forecast and backtest (15) | 7.3 to 7.7, 9 |
| Bonus: building × bedrooms, scenarios, dashboard | 7.7, 8, 8b |
| Beyond the brief (optional prototype) | 10 |
Confidentiality: no CRM row is displayed. The outputs are aggregates (counts, averages, charts).
0. The question we are answeringDefinition adopted: effective same-unit increase. For each unit (key sPropCode + sUnitCode), we compare the new lease with the
0. The question we are answering
Definition adopted: effective same-unit increase. For each unit (key sPropCode + sUnitCode), we compare the new lease with the
immediately preceding lease of the same unit, using the effective rent (sRentEffective, after concessions), annualise the change, and take the
average of these changes over all pairs whose new lease starts in 2026. Renewals and turnovers are both included, each with its expected weight.
Throughout this notebook, "collected" means the supplied sRentEffective field: the rent after the concessions recorded in the data (this is how the starter describes it). It is not a ledger of actual cash receipts: the extract contains no payments and no arrears.
Details
Why this choice rather than the other definitions offered in the starter:
| Possible definition | Why we do not use it as the headline figure |
|---|---|
| Annual median of the whole portfolio | Mostly measures mix: the arrival of three upscale buildings creates a 10.8% "increase" in 2023 that did not happen (section 3). |
Same unit, contractual (face) rent (sRent) |
Ignores free months. Since 2024 the gap between contractual (face) and collected rent has been widening (section 5): contractual (face) rent overstates what Équinoxe collects. Shown alongside (6.38%). |
| Renewals only / turnovers only | Useful for setting rents, but a revenue budget needs both. The two are separated in sections 6 and 7.3 and recombined by their expected share. |
| Total revenue or NOI | Impossible with this extract: no occupancy or vacancy data (section 2). |
Average rather than median: the average over pairs recomposes exactly from the segments (building × event type), which the median does not allow. The median is still shown as a robustness diagnostic.
1. Load the dataThe four source files stay untouched: every transformation is done in memory, on copies. Table names and library versions are printed for reproducibility.
1. Load the data
The four source files stay untouched: every transformation is done in memory, on copies. Table names and library versions are printed for reproducibility.
import os import platform from dataclasses import dataclass from pathlib import Path import matplotlib import matplotlib.pyplot as plt import numpy as np import pandas as pd import sklearn import xgboost from IPython.display import display pd.set_option("display.width", 160) pd.set_option("display.max_columns", 40) TARGET_YEAR = 2026 # the year to forecast LAST_OBSERVED_YEAR = TARGET_YEAR - 1 # the extract stops at 2025-12-31 REQUIRED_BACKTEST_YEARS = (2023, 2024, 2025) # required by the brief PERCENT = 100.0 # fraction -> percentage DAYS_PER_YEAR = 365.25 # annualisation of intervals between leases # The original CSV files go next to the notebook, as for the starter. # Optional environment variable if you prefer to keep them elsewhere. DATA_DIR = Path(os.environ.get("EQUINOXE_DATA_DIR", ".")) SOURCE_FILES = {"listings": "equinoxe_listings.csv", "leases": "equinoxe_lease_history.csv", "concessions": "equinoxe_concessions.csv", "asking": "equinoxe_asking_history.csv"} missing_files = [file_name for file_name in SOURCE_FILES.values() if not (DATA_DIR / file_name).is_file()] if missing_files: raise FileNotFoundError(f"Place the original CSV files next to the notebook (or set EQUINOXE_DATA_DIR). Missing: {missing_files}") source_tables = {table: pd.read_csv(DATA_DIR / file_name) for table, file_name in SOURCE_FILES.items()} listings, leases = source_tables["listings"], source_tables["leases"] concessions, asking = source_tables["concessions"], source_tables["asking"] for name, table in source_tables.items(): print(f"{name:<12} {len(table):>6,} rows {table.shape[1]:>2} columns") print({"python": platform.python_version(), "pandas": pd.__version__, "numpy": np.__version__, "matplotlib": matplotlib.__version__, "scikit-learn": sklearn.__version__, "xgboost": xgboost.__version__})
listings 1,061 rows 29 columns
leases 4,302 rows 35 columns
concessions 3,560 rows 18 columns
asking 3,857 rows 14 columns
{'python': '3.12.7', 'pandas': '2.2.3', 'numpy': '2.3.5', 'matplotlib': '3.11.2', 'scikit-learn': '1.9.1', 'xgboost': '2.1.4'}
As in the starter, the dates are converted once, at the start, and the lease start year is added.
Instead of displaying CRM rows (leases.head()), we display the column profile: type, missing values and number of distinct values.
LEASE_DATE_COLUMNS = ["sLeaseFrom", "sLeaseTo", "sSignDate", "sAvailable"] for column in LEASE_DATE_COLUMNS: if column in leases.columns: leases[column] = pd.to_datetime(leases[column], errors="coerce") leases["year"] = leases.sLeaseFrom.dt.year asking["date"] = pd.to_datetime(asking.sMonth + "-01") lease_column_profile = pd.DataFrame({"type": leases.dtypes.astype(str), "missing_values": leases.isna().sum(), "distinct_values": leases.nunique()}) lease_column_profile
| type | missing_values | distinct_values | |
|---|---|---|---|
| hProperty | int64 | 0 | 9 |
| hUnit | int64 | 0 | 1061 |
| hBuilding | int64 | 0 | 6 |
| sPropCode | object | 0 | 9 |
| sUnitCode | object | 0 | 519 |
| sBuilding | object | 0 | 6 |
| sCity | object | 0 | 4 |
| sState | object | 0 | 2 |
| sAddr1 | object | 0 | 6 |
| sLatitude | float64 | 0 | 6 |
| sLongitude | float64 | 0 | 6 |
| sLeaseTerm | object | 0 | 4 |
| sPropType | object | 0 | 1 |
| sRent | int64 | 0 | 666 |
| sBeds | int64 | 0 | 4 |
| sBaths | float64 | 0 | 3 |
| sSqft | int64 | 0 | 199 |
| sLink | object | 0 | 1061 |
| sFurnishing | object | 0 | 1 |
| sAvailable | datetime64[ns] | 0 | 1302 |
| sSmoking | object | 0 | 1 |
| sCats | bool | 0 | 1 |
| sDogs | bool | 0 | 1 |
| sRentEffective | float64 | 0 | 3178 |
| sConcession | int64 | 0 | 2 |
| sSite | object | 0 | 6 |
| sUnitSubtype | object | 0 | 4 |
| sUnitType | object | 0 | 11 |
| sSignDate | datetime64[ns] | 0 | 2067 |
| sLeaseFrom | datetime64[ns] | 0 | 1302 |
| sLeaseTo | datetime64[ns] | 0 | 1341 |
| sTermMonths | float64 | 0 | 4 |
| sTermSeq | int64 | 0 | 8 |
| sRenewal | int64 | 0 | 2 |
| sFloor | int64 | 0 | 27 |
| year | int32 | 0 | 9 |
1b. Reading the columns: `h` fields and `s` fieldsYardi convention recalled by the starter: the h (handle) columns are numeric identifiers used for joins;
1b. Reading the columns: h fields and s fields
Yardi convention recalled by the starter: the h (handle) columns are numeric identifiers used for joins;
the s (stored value) columns are the readable values used for display and grouping. Join on h, label with s.
The s prefix does not mean "text": sRent and sSqft are numeric.
Details
For the unit, we use the recommended key sPropCode + sUnitCode (never sSite + sUnitCode, see section 4).
Property codes can designate phases of the same building; the results by building are grouped by sBuilding.
Charge codes (sChargeCode) are truncated to eight characters by Yardi (Freeothe, Referenc). We classify them as follows:
sChargeCode |
What it is | Classification |
|---|---|---|
PromoPay, freerent |
Discount on the rent | Rent |
freepark, freelock, freeappl |
Free parking, locker, appliances | Service (not rent) |
Freeothe, Indem, Referenc |
Other, indemnity, referral | Other, kept separate |
1c. What the columns really represent, and how we rename themThe brief warns that the descriptions do not always match the data. Our checks:
1c. What the columns really represent, and how we rename them
The brief warns that the descriptions do not always match the data. Our checks:
Details
PromoPayis described as a monthly amount. The next cell shows that in nearly 9 cases out of 10 it is a single entry per lease (sMonths = 1), with a median amount of about 70% of one month's rent. It is therefore a one-off credit to be spread over the lease term, not a discount applied every month. Taken literally, it would suggest that residents live almost for free.sAmountis negative for a benefit. The total credit of an entry is−sAmount × sMonths.sRentEffectiveis already the rent after concessions, amortised over the lease. We use it as is and do not subtract concessions a second time. Section 5 checks that it is always less than or equal tosRent, and rebuilds the rent from the concession ledger.sTermSeqnumbers the successive leases of the same unit: it is what defines the "previous lease".sRenewalequals 1 for a sitting tenant who renews and 0 for a turnover.- Asking rent (
asking): an advertised price, not a signed one.
The columns used by the model are renamed once, with the COLUMN_RENAMES dictionary below.
The source files are never modified.
UNIT_KEY = ["sPropCode", "sUnitCode"] # safe unit key (never sSite + sUnitCode) PROMO_CODE = "PromoPay" COLUMN_RENAMES = {"sPropCode": "property_code", "sUnitCode": "unit_code", "sState": "province", "sBeds": "bedrooms", "sSignDate": "sign_date", "sLeaseFrom": "lease_start", "sLeaseTo": "lease_end", "sTermSeq": "term_seq", "sRent": "face_rent", "sRentEffective": "effective_rent"} def normalise_unit_key(table): # Copy with a cleaned unit key: whitespace removed, property code in lower case. keyed = table.copy() keyed["sPropCode"] = keyed["sPropCode"].astype(str).str.strip().str.lower() keyed["sUnitCode"] = keyed["sUnitCode"].astype(str).str.strip() return keyed promo_entries = normalise_unit_key(concessions[concessions.sChargeCode.eq(PROMO_CODE)]) unit_listed_rent = normalise_unit_key(listings)[UNIT_KEY + ["sRent"]] promo_entries = promo_entries.merge(unit_listed_rent, on=UNIT_KEY, how="left", validate="many_to_one") promo_check = pd.Series({ "PromoPay entries": len(promo_entries), "share with sMonths = 1": (promo_entries.sMonths == 1).mean(), "share with negative sAmount": (promo_entries.sAmount < 0).mean(), "median credit / unit rent": (-promo_entries.sAmount / promo_entries.sRent).median(), }) display(promo_check.round(3)) COLUMN_MEANINGS = { "sPropCode": "property code (one phase of a building); half of the unit key", "sUnitCode": "unit number within the property; the other half of the unit key", "sState": "province: Quebec (TAL) or Ontario (guideline)", "sBeds": "number of bedrooms", "sSignDate": "signature date: used to know what was known at a given date", "sLeaseFrom": "lease start: date of the measured event", "sLeaseTo": "contractual lease end: used to anticipate the leases expiring in 2026", "sTermSeq": "rank of the lease in the unit's history: defines the previous lease", "sRent": "monthly contractual (face) rent, the rent written in the lease", "sRentEffective": "monthly effective rent, after concessions amortised over the lease: what is collected", } column_dictionary = pd.DataFrame({"new name": COLUMN_RENAMES, "what the column represents": COLUMN_MEANINGS}) column_dictionary.index.name = "original column" column_dictionary
PromoPay entries 1494.000 share with sMonths = 1 0.882 share with negative sAmount 1.000 median credit / unit rent 0.711 dtype: float64
| new name | what the column represents | |
|---|---|---|
| original column | ||
| sPropCode | property_code | property code (one phase of a building); half ... |
| sUnitCode | unit_code | unit number within the property; the other hal... |
| sState | province | province: Quebec (TAL) or Ontario (guideline) |
| sBeds | bedrooms | number of bedrooms |
| sSignDate | sign_date | signature date: used to know what was known at... |
| sLeaseFrom | lease_start | lease start: date of the measured event |
| sLeaseTo | lease_end | contractual lease end: used to anticipate the ... |
| sTermSeq | term_seq | rank of the lease in the unit's history: defin... |
| sRent | face_rent | monthly contractual (face) rent, the rent writ... |
| sRentEffective | effective_rent | monthly effective rent, after concessions amor... |
Data contract. Before any measurement, the model checks that the leases meet the assumptions it depends on, and stops otherwise: no empty key, no duplicated or non-integer sequence, consistent dates (signature ≤ start ≤ end), positive rents, effective rent ≤ contractual (face) rent, known province, stable per unit. Nothing is imputed. The validation works on a copy, with the cleaned key.
REQUIRED_LEASE_COLUMNS = {"sPropCode", "sUnitCode", "sState", "sBeds", "sRent", "sRentEffective", "sSignDate", "sLeaseFrom", "sLeaseTo", "sTermSeq", "sRenewal"} KNOWN_PROVINCES = ["Quebec", "Ontario"] LEASE_NUMERIC_COLUMNS = ["sRent", "sRentEffective", "sTermSeq", "sRenewal", "sBeds"] class DataContractError(ValueError): # An input does not meet an assumption the model depends on. pass def validate_leases(lease_table): # Validated copy of the leases: dates and numbers converted, key cleaned, rules checked. if not isinstance(lease_table, pd.DataFrame) or lease_table.empty: raise DataContractError("leases must be a non-empty DataFrame of lease history") missing_columns = REQUIRED_LEASE_COLUMNS - set(lease_table.columns) if missing_columns: raise DataContractError(f"missing columns: {sorted(missing_columns)}") checked = lease_table.copy() for column in ["sSignDate", "sLeaseFrom", "sLeaseTo"]: checked[column] = pd.to_datetime(checked[column], errors="coerce") for column in LEASE_NUMERIC_COLUMNS: checked[column] = pd.to_numeric(checked[column], errors="coerce") if checked[list(REQUIRED_LEASE_COLUMNS)].isna().any().any(): raise DataContractError("missing or unreadable values in the required columns") checked = normalise_unit_key(checked) if checked[UNIT_KEY].eq("").any().any() or not checked["sState"].isin(KNOWN_PROVINCES).all(): raise DataContractError("empty unit key or unknown province") if not np.isfinite(checked[LEASE_NUMERIC_COLUMNS].to_numpy()).all(): raise DataContractError("non-finite numeric value") if not checked["sRenewal"].isin([0, 1]).all(): raise DataContractError("sRenewal must be 0 or 1") if ((checked["sTermSeq"] < 1) | (checked["sTermSeq"] % 1 != 0)).any(): raise DataContractError("sTermSeq must be a positive integer") if checked.duplicated(UNIT_KEY + ["sTermSeq"]).any(): raise DataContractError("duplicated lease sequence for the same unit") if (checked[["sRent", "sRentEffective"]] <= 0).any().any(): raise DataContractError("rents must be positive") if (checked["sRentEffective"] > checked["sRent"]).any(): raise DataContractError("effective rent greater than contractual (face) rent") if ((checked["sLeaseTo"] < checked["sLeaseFrom"]) | (checked["sSignDate"] > checked["sLeaseFrom"])).any(): raise DataContractError("inconsistent lease or signature dates") if checked.groupby(UNIT_KEY)["sState"].nunique().gt(1).any(): raise DataContractError("a unit cannot change province") return checked validated_leases = validate_leases(leases) print(f"Data contract met: {len(validated_leases):,} leases, {validated_leases.groupby(UNIT_KEY).ngroups:,} units.")
Data contract met: 4,302 leases, 1,061 units.
2. What we are looking atSix buildings: Daniel-Johnson, Lévesque and Saint-Elzéar (Laval), Le Carlyle (Mont-Royal), Westpark (Pointe-Claire) and The Met (Ottawa).
2. What we are looking at
Six buildings: Daniel-Johnson, Lévesque and Saint-Elzéar (Laval), Le Carlyle (Mont-Royal), Westpark (Pointe-Claire) and The Met (Ottawa). Everything stops at 31 December 2025; 2026 is what we forecast. Five buildings fall under Quebec (TAL) rules and The Met under Ontario's: the two regimes are treated separately throughout.
Details
This is an extract: a fixed share of the units of each building and each unit type. The distribution is representative, the volumes are not. We therefore compute no occupancy, no vacancy, no absorption and no total rent roll: that would be an artefact of the extract. These data measure the change in rents, and that is what we are asked for.
print(listings.groupby(["sState", "sCity", "sBuilding"]).size().to_string()) print() print(listings.groupby(["sBeds", "sBaths"]) .agg(units=("sRent", "size"), median_rent=("sRent", "median"), median_sqft=("sSqft", "median")) .to_string())
sState sCity sBuilding
Ontario Ottawa The Met 124
Quebec Laval Daniel-Johnson 134
Levesque 84
Saint-Elzear 361
Mont-Royal Le Carlyle 192
Pointe-Claire Westpark 166
units median_rent median_sqft
sBeds sBaths
0 1.0 49 2565.0 550.0
1 1.0 350 1380.0 600.0
2 1.0 358 2440.0 1150.0
2.0 281 2605.0 1265.0
3 1.0 2 2687.5 1597.5
2.0 20 3237.5 1545.0
3.0 1 4115.0 1905.0
3. The obvious approach, and why it misleadsFirst reflex: the median rent of all leases, year by year.
3. The obvious approach, and why it misleads
First reflex: the median rent of all leases, year by year.
FIRST_YEAR_SHOWN = 2019 naive = (leases[leases.year.between(FIRST_YEAR_SHOWN, LAST_OBSERVED_YEAR)] .groupby("year") .agg(leases=("sRent", "size"), median_rent=("sRent", "median"))) naive["yoy_pct"] = (naive.median_rent.pct_change() * PERCENT).round(2) naive
| leases | median_rent | yoy_pct | |
|---|---|---|---|
| year | |||
| 2019 | 328 | 1730.0 | NaN |
| 2020 | 373 | 1775.0 | 2.60 |
| 2021 | 361 | 1850.0 | 4.23 |
| 2022 | 422 | 1912.5 | 3.38 |
| 2023 | 693 | 2120.0 | 10.85 |
| 2024 | 902 | 2215.0 | 4.48 |
| 2025 | 958 | 2305.0 | 4.06 |
According to this table, rents rose by 10.8% in 2023. They did not. An annual median compares different sets of units: it moves as soon as the composition of the portfolio moves. The next two cells look beneath the median: size, unit type and buildings leased each year.
TWO_BEDROOMS = 2 window = leases[leases.year.between(FIRST_YEAR_SHOWN, LAST_OBSERVED_YEAR)] mix = window.groupby("year").agg( leases=("sRent", "size"), median_sqft=("sSqft", "median"), pct_2bed_plus=("sBeds", lambda beds: (beds >= TWO_BEDROOMS).mean() * PERCENT), buildings=("sBuilding", "nunique")) mix["rent_per_sqft"] = (window.assign(psf=window.sRent / window.sSqft) .groupby("year").psf.median()) mix.round(2)
| leases | median_sqft | pct_2bed_plus | buildings | rent_per_sqft | |
|---|---|---|---|---|---|
| year | |||||
| 2019 | 328 | 1177.5 | 78.66 | 3 | 1.48 |
| 2020 | 373 | 1180.0 | 77.75 | 3 | 1.53 |
| 2021 | 361 | 1175.0 | 78.39 | 3 | 1.60 |
| 2022 | 422 | 1160.0 | 72.51 | 3 | 1.71 |
| 2023 | 693 | 1095.0 | 62.63 | 5 | 2.04 |
| 2024 | 902 | 1045.0 | 62.20 | 6 | 2.20 |
| 2025 | 958 | 1025.0 | 60.23 | 6 | 2.33 |
SNAPSHOT_YEAR = 2024 # first year in which all three new buildings lease # When did each building start generating leases? print(window.groupby("sBuilding").year.min().sort_values().to_string()) print() # And at what rent level? (leases of SNAPSHOT_YEAR) print(window[window.year == SNAPSHOT_YEAR].groupby("sBuilding") .agg(leases=("sRent", "size"), median_rent=("sRent", "median")) .sort_values("median_rent").to_string())
sBuilding
Daniel-Johnson 2019
Levesque 2019
Saint-Elzear 2019
Le Carlyle 2023
The Met 2023
Westpark 2024
leases median_rent
sBuilding
Levesque 84 1672.5
Daniel-Johnson 136 1870.0
Saint-Elzear 215 2145.0
Westpark 144 2202.5
Le Carlyle 195 2350.0
The Met 128 2752.5
What happened: Le Carlyle, The Met and Westpark came into service in 2023 and 2024. They are upscale buildings, with smaller units that cost more per square foot. The units leased became smaller and the share of 2-bedroom and larger units fell, yet the median rose: the opposite of what size would predict. The rise in the median comes mostly from which buildings are leasing, hardly at all from the same tenant paying more.
The chart below shows this change in composition: the share of leases by building, year by year. The comparison with same-unit growth follows in section 4, once the pairs are built.
building_mix = pd.crosstab(validated_leases.sLeaseFrom.dt.year, validated_leases.sBuilding, normalize="index") ax = building_mix.plot.area(stacked=True, figsize=(9, 4)) ax.set(title="Lease composition by building", ylabel="Share of the year's leases", xlabel="Lease start year") ax.legend(loc="center left", bbox_to_anchor=(1, 0.5)) plt.tight_layout() plt.show()
4. The method that works: comparing a unit with itselfWe pair the consecutive leases of each unit and annualise the change: same apartment, same building, so what remains is the price.
4. The method that works: comparing a unit with itself
We pair the consecutive leases of each unit and annualise the change: same apartment, same building, so what remains is the price.
The key: sPropCode + sUnitCode. Not sSite + sUnitCode: several sites group several property codes whose unit numbers overlap.
The next cell demonstrates it, without displaying any CRM row.
COLLISION_EXAMPLE_SITE, COLLISION_EXAMPLE_UNIT = "saint-elzear", "0203" # example given by the starter site_key_collisions = listings[listings.duplicated(["sSite", "sUnitCode"], keep=False)] print(f"{len(site_key_collisions):,} units out of {len(listings):,} collide on sSite + sUnitCode") example = listings[(listings.sSite == COLLISION_EXAMPLE_SITE) & (listings.sUnitCode == COLLISION_EXAMPLE_UNIT)] print(f"Example {COLLISION_EXAMPLE_SITE} / {COLLISION_EXAMPLE_UNIT}: {example.hUnit.nunique()} distinct apartments (hUnit), " f"{example.sPropCode.nunique()} different property codes") print(f"Duplicates on sPropCode + sUnitCode in the listings: {listings.duplicated(UNIT_KEY).sum()}")
248 units out of 1,061 collide on sSite + sUnitCode Example saint-elzear / 0203: 3 distinct apartments (hUnit), 3 different property codes Duplicates on sPropCode + sUnitCode in the listings: 0
The starter proposes a first version of the matching (same_unit_growth, kept below): previous lease of the same unit, gap between starts
of 0.5 to 2.5 years, annualised growth, median by year. It already confirms that the naive median mostly measures composition.
GAP_MIN_YEARS, GAP_MAX_YEARS = 0.5, 2.5 # shorter: often the same lease recorded twice; longer: too old FIRST_PAIR_YEAR_SHOWN = 2021 def same_unit_growth(df, price_col="sRent", date_col="sLeaseFrom", min_gap=GAP_MIN_YEARS, max_gap=GAP_MAX_YEARS): # Starter version: annualised rent change, each unit compared with its own previous lease. d = df.sort_values(UNIT_KEY + [date_col]).copy() d["prev_price"] = d.groupby(UNIT_KEY)[price_col].shift(1) d["prev_date"] = d.groupby(UNIT_KEY)[date_col].shift(1) d = d.dropna(subset=["prev_price", "prev_date"]) d["gap_years"] = (d[date_col] - d.prev_date).dt.days / DAYS_PER_YEAR d = d[d.gap_years.between(min_gap, max_gap, inclusive="neither")] d = d[(d.prev_price > 0) & (d[price_col] > 0)] d["growth_pct"] = ((d[price_col] / d.prev_price) ** (1 / d.gap_years) - 1) * PERCENT return d pairs = same_unit_growth(leases) print(f"{len(pairs):,} usable pairs across {pairs.groupby(UNIT_KEY).ngroups:,} units") honest = (pairs[pairs.year.between(FIRST_PAIR_YEAR_SHOWN, LAST_OBSERVED_YEAR)] .groupby("year") .growth_pct.agg(pairs="size", median="median").round(2)) honest
3,179 usable pairs across 890 units
| pairs | median | |
|---|---|---|
| year | ||
| 2021 | 355 | 4.46 |
| 2022 | 352 | 5.40 |
| 2023 | 416 | 2.29 |
| 2024 | 698 | 3.39 |
| 2025 | 780 | 7.04 |
fig, ax = plt.subplots(figsize=(9, 4.5)) years_shown = range(FIRST_PAIR_YEAR_SHOWN, TARGET_YEAR) ax.plot(years_shown, naive.loc[years_shown, "yoy_pct"], "o--", label="naive: median of all leases", lw=2) ax.plot(years_shown, honest.loc[years_shown, "median"], "o-", label="same unit compared with itself", lw=2) ax.axhline(0, color="grey", lw=0.8) ax.set_ylabel("annual rent growth (%)") ax.set_title("The naive method mostly measures composition") ax.set_xticks(list(years_shown)) ax.legend() ax.grid(alpha=0.3) plt.tight_layout() plt.show()
4b. Our matching: rules fixed once, exclusions visible
In brief: each unit is compared with its previous lease, with rules written once and exclusions counted. Result: 3,179 comparable pairs.
Details
We tighten the starter's version on three points:
- Immediately preceding lease: a pair is kept only if the sequences are consecutive (
sTermSeqincreases by 1). A missing lease is not "skipped". - Contractual (face) and effective measured together on the same pairs, with the concession gap
gap = 1 − sRentEffective / sRentof the previous lease and of the new one. - All rules and all parameters of the model are fixed here, once, in
ForecastSpec. None was optimised on the backtest years.
Annualisation: t = days between the two starts / 365.25, growth = (new_rent / previous_rent) ** (1/t) − 1, pair kept if 0.5 < t < 2.5.
Exact identity at unit level: (1 + effective growth) = (1 + contractual growth) × ((1 − new_gap) / (1 − previous_gap)) ** (1/t).
Effective growth is contractual (face) growth corrected for the change in the concession gap. The cell checks it on all pairs.
@dataclass(frozen=True) class ForecastSpec: # Parameters frozen before the backtests of this revision. origin_month: int = 12 # origin of each forecast: 31 December of the previous year origin_day: int = 31 gap_min_years: float = GAP_MIN_YEARS # admissible interval between two lease starts gap_max_years: float = GAP_MAX_YEARS growth_clip: tuple = (-0.30, 0.60) # robustness bounds, training means only gap_clip: tuple = (0.0, 0.75) # bounds of the forecast concession gap shrink_k_cell: float = 10.0 # shrinkage of building × event toward province × event shrink_k_mix: float = 20.0 # shrinkage of the renewal share toward the province trailing_years_mix: int = 3 # window of the renewal share components: tuple = ("DIRECT", "BRIDGE", "ANCHOR") component_weights: tuple = (1 / 3, 1 / 3, 1 / 3) face_persistence_weight: float = 0.5 # contractual: half last year, half history evaluation_years: tuple = (2020, 2021, 2022, 2023, 2024, 2025) SPEC = ForecastSpec() PAIR_COLUMNS = ["property_code", "unit_code", "province", "bedrooms", "term_seq", "prior_seq", "sign_date", "lease_start", "lease_end", "prior_start", "prior_sign", "event_year", "event_type", "prior_face_rent", "prior_effective_rent", "face_rent", "effective_rent", "interval_years", "face_growth", "effective_growth", "prior_gap", "new_gap", "eligible"] def build_pairs(lease_table): # Pairs of consecutive leases of the same unit, annualised growth and concession gaps. ordered = validate_leases(lease_table).sort_values(UNIT_KEY + ["sTermSeq", "sLeaseFrom"]) by_unit = ordered.groupby(UNIT_KEY, sort=False) paired = ordered.assign(prior_start=by_unit["sLeaseFrom"].shift(1), prior_sign=by_unit["sSignDate"].shift(1), prior_face_rent=by_unit["sRent"].shift(1), prior_effective_rent=by_unit["sRentEffective"].shift(1), prior_seq=by_unit["sTermSeq"].shift(1)) paired = paired[paired["prior_start"].notna()].copy() paired["interval_years"] = (paired["sLeaseFrom"] - paired["prior_start"]).dt.days / DAYS_PER_YEAR paired["face_growth"] = (paired["sRent"] / paired["prior_face_rent"]) ** (1.0 / paired["interval_years"]) - 1.0 paired["effective_growth"] = (paired["sRentEffective"] / paired["prior_effective_rent"]) ** (1.0 / paired["interval_years"]) - 1.0 paired["prior_gap"] = 1.0 - paired["prior_effective_rent"] / paired["prior_face_rent"] paired["new_gap"] = 1.0 - paired["sRentEffective"] / paired["sRent"] rents_positive = (paired[["sRent", "prior_face_rent", "sRentEffective", "prior_effective_rent"]] > 0).all(axis=1) consecutive = paired["sTermSeq"].sub(paired["prior_seq"]).eq(1) paired["eligible"] = (paired["interval_years"].gt(SPEC.gap_min_years) & paired["interval_years"].lt(SPEC.gap_max_years) & rents_positive & consecutive) paired["event_year"] = paired["sLeaseFrom"].dt.year paired["event_type"] = np.where(paired["sRenewal"].eq(1), "renewal", "turnover") return paired.rename(columns=COLUMN_RENAMES)[PAIR_COLUMNS].reset_index(drop=True) def exact_bridge(face_growth, prior_gap, new_gap, interval_years=1.0): # Exact identity: (1 + g_contractual) × ((1 − new_gap) / (1 − previous_gap)) ** (1/t) − 1. inputs = (interval_years, prior_gap, new_gap, face_growth) if (any(not np.isfinite(np.asarray(value)).all() for value in inputs) or np.any(np.asarray(interval_years) <= 0) or np.any(np.asarray(prior_gap) >= 1) or np.any(np.asarray(prior_gap) < 0) or np.any(np.asarray(new_gap) < 0) or np.any(np.asarray(new_gap) >= 1) or np.any(np.asarray(face_growth) <= -1)): raise DataContractError("the bridge requires positive durations and valid rent ratios") return (1.0 + face_growth) * ((1.0 - new_gap) / (1.0 - prior_gap)) ** (1.0 / interval_years) - 1.0 unit_pairs = build_pairs(leases) eligible_pairs = unit_pairs[unit_pairs["eligible"]] exclusion_reasons = pd.Series({ "interval ≤ 0.5 year": int((unit_pairs.interval_years <= SPEC.gap_min_years).sum()), "interval ≥ 2.5 years": int((unit_pairs.interval_years >= SPEC.gap_max_years).sum()), "non-consecutive sequences": int(unit_pairs.term_seq.sub(unit_pairs.prior_seq).ne(1).sum()), }, name="pairs (one case can have two reasons)") display(pd.DataFrame([{"candidate_pairs": len(unit_pairs), "eligible": len(eligible_pairs), "excluded": len(unit_pairs) - len(eligible_pairs)}])) display(exclusion_reasons) assert eligible_pairs["term_seq"].sub(eligible_pairs["prior_seq"]).eq(1).all() np.testing.assert_allclose(exact_bridge(eligible_pairs.face_growth, eligible_pairs.prior_gap, eligible_pairs.new_gap, eligible_pairs.interval_years), eligible_pairs.effective_growth, atol=1e-12) print("Contractual (face) / effective identity verified on all eligible pairs.")
| candidate_pairs | eligible | excluded | |
|---|---|---|---|
| 0 | 3241 | 3179 | 62 |
interval ≤ 0.5 year 62 interval ≥ 2.5 years 0 non-consecutive sequences 0 Name: pairs (one case can have two reasons), dtype: int64
Contractual (face) / effective identity verified on all eligible pairs.
Naive median vs same unit, by year. For each start year of the new lease, the table gives: the number of pairs, the average contractual (face) and effective growth, the effective median (robustness), the renewal share, and the change in the naive median. The gap between the naive median and the matched average illustrates the mix effect. It is not an exact causal decomposition: the statistics and the samples differ.
annual_growth = eligible_pairs.groupby("event_year").agg( pairs=("effective_growth", "size"), face_pct=("face_growth", lambda growth: PERCENT * growth.mean()), effective_pct=("effective_growth", lambda growth: PERCENT * growth.mean()), median_effective_pct=("effective_growth", lambda growth: PERCENT * growth.median()), renewal_share=("event_type", lambda event: event.eq("renewal").mean())).reset_index() naive_median_change = validated_leases.groupby(validated_leases.sLeaseFrom.dt.year).sRent.median().pct_change() * PERCENT annual_growth["naive_median_face_change_pct"] = annual_growth.event_year.map(naive_median_change) display(annual_growth.round(3)) ax = annual_growth.set_index("event_year")[["naive_median_face_change_pct", "face_pct", "effective_pct"]].rename(columns={ "naive_median_face_change_pct": "Naive median (all leases)", "face_pct": "Same unit, contractual", "effective_pct": "Same unit, effective"}).plot(marker="o", figsize=(9, 4)) ax.set(title="Naive median vs same-unit growth", ylabel="% annualised / annual change", xlabel="Start year of the new lease") ax.axhline(0, color="grey", lw=0.8) plt.tight_layout() plt.show()
| event_year | pairs | face_pct | effective_pct | median_effective_pct | renewal_share | naive_median_face_change_pct | |
|---|---|---|---|---|---|---|---|
| 0 | 2018 | 88 | 2.126 | 2.041 | 1.976 | 0.534 | -11.295 |
| 1 | 2019 | 159 | 2.000 | 1.593 | 1.629 | 0.579 | 7.453 |
| 2 | 2020 | 331 | 3.034 | 2.769 | 3.019 | 0.604 | 2.601 |
| 3 | 2021 | 355 | 4.384 | 3.753 | 3.543 | 0.620 | 4.225 |
| 4 | 2022 | 352 | 5.273 | 4.716 | 4.586 | 0.642 | 3.378 |
| 5 | 2023 | 416 | 2.089 | 2.178 | 2.019 | 0.575 | 10.850 |
| 6 | 2024 | 698 | 3.728 | 1.951 | 1.772 | 0.605 | 4.481 |
| 7 | 2025 | 780 | 7.559 | 3.833 | 3.575 | 0.606 | 4.063 |
5. Contractual (face) rent is not collected rentsRent is what the lease states; sRentEffective is what Équinoxe actually collects after free months and other benefits.
5. Contractual (face) rent is not collected rent
sRent is what the lease states; sRentEffective is the rent after free months and other benefits, as supplied in the data (the starter describes it as what Équinoxe actually collects).
Concessions have become the norm. The first table (starter) compares levels; the second compares growth on the starter's pairs.
conc = (leases[leases.year.between(FIRST_PAIR_YEAR_SHOWN, LAST_OBSERVED_YEAR)] .groupby("year") .agg(leases=("sRent", "size"), pct_with_concession=("sConcession", lambda flag: flag.mean() * PERCENT), median_contracted=("sRent", "median"), median_effective=("sRentEffective", "median"))) conc["gap_pct"] = ((conc.median_effective / conc.median_contracted - 1) * PERCENT) conc.round(2)
| leases | pct_with_concession | median_contracted | median_effective | gap_pct | |
|---|---|---|---|---|---|
| year | |||||
| 2021 | 361 | 54.02 | 1850.0 | 1785.00 | -3.51 |
| 2022 | 422 | 57.35 | 1912.5 | 1835.00 | -4.05 |
| 2023 | 693 | 54.26 | 2120.0 | 2025.40 | -4.46 |
| 2024 | 902 | 73.06 | 2215.0 | 2084.46 | -5.89 |
| 2025 | 958 | 85.39 | 2305.0 | 2105.59 | -8.65 |
# Does the concession change the trend, or only the level? eff = same_unit_growth(leases, price_col="sRentEffective") compare = pd.DataFrame({ "contracted": honest["median"], "effective": eff[eff.year.between(FIRST_PAIR_YEAR_SHOWN, LAST_OBSERVED_YEAR)].groupby("year").growth_pct.median(), }).round(2) compare
| contracted | effective | |
|---|---|---|
| year | ||
| 2021 | 4.46 | 3.54 |
| 2022 | 5.40 | 4.59 |
| 2023 | 2.29 | 2.02 |
| 2024 | 3.39 | 1.77 |
| 2025 | 7.04 | 3.58 |
5b. Checking sRentEffective against the concession ledger
In brief: the concession ledger confirms the direction of the gap without rebuilding it exactly; we therefore use the supplied effective rent as it is.
Details
The supplied effective rent remains our measure. To check it, each concession entry is attached to the lease of the same unit whose period contains
its start date. An entry that would match several leases stops the computation. The credit of an entry is −sAmount × sMonths; only the rent codes
(PromoPay, freerent) are subtracted from the rent, spread over the lease term (sTermMonths). Services (parking, locker, appliances) are not rent.
The first table shows what the ledger explains and what it does not: entries with no enclosing lease, and leases flagged sConcession = 1 without an entry.
The second compares, on a common sample where both leases of the pair have a positive reconstruction and a consistent flag, three definitions of rent:
contractual (face), contractual less only the ledger's rent credits, and supplied effective. These entries have no reliable recording date:
this reconstruction is a diagnostic and does not enter the backtests.
RENT_CREDIT_CODES = ("promopay", "freerent") AMENITY_CODES = ("freepark", "freelock", "freeappl") def match_concessions_to_leases(lease_table, concession_table): # Attach each entry to the lease of the same unit that contains its start date. lease_rows = validate_leases(lease_table).reset_index(drop=True).reset_index(names="lease_id") ledger = normalise_unit_key(concession_table).reset_index(names="concession_id") for column in ("sDateFrom", "sDateTo"): ledger[column] = pd.to_datetime(ledger[column]) matched = ledger.merge(lease_rows[["lease_id", *UNIT_KEY, "sLeaseFrom", "sLeaseTo"]], on=UNIT_KEY, how="left") matched = matched[matched.sDateFrom.between(matched.sLeaseFrom, matched.sLeaseTo)].copy() if matched.groupby("concession_id").lease_id.nunique().gt(1).any(): raise DataContractError("a concession entry matches several leases") charge_code = matched.sChargeCode.str.lower() matched["code"] = charge_code matched["category"] = np.select([charge_code.isin(RENT_CREDIT_CODES), charge_code.isin(AMENITY_CODES)], ["rent", "amenity"], default="other") matched["total_credit"] = -matched.sAmount * matched.sMonths if (matched.total_credit < 0).any(): raise DataContractError("a concession entry is a positive charge") return lease_rows, matched lease_rows, matched_concessions = match_concessions_to_leases(leases, concessions) rent_credit_by_lease = matched_concessions[matched_concessions.category.eq("rent")].groupby("lease_id").total_credit.sum() entries_per_lease = matched_concessions.groupby("lease_id").size() lease_rows["matched_entries"] = lease_rows.lease_id.map(entries_per_lease).fillna(0) lease_rows["rent_only"] = lease_rows.sRent - lease_rows.lease_id.map(rent_credit_by_lease).fillna(0) / lease_rows.sTermMonths lease_rows["flag_consistent"] = ~(lease_rows.sConcession.eq(1) & lease_rows.matched_entries.eq(0)) by_unit_sequence = lease_rows.sort_values(UNIT_KEY + ["sTermSeq"]).groupby(UNIT_KEY) lease_rows["prior_rent_only"] = by_unit_sequence.rent_only.shift() lease_rows["prior_flag_consistent"] = by_unit_sequence.flag_consistent.shift(fill_value=False) concession_reconciliation = pd.DataFrame([{ "entries": len(concessions), "matched to a lease": matched_concessions.concession_id.nunique(), "unmatched": len(concessions) - matched_concessions.concession_id.nunique(), "leases flagged without an entry": int((lease_rows.sConcession.eq(1) & lease_rows.matched_entries.eq(0)).sum()), "leases not flagged with an entry": int((lease_rows.sConcession.eq(0) & lease_rows.matched_entries.gt(0)).sum())}]) display(concession_reconciliation) pairs_with_reconstruction = eligible_pairs.merge( lease_rows[[*UNIT_KEY, "sTermSeq", "rent_only", "prior_rent_only", "flag_consistent", "prior_flag_consistent"]], left_on=["property_code", "unit_code", "term_seq"], right_on=[*UNIT_KEY, "sTermSeq"], validate="one_to_one") common_sample = pairs_with_reconstruction[pairs_with_reconstruction.flag_consistent & pairs_with_reconstruction.prior_flag_consistent & pairs_with_reconstruction.rent_only.gt(0) & pairs_with_reconstruction.prior_rent_only.gt(0)].copy() common_sample["rent_only_pct"] = PERCENT * ((common_sample.rent_only / common_sample.prior_rent_only) ** (1 / common_sample.interval_years) - 1) common_sample_by_year = common_sample.groupby("event_year").agg( n=("effective_growth", "size"), face_pct=("face_growth", lambda growth: PERCENT * growth.mean()), rent_only_pct=("rent_only_pct", "mean"), supplied_effective_pct=("effective_growth", lambda growth: PERCENT * growth.mean())).reset_index() display(common_sample_by_year.round(3)) ax = common_sample_by_year.set_index("event_year")[["face_pct", "rent_only_pct", "supplied_effective_pct"]].rename(columns={ "face_pct": "Contractual", "rent_only_pct": "Contractual − ledger rent credits", "supplied_effective_pct": "Supplied effective"}).plot(marker="o", figsize=(9, 4)) ax.set(title="Same sample: effect of the rent definition", ylabel="Annualised growth (%)", xlabel="Start year of the new lease") plt.tight_layout() plt.show()
| entries | matched to a lease | unmatched | leases flagged without an entry | leases not flagged with an entry | |
|---|---|---|---|---|---|
| 0 | 3560 | 3356 | 204 | 497 | 264 |
| event_year | n | face_pct | rent_only_pct | supplied_effective_pct | |
|---|---|---|---|---|---|
| 0 | 2018 | 84 | 2.038 | 1.893 | 2.003 |
| 1 | 2019 | 142 | 2.034 | 1.361 | 1.740 |
| 2 | 2020 | 280 | 2.998 | 3.116 | 2.843 |
| 3 | 2021 | 283 | 4.503 | 3.717 | 3.813 |
| 4 | 2022 | 273 | 5.252 | 5.689 | 4.641 |
| 5 | 2023 | 336 | 2.078 | 1.693 | 2.003 |
| 6 | 2024 | 534 | 3.761 | 2.551 | 2.232 |
| 7 | 2025 | 502 | 7.542 | 5.942 | 3.775 |
5c. Why contractual (face) and effective growth diverge from 2024
By the identity of section 4b, effective growth is contractual (face) growth minus the change in the concession gap between the previous lease and the new one. The average gap breaks down into incidence (share of leases with a concession) × depth (average gap when there is one). The change in the gap therefore splits exactly, at the midpoint, into an "incidence" part and a "depth" part.
Details
Reading: if the two growth rates diverge, it is not that the contractual (face) price stops rising; it is that the gap widens on the new lease.
The ledger shows which codes carry the gap, but does not reconcile it exactly: it also contains services that are not rent,
and it is missing entries (unmatched entries, leases flagged without an entry). The reliable figure remains the sRent / sRentEffective gap; the breakdown by code is indicative.
def concession_divergence(pairs_table): # By year: contractual vs effective growth, and change in the gap = incidence + depth. rows = [] for year, year_pairs in pairs_table[pairs_table["eligible"]].groupby("event_year"): incidence_prior, incidence_new = float((year_pairs["prior_gap"] > 0).mean()), float((year_pairs["new_gap"] > 0).mean()) depth_prior = float(year_pairs.loc[year_pairs["prior_gap"] > 0, "prior_gap"].mean()) if incidence_prior else 0.0 depth_new = float(year_pairs.loc[year_pairs["new_gap"] > 0, "new_gap"].mean()) if incidence_new else 0.0 gap_prior, gap_new = float(year_pairs["prior_gap"].mean()), float(year_pairs["new_gap"].mean()) incidence_part = (incidence_new - incidence_prior) * (depth_prior + depth_new) / 2.0 depth_part = (depth_new - depth_prior) * (incidence_prior + incidence_new) / 2.0 rows.append({ "event_year": int(year), "pairs": len(year_pairs), "face_pct": PERCENT * year_pairs["face_growth"].mean(), "effective_pct": PERCENT * year_pairs["effective_growth"].mean(), "face_minus_effective_pp": PERCENT * (year_pairs["face_growth"].mean() - year_pairs["effective_growth"].mean()), "prior_gap_pct": PERCENT * gap_prior, "new_gap_pct": PERCENT * gap_new, "gap_change_pp": PERCENT * (gap_new - gap_prior), "incidence_prior": incidence_prior, "incidence_new": incidence_new, "depth_prior_pct": PERCENT * depth_prior, "depth_new_pct": PERCENT * depth_new, "gap_change_from_incidence_pp": PERCENT * incidence_part, "gap_change_from_depth_pp": PERCENT * depth_part}) return pd.DataFrame(rows) def concession_code_contribution(lease_rows_table, matched_table): # Average amortised concession as % of contractual rent, over all leases, by year and by code. credit_by_code = matched_table.groupby(["lease_id", "code"])["total_credit"].sum().unstack(fill_value=0.0) codes = list(credit_by_code.columns) per_lease = lease_rows_table[["lease_id", "sLeaseFrom", "sTermMonths", "sRent", "sRentEffective"]].join(credit_by_code, on="lease_id") per_lease[codes] = per_lease[codes].fillna(0.0).div(per_lease["sTermMonths"], axis=0).div(per_lease["sRent"], axis=0) start_year = per_lease["sLeaseFrom"].dt.year contribution = per_lease.groupby(start_year)[codes].mean().mul(PERCENT) contribution["total_matched_pct"] = contribution[codes].sum(axis=1) contribution["supplied_gap_pct"] = PERCENT * (1 - per_lease["sRentEffective"] / per_lease["sRent"]).groupby(start_year).mean() contribution["unexplained_by_ledger_pct"] = contribution["supplied_gap_pct"] - contribution["total_matched_pct"] contribution.index.name = "lease_start_year" return contribution.reset_index() divergence = concession_divergence(unit_pairs) display(divergence.round(2)) fig, axes = plt.subplots(1, 2, figsize=(13, 4)) divergence.set_index("event_year")[["face_pct", "effective_pct"]].rename( columns={"face_pct": "Contractual", "effective_pct": "Effective"}).plot(ax=axes[0], marker="o", title="Contractual vs effective, same unit") axes[0].set(ylabel="% annualised", xlabel="Start year of the new lease") divergence.set_index("event_year")[["gap_change_from_incidence_pp", "gap_change_from_depth_pp"]].rename( columns={"gap_change_from_incidence_pp": "Incidence", "gap_change_from_depth_pp": "Depth"}).plot.bar( ax=axes[1], stacked=True, title="Change in the concession gap: incidence + depth") axes[1].set(ylabel="percentage points", xlabel="Start year of the new lease") plt.tight_layout() plt.show() display(concession_code_contribution(lease_rows, matched_concessions).round(2))
| event_year | pairs | face_pct | effective_pct | face_minus_effective_pp | prior_gap_pct | new_gap_pct | gap_change_pp | incidence_prior | incidence_new | depth_prior_pct | depth_new_pct | gap_change_from_incidence_pp | gap_change_from_depth_pp | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 2018 | 88 | 2.13 | 2.04 | 0.08 | 1.21 | 1.36 | 0.15 | 0.40 | 0.38 | 3.04 | 3.62 | -0.08 | 0.22 |
| 1 | 2019 | 159 | 2.00 | 1.59 | 0.41 | 1.36 | 1.78 | 0.42 | 0.38 | 0.47 | 3.54 | 3.77 | 0.32 | 0.10 |
| 2 | 2020 | 331 | 3.03 | 2.77 | 0.27 | 1.77 | 2.07 | 0.30 | 0.46 | 0.48 | 3.84 | 4.28 | 0.10 | 0.20 |
| 3 | 2021 | 355 | 4.38 | 3.75 | 0.63 | 2.02 | 2.68 | 0.66 | 0.47 | 0.54 | 4.29 | 4.99 | 0.31 | 0.35 |
| 4 | 2022 | 352 | 5.27 | 4.72 | 0.56 | 2.74 | 3.35 | 0.61 | 0.56 | 0.58 | 4.92 | 5.78 | 0.12 | 0.49 |
| 5 | 2023 | 416 | 2.09 | 2.18 | -0.09 | 3.21 | 3.24 | 0.03 | 0.55 | 0.49 | 5.81 | 6.64 | -0.40 | 0.43 |
| 6 | 2024 | 698 | 3.73 | 1.95 | 1.78 | 3.66 | 5.46 | 1.79 | 0.55 | 0.70 | 6.63 | 7.79 | 1.06 | 0.73 |
| 7 | 2025 | 780 | 7.56 | 3.83 | 3.73 | 5.79 | 9.24 | 3.46 | 0.74 | 0.86 | 7.79 | 10.70 | 1.13 | 2.33 |
| lease_start_year | freeappl | freelock | freeothe | freepark | freerent | indem | promopay | referenc | total_matched_pct | supplied_gap_pct | unexplained_by_ledger_pct | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 2017 | 0.00 | 0.12 | 0.19 | 0.49 | 0.03 | 0.00 | 0.64 | 0.00 | 1.47 | 1.18 | -0.29 |
| 1 | 2018 | 0.07 | 0.17 | 0.22 | 0.44 | 0.07 | 0.03 | 0.73 | 0.06 | 1.79 | 1.36 | -0.43 |
| 2 | 2019 | 0.00 | 0.12 | 0.37 | 0.65 | 0.09 | 0.05 | 1.21 | 0.00 | 2.50 | 1.70 | -0.80 |
| 3 | 2020 | 0.03 | 0.11 | 0.41 | 0.47 | 0.09 | 0.04 | 1.08 | 0.03 | 2.25 | 2.09 | -0.16 |
| 4 | 2021 | 0.06 | 0.09 | 0.43 | 0.80 | 0.11 | 0.04 | 1.82 | 0.00 | 3.35 | 2.69 | -0.66 |
| 5 | 2022 | 0.02 | 0.13 | 0.55 | 0.96 | 0.12 | 0.05 | 1.58 | 0.06 | 3.47 | 3.33 | -0.15 |
| 6 | 2023 | 0.04 | 0.27 | 0.82 | 1.72 | 0.30 | 0.11 | 1.77 | 0.05 | 5.09 | 3.58 | -1.51 |
| 7 | 2024 | 0.09 | 0.34 | 1.25 | 1.67 | 0.33 | 0.10 | 2.56 | 0.06 | 6.39 | 5.71 | -0.69 |
| 8 | 2025 | 0.07 | 0.27 | 0.77 | 1.62 | 0.19 | 0.10 | 3.77 | 0.06 | 6.83 | 9.13 | 2.30 |
Reading. Up to 2023, contractual (face) and effective growth stay within one point of each other. After that the concession gap of the new lease widens, for two successive reasons:
- in 2024, mostly incidence: the share of new leases with a concession goes from about 55% to 70%;
- in 2025, mostly depth: the average gap on a lease with a concession goes from about 7.8% to 10.7% of the rent, while incidence rises further.
The ledger shows that PromoPay carries most of this increase. Contractual (face) rent keeps rising, but a growing part of it is handed back as free months. This is why we quote the effective figure: it is what a landlord collects. Contractual (face) growth is still shown, because it is what matters for the regulation of renewals.
6. Renewals do not behave like turnoverssRenewal = 1: a sitting tenant renews. sRenewal = 0: the unit becomes vacant and is re-let at market price. In Quebec, the two are governed by different
6. Renewals do not behave like turnovers
sRenewal = 1: a sitting tenant renews. sRenewal = 0: the unit becomes vacant and is re-let at market price. In Quebec, the two are governed by different
rules (the TAL regulates the increase for a sitting tenant); in Ontario, the guideline applies only to rent-controlled units.
The starter's table shows that the gap changes sides: renewals lead in 2022, turnovers in 2025.
FIRST_SPLIT_YEAR = 2022 split = (pairs[pairs.year.between(FIRST_SPLIT_YEAR, LAST_OBSERVED_YEAR)] .groupby(["year", "sRenewal"]) .growth_pct.agg(pairs="size", median="median").round(2) .unstack("sRenewal")) split.columns = [f"{stat}_{'renewal' if is_renewal else 'turnover'}" for stat, is_renewal in split.columns] split
| pairs_turnover | pairs_renewal | median_turnover | median_renewal | |
|---|---|---|---|---|
| year | ||||
| 2022 | 126 | 226 | 4.22 | 5.69 |
| 2023 | 177 | 239 | 1.85 | 2.58 |
| 2024 | 276 | 422 | 4.85 | 2.94 |
| 2025 | 307 | 473 | 8.98 | 6.41 |
Split by province and event type, on our pairs. Contractual (face) and effective averages by year × province × event. The Met (Ontario) is always treated separately: one province's cap is never applied to the other. The model (section 7.3) forecasts each building × event type separately, then recombines them according to the expected renewal share.
Asking rent. This is an advertised price, not a signed one. The last known asking rent at 31 December 2025 is very close to the listings rent: in this extract, it brings no independent information. Without a time-stamped offer before signature and linked to the realised lease, we do not use it as a variable.
segments = eligible_pairs.groupby(["event_year", "province", "event_type"]).agg( n=("effective_growth", "size"), face_pct=("face_growth", lambda growth: PERCENT * growth.mean()), effective_pct=("effective_growth", lambda growth: PERCENT * growth.mean())).reset_index() display(segments[segments.event_year.ge(REQUIRED_BACKTEST_YEARS[0])].round(3)) asking_known = asking.copy() asking_known["sMonth"] = pd.to_datetime(asking_known.sMonth) asking_known = asking_known[asking_known.sMonth.le(pd.Timestamp(LAST_OBSERVED_YEAR, 12, 31))] latest_asking = asking_known.sort_values("sMonth").drop_duplicates(UNIT_KEY, keep="last") asking_vs_listing = latest_asking.merge(listings[UNIT_KEY + ["sRent"]], on=UNIT_KEY, validate="one_to_one") display(pd.DataFrame([{"origin": f"{LAST_OBSERVED_YEAR}-12-31", "units with asking rent": len(latest_asking), "matched to listings": len(asking_vs_listing), "median asking / listing ratio": float((asking_vs_listing.sAskingRent / asking_vs_listing.sRent).median())}]).round(4))
| event_year | province | event_type | n | face_pct | effective_pct | |
|---|---|---|---|---|---|---|
| 10 | 2023 | Quebec | renewal | 239 | 2.550 | 2.747 |
| 11 | 2023 | Quebec | turnover | 177 | 1.467 | 1.409 |
| 12 | 2024 | Ontario | renewal | 67 | 2.899 | 0.328 |
| 13 | 2024 | Ontario | turnover | 41 | 5.080 | 4.756 |
| 14 | 2024 | Quebec | renewal | 355 | 2.915 | 1.368 |
| 15 | 2024 | Quebec | turnover | 235 | 4.956 | 2.803 |
| 16 | 2025 | Ontario | renewal | 63 | 6.333 | 1.893 |
| 17 | 2025 | Ontario | turnover | 54 | 8.925 | 4.547 |
| 18 | 2025 | Quebec | renewal | 410 | 6.529 | 2.603 |
| 19 | 2025 | Quebec | turnover | 253 | 9.243 | 6.157 |
| origin | units with asking rent | matched to listings | median asking / listing ratio | |
|---|---|---|---|---|
| 0 | 2025-12-31 | 1061 | 1061 | 0.9824 |
7. Your turn: our forecastWe forecast the average of the annualised effective-rent growth rates, same unit, for the pairs whose new lease starts in 2026
7. Your turn: our forecast
7.1 The question (recap)
We forecast the average of the annualised effective-rent growth rates, same unit, for the pairs whose new lease starts in 2026 (section 0). Contractual (face) growth is forecast alongside. This figure describes comparable leases; it is neither the growth of total revenue nor that of NOI.
7.2 Public data and the date rule
external_context.csv gathers the requested public sources, each with its period, its release date and its link:
CMHC (fixed-sample rents and vacancy, Montréal and Ottawa CMAs), Statistics Canada (CPI, rent component, Quebec and Ontario),
the TAL (base adjustment for renewals in Quebec) and the Ontario guideline.
Details
Rule: a forecast made on 31 December may only use data published before that date. The external_asof function rejects any
later publication, any without a link, or any non-numeric value. This is why the 2026 TAL figure, published in January 2026, is kept apart in
external_postorigin_2026.csv and is used only for the sensitivities of section 7.6.
external = pd.read_csv("external_context.csv") external_post_origin = pd.read_csv("external_postorigin_2026.csv") EXTERNAL_REQUIRED_COLUMNS = {"target_year", "release_date", "source_url", "metric", "value"} def origin_date(target_year): # Forecast origin: 31 December of the year before the target. return pd.Timestamp(target_year - 1, SPEC.origin_month, SPEC.origin_day) def external_asof(external_table, target_year): # Public sources available at the origin; rejects later publications or those without provenance. if external_table is None: return pd.DataFrame() if not isinstance(external_table, pd.DataFrame): raise DataContractError("external must be a DataFrame of public sources with provenance") if EXTERNAL_REQUIRED_COLUMNS - set(external_table): raise DataContractError(f"external: missing columns {sorted(EXTERNAL_REQUIRED_COLUMNS - set(external_table))}") available = external_table[external_table.target_year.eq(target_year)].copy() release = pd.to_datetime(available.release_date, errors="coerce") if release.isna().any() or (release > origin_date(target_year)).any(): raise DataContractError("publication missing or later than the forecast origin") if available.source_url.isna().any() or not available.source_url.astype(str).str.match(r"https?://\S+").all(): raise DataContractError("missing provenance") if not np.isfinite(pd.to_numeric(available.value, errors="coerce")).all(): raise DataContractError("non-finite public values") return available display(external_asof(external, TARGET_YEAR)[["geography", "metric", "reference_period", "release_date", "value", "unit"]])
| geography | metric | reference_period | release_date | value | unit | |
|---|---|---|---|---|---|---|
| 3 | Quebec | TAL estimated average basic adjustment (lagged) | TAL 2025 | 2025-01-21 | 5.9000 | percent |
| 7 | Ontario | Ontario rent increase guideline | 2026 guideline | 2025-06-30 | 2.1000 | percent |
| 11 | Montreal CMA | CMHC fixed-sample average rent change | October 2025 | 2025-12-11 | 7.4000 | percent |
| 15 | Ottawa-Gatineau CMA (Ontario part) | CMHC fixed-sample average rent change | October 2025 | 2025-12-11 | 3.5000 | percent |
| 19 | Montreal CMA | CMHC apartment vacancy rate | October 2025 | 2025-12-11 | 2.9000 | percent |
| 23 | Ottawa-Gatineau CMA (Ontario part) | CMHC apartment vacancy rate | October 2025 | 2025-12-11 | 3.0000 | percent |
| 27 | Montreal CMA | Statistics Canada rented accommodation CPI yea... | November 2025 | 2025-12-15 | 7.3678 | percent YoY |
| 31 | Ontario | Statistics Canada rent CPI year-over-year | November 2025 | 2025-12-15 | 4.0687 | percent YoY |
7.3 Method: three simple components, forecast by building × event type
In brief: for each building and each lease type (renewal or turnover), we average three simple readings: last year's momentum, the same momentum converted to effective rent unit by unit, and the long-term trend. We then weight by the number of leases expected in 2026.
Details
What the forecast is allowed to see. For a target Y, the origin is 31 December of Y − 1. We keep the leases signed and started before the origin, then we build the pairs. The roster is the set of units whose last known lease at the origin ends in Y: these are the leases that will be renewed or re-let. The roster anticipates the composition of events; it is not a full rent roll.
Three equally weighted components (one third each, fixed in advance), computed for each building × event type:
- DIRECT: last year's average effective growth (persistence).
- BRIDGE: last year's contractual (face) growth, converted to effective by the exact identity of section 4b, applied unit by unit to the roster, with each unit's current concession gap and the gap of last year's new leases.
- ANCHOR: the effective average over the whole eligible history (long-term anchor).
Small samples. Each building × event average is shrunk toward the province × event average: (n × mean + k × parent) / (n + k), with k = 10.
A missing cell uses province × event, then event, then the portfolio.
Training means are bounded at −30% / +60% so that an outlier lease does not dominate a small building. Realised results are never bounded.
Renewal share. For each building, the share over the last three years is shrunk toward the province (k = 20).
Quebec and Ontario renewals are never mixed.
Contractual (face). Half last year, half history, by cell.
@dataclass(frozen=True) class OriginBundle: # Everything the forecast is allowed to see for a target year. target_year: int origin: pd.Timestamp pairs: pd.DataFrame # eligible pairs signed and started before the origin roster: pd.DataFrame # units whose last known lease ends during the target year @property def summary(self): return {"target_year": self.target_year, "origin": self.origin.date().isoformat(), "n_training_pairs": int(len(self.pairs)), "n_roster_units": int(len(self.roster)), "training_years": sorted(int(year) for year in self.pairs["event_year"].unique())} ROSTER_COLUMNS = ["property_code", "unit_code", "province", "bedrooms", "term_seq", "lease_end", "face_rent", "effective_rent", "prior_gap", "expected_years", "comparable"] def build_roster(lease_table, target_year): # Units whose last known lease at the origin ends during the target year. # The origin filter comes BEFORE the choice of the last lease: a more recent extract does not change the roster. checked = validate_leases(lease_table) origin = origin_date(target_year) known_at_origin = checked[(checked["sSignDate"] <= origin) & (checked["sLeaseFrom"] <= origin)] latest_lease = known_at_origin.sort_values(UNIT_KEY + ["sTermSeq", "sLeaseFrom"]).drop_duplicates(UNIT_KEY, keep="last") roster = latest_lease[latest_lease["sLeaseTo"].dt.year.eq(target_year)].rename(columns=COLUMN_RENAMES) roster["prior_gap"] = 1.0 - roster["effective_rent"] / roster["face_rent"] # Assumption: the next lease starts the day after the current lease ends. roster["expected_years"] = ((roster["lease_end"] + pd.Timedelta(days=1)) - roster["lease_start"]).dt.days / DAYS_PER_YEAR roster["comparable"] = roster["expected_years"].between(SPEC.gap_min_years, SPEC.gap_max_years, inclusive="neither") return roster[ROSTER_COLUMNS].reset_index(drop=True) def make_bundle(lease_table, target_year): # Filter on signature AND start before pairing: a missing sequence cannot be bridged. checked = validate_leases(lease_table) origin = origin_date(target_year) observed = checked[(checked["sSignDate"] <= origin) & (checked["sLeaseFrom"] <= origin)] observed_pairs = build_pairs(observed) known_pairs = observed_pairs[observed_pairs["eligible"]].copy() roster = build_roster(checked, target_year) if known_pairs.empty: raise DataContractError(f"no eligible pair known before {origin.date()}") if roster.empty or not roster["comparable"].any(): raise DataContractError(f"no unit expiring in {target_year} is known at {origin.date()}") return OriginBundle(target_year, origin, known_pairs, roster) def clip_for_training(values, column): # Robustness bounds: forecast concession gap, or growth. lower, upper = SPEC.gap_clip if column == "new_gap" else SPEC.growth_clip return values.clip(lower, upper) def shrunk_cell_means(pairs_table, column, shrink_k): # Building × event means shrunk toward province × event (then event, then portfolio). if pairs_table.empty: return {} bounded = pairs_table.assign(_value=clip_for_training(pairs_table[column], column)) portfolio_mean = float(bounded["_value"].mean()) event_means = bounded.groupby("event_type")["_value"].mean().to_dict() province_event_means = bounded.groupby(["province", "event_type"])["_value"].mean() province_of = bounded.groupby("property_code")["province"].first().to_dict() cell_means = {} for (property_code, event_type), cell in bounded.groupby(["property_code", "event_type"]): parent = province_event_means.get((province_of[property_code], event_type), event_means.get(event_type, portfolio_mean)) n_pairs = len(cell) cell_means[(property_code, event_type)] = (n_pairs * float(cell["_value"].mean()) + shrink_k * parent) / (n_pairs + shrink_k) return cell_means def cell_value(cell_means, pairs_table, column, property_code, event_type, province): # Cell value, with hierarchical fallback for a missing cell. if (property_code, event_type) in cell_means: return cell_means[(property_code, event_type)] bounded = pairs_table.assign(_value=clip_for_training(pairs_table[column], column)) province_event = bounded[bounded["province"].eq(province) & bounded["event_type"].eq(event_type)]["_value"] if len(province_event): return float(province_event.mean()) same_event = bounded[bounded["event_type"].eq(event_type)]["_value"] return float(same_event.mean()) if len(same_event) else float(bounded["_value"].mean()) def event_mix(bundle): # Expected share of renewals / turnovers by building, shrunk toward the province. roster = bundle.roster[bundle.roster["comparable"]] recent = bundle.pairs[bundle.pairs["event_year"].between(bundle.target_year - SPEC.trailing_years_mix, bundle.target_year - 1)] overall_rate = (float(recent["event_type"].eq("renewal").mean()) if len(recent) else float(bundle.pairs["event_type"].eq("renewal").mean())) rows = [] for property_code, units in roster.groupby("property_code"): province = units["province"].iloc[0] property_recent = recent[recent["property_code"].eq(property_code)] province_recent = recent[recent["province"].eq(province)] prior_rate = float(province_recent["event_type"].eq("renewal").mean()) if len(province_recent) else overall_rate renewal_rate = ((property_recent["event_type"].eq("renewal").sum() + SPEC.shrink_k_mix * prior_rate) / (len(property_recent) + SPEC.shrink_k_mix)) for event_type, share in (("renewal", renewal_rate), ("turnover", 1.0 - renewal_rate)): rows.append({"property_code": property_code, "province": province, "event_type": event_type, "roster_units": len(units), "event_share": float(share), "expected_units": len(units) * float(share), "roster_prior_gap": float(units["prior_gap"].mean()), "n_recent_pairs": int(len(property_recent))}) mix_table = pd.DataFrame(rows) mix_table["weight"] = mix_table["expected_units"] / mix_table["expected_units"].sum() return mix_table def roster_bridge(bundle, property_code, face_growth, new_gap): # Average of the unit-by-unit identities over a building's roster (next start = current end + 1 day). units = bundle.roster[bundle.roster["property_code"].eq(property_code) & bundle.roster["comparable"]] return float(np.mean(exact_bridge(face_growth, units["prior_gap"], new_gap, units["expected_years"])))
The components are computed for each building × event cell, combined with equal weights, then aggregated with each cell's expected weight (roster units × event share). Computing the identity unit by unit and then averaging avoids replacing an average of ratios by a ratio of averages.
def component_cells(bundle): # DIRECT, BRIDGE, ANCHOR and contractual for each building × event. known_pairs, target_year = bundle.pairs, bundle.target_year last_year = known_pairs[known_pairs["event_year"].eq(target_year - 1)] if last_year.empty: # no lease started last year: most recent year available last_year = known_pairs[known_pairs["event_year"].eq(known_pairs["event_year"].max())] weights = event_mix(bundle) direct = shrunk_cell_means(last_year, "effective_growth", SPEC.shrink_k_cell) face_last = shrunk_cell_means(last_year, "face_growth", SPEC.shrink_k_cell) gap_last = shrunk_cell_means(last_year, "new_gap", SPEC.shrink_k_cell) anchor = shrunk_cell_means(known_pairs, "effective_growth", SPEC.shrink_k_cell) face_anchor = shrunk_cell_means(known_pairs, "face_growth", SPEC.shrink_k_cell) rows = [] for _, cell in weights.iterrows(): key = (cell["property_code"], cell["event_type"], cell["province"]) face_persistence = cell_value(face_last, last_year, "face_growth", *key) new_gap_persistence = cell_value(gap_last, last_year, "new_gap", *key) rows.append({**cell.to_dict(), "DIRECT": cell_value(direct, last_year, "effective_growth", *key), "BRIDGE": roster_bridge(bundle, cell["property_code"], face_persistence, new_gap_persistence), "ANCHOR": cell_value(anchor, known_pairs, "effective_growth", *key), "face_persistence": face_persistence, "face_anchor": cell_value(face_anchor, known_pairs, "face_growth", *key), "new_gap_persistence": new_gap_persistence, "n_last_year_pairs": int((last_year["property_code"].eq(cell["property_code"]) & last_year["event_type"].eq(cell["event_type"])).sum())}) return pd.DataFrame(rows) def combine(cells, components=None, weights=None): # Combination with fixed weights: effective = average of the components; contractual = last year / history blend. components = SPEC.components if components is None else components weights = SPEC.component_weights if weights is None else weights if (not components or len(components) != len(weights) or not np.isfinite(weights).all() or min(weights) < 0 or abs(sum(weights) - 1.0) > 1e-9 or len(set(components)) != len(components) or not set(components).issubset(SPEC.components)): raise DataContractError("the weights must match the components and sum to 1") combined = cells.copy() combined["effective"] = sum(weight * combined[name] for name, weight in zip(components, weights)) combined["face"] = (SPEC.face_persistence_weight * combined["face_persistence"] + (1 - SPEC.face_persistence_weight) * combined["face_anchor"]) return combined def aggregate(cells, column="effective"): # Average weighted by each cell's expected weight. return float(np.average(cells[column], weights=cells["weight"])) def forecast(bundle, components=None, weights=None): cells = combine(component_cells(bundle), components, weights) return {"target_year": bundle.target_year, "origin": bundle.origin.date().isoformat(), "effective": aggregate(cells, "effective"), "face": aggregate(cells, "face"), "components": {name: aggregate(cells, name) for name in SPEC.components}, "renewal_share": float(cells.loc[cells["event_type"].eq("renewal"), "weight"].sum()), "roster_units": int(bundle.roster["comparable"].sum()), "roster_excluded": int((~bundle.roster["comparable"]).sum()), "cells": cells, "bundle": bundle.summary}
7.4 estimate_2026() and backtest() completed
The two starter functions, with the same signature. estimate_2026 returns a percentage (4.8 means 4.8%).
backtest(leases, target_year) runs the same method as if we were on 31 December of target_year − 1, then compares it with what actually
happened during target_year. The realised result contains all eligible pairs of the year, with no bounds.
def realized(lease_table, target_year): # What actually happened: all eligible pairs whose new lease starts during the target year. all_pairs = build_pairs(lease_table) return all_pairs[all_pairs["eligible"] & all_pairs["event_year"].eq(target_year)].copy() def backtest_detail(lease_table, target_year, components=None, weights=None): bundle = make_bundle(lease_table, target_year) prediction = forecast(bundle, components, weights) actual_pairs = realized(lease_table, target_year) if actual_pairs.empty: raise DataContractError(f"no realised pair in {target_year}") actual_effective, actual_face = float(actual_pairs["effective_growth"].mean()), float(actual_pairs["face_growth"].mean()) actual_by_cell = actual_pairs.groupby(["property_code", "event_type"]).agg( actual_effective=("effective_growth", "mean"), actual_face=("face_growth", "mean"), realized_n=("effective_growth", "size")).reset_index() cells = prediction["cells"].merge(actual_by_cell, on=["property_code", "event_type"], how="left") cells["abs_error"] = (cells["effective"] - cells["actual_effective"]).abs() scored = cells.dropna(subset=["actual_effective"]) segment_mae = float(np.average(scored["abs_error"], weights=scored["realized_n"])) if len(scored) else np.nan # Diagnostics only: the roster is never reweighted with the realised mix. comparable_roster = bundle.roster[bundle.roster.comparable] in_roster = actual_pairs.merge(comparable_roster[["property_code", "unit_code", "term_seq"]], left_on=["property_code", "unit_code", "prior_seq"], right_on=["property_code", "unit_code", "term_seq"], how="inner", validate="many_to_one") roster_units_seen = in_roster[["property_code", "unit_code"]].drop_duplicates().shape[0] return {"target_year": target_year, "origin": prediction["origin"], "predicted": prediction["effective"], "actual": actual_effective, "error": prediction["effective"] - actual_effective, "predicted_face": prediction["face"], "actual_face": actual_face, "face_error": prediction["face"] - actual_face, "components": prediction["components"], "component_errors": {name: value - actual_effective for name, value in prediction["components"].items()}, "segment_mae": segment_mae, "realized_pairs": int(len(actual_pairs)), "roster_units": prediction["roster_units"], "realized_in_roster": int(len(in_roster)), "target_roster_coverage": len(in_roster) / len(actual_pairs), "roster_observed_fraction": roster_units_seen / len(comparable_roster), "segment_target_coverage": float(scored.realized_n.sum() / len(actual_pairs)), "by_segment": cells.to_dict("records"), "cells": cells, "bundle": prediction["bundle"]} def estimate_2026(leases, asking, external=None): # Returns our estimate of the 2026 rent increase, as a percentage. # Definition: average of the annualised effective-rent (sRentEffective) growth rates, same unit (sPropCode + sUnitCode), # consecutive leases whose new lease starts in 2026. `asking` is accepted to respect the starter's signature: # the asking rent remains context (section 6). `external` is checked by the date rule of section 7.2. external_asof(external, TARGET_YEAR) return PERCENT * forecast(make_bundle(leases, TARGET_YEAR))["effective"] def backtest(leases, target_year): # Runs the method as if we were at the end of target_year - 1, and compares with what happened in target_year. detail = backtest_detail(leases, target_year) return {"target_year": target_year, "predicted_pct": PERCENT * detail["predicted"], "actual_pct": PERCENT * detail["actual"], "error_pp": PERCENT * detail["error"], "by_segment": detail["by_segment"], "segment_rate_units": "fraction"} estimate_pct = estimate_2026(leases, asking, external) forecast_2026 = forecast(make_bundle(leases, TARGET_YEAR)) display(pd.Series({"2026 effective increase (%)": estimate_pct, "2026 contractual (face) increase (%)": PERCENT * forecast_2026["face"], **{f"Component {name} (%)": PERCENT * value for name, value in forecast_2026["components"].items()}, "Expected renewal share": forecast_2026["renewal_share"], "Comparable roster units": forecast_2026["roster_units"], "Excluded units (term outside 0.5–2.5 years)": forecast_2026["roster_excluded"]}).round(4))
2026 effective increase (%) 4.8047 2026 contractual (face) increase (%) 6.3780 Component DIRECT (%) 3.8592 Component BRIDGE (%) 7.5240 Component ANCHOR (%) 3.0309 Expected renewal share 0.6031 Comparable roster units 928.0000 Excluded units (term outside 0.5–2.5 years) 3.0000 dtype: float64
7.5 Backtest on 2023, 2024 and 2025, and comparison with simple methods
The starter suggests for yr in (2023, 2024, 2025): backtest(leases, yr). We display the result as a table. To see more regimes, we also replay
2020 to 2022, without presenting them as independent samples.
Comparators, with exactly the same origins and the same target: persistence (last year's average), the average of the last three years, the average of the whole history. No winner is chosen on these scores: the years 2023–2025 were already known when the method was designed, and three points are not enough to separate methods.
def rolling_evaluation(lease_table, target_years, components=None, weights=None): rows = [] for year in target_years: detail = backtest_detail(lease_table, year, components, weights) rows.append({"target_year": year, "origin": detail["origin"], "predicted_pct": PERCENT * detail["predicted"], "actual_pct": PERCENT * detail["actual"], "error_pp": PERCENT * detail["error"], "abs_error_pp": PERCENT * abs(detail["error"]), "predicted_face_pct": PERCENT * detail["predicted_face"], "actual_face_pct": PERCENT * detail["actual_face"], "face_error_pp": PERCENT * detail["face_error"], "segment_mae_pp": PERCENT * detail["segment_mae"], **{f"{name}_pct": PERCENT * value for name, value in detail["components"].items()}, **{f"{name}_error_pp": PERCENT * value for name, value in detail["component_errors"].items()}, "realized_pairs": detail["realized_pairs"], "roster_units": detail["roster_units"], **{key: detail[key] for key in ["realized_in_roster", "target_roster_coverage", "roster_observed_fraction", "segment_target_coverage"]}}) return pd.DataFrame(rows) def baseline_evaluation(lease_table, target_years): # Three simple comparators, same origin and same target; no winner is chosen. rows = [] for year in target_years: bundle = make_bundle(lease_table, year) actual = float(realized(lease_table, year)["effective_growth"].mean()) for name, training in (("Last year pooled", bundle.pairs[bundle.pairs.event_year.eq(year - 1)]), ("Trailing 3 years pooled", bundle.pairs[bundle.pairs.event_year.ge(year - 3)]), ("All history pooled", bundle.pairs)): if training.empty: training = bundle.pairs prediction = float(training.effective_growth.mean()) rows.append({"target_year": year, "model": name, "predicted_pct": PERCENT * prediction, "actual_pct": PERCENT * actual, "error_pp": PERCENT * (prediction - actual)}) return pd.DataFrame(rows) required_backtests = pd.DataFrame([{key: value for key, value in backtest(leases, year).items() if key not in ("by_segment", "segment_rate_units")} for year in REQUIRED_BACKTEST_YEARS]) display(required_backtests.round(3)) evaluation_years = tuple(year for year in SPEC.evaluation_years if year < TARGET_YEAR) all_folds = rolling_evaluation(leases, evaluation_years) display(all_folds[["target_year", "predicted_pct", "actual_pct", "error_pp", "predicted_face_pct", "actual_face_pct", "face_error_pp", "segment_mae_pp", "roster_units", "realized_pairs"]].round(3)) baselines = baseline_evaluation(leases, evaluation_years) scores = baselines.groupby("model").error_pp.agg(MAE_pp=lambda error: error.abs().mean(), bias_pp="mean", worst_pp=lambda error: error.abs().max()) scores.loc["Our method (fixed)"] = [all_folds.abs_error_pp.mean(), all_folds.error_pp.mean(), all_folds.abs_error_pp.max()] scores["MAE_2023_2025_pp"] = baselines[baselines.target_year.isin(REQUIRED_BACKTEST_YEARS)].groupby("model").error_pp.apply( lambda error: error.abs().mean()) scores.loc["Our method (fixed)", "MAE_2023_2025_pp"] = all_folds[all_folds.target_year.isin(REQUIRED_BACKTEST_YEARS)].abs_error_pp.mean() display(scores.round(3)) ax = all_folds.set_index("target_year")[["predicted_pct", "actual_pct"]].rename( columns={"predicted_pct": "Forecast at the previous 31 December", "actual_pct": "Realised"}).plot(marker="o", figsize=(9, 4)) ax.set(title="Backtest: simulated forecast vs realised (effective, same unit)", ylabel="% annualised", xlabel="Target year", xticks=list(evaluation_years)) plt.tight_layout() plt.show()
| target_year | predicted_pct | actual_pct | error_pp | |
|---|---|---|---|---|
| 0 | 2023 | 4.472 | 2.178 | 2.294 |
| 1 | 2024 | 2.620 | 1.951 | 0.669 |
| 2 | 2025 | 2.928 | 3.833 | -0.905 |
| target_year | predicted_pct | actual_pct | error_pp | predicted_face_pct | actual_face_pct | face_error_pp | segment_mae_pp | roster_units | realized_pairs | |
|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 2020 | 1.764 | 2.769 | -1.005 | 2.005 | 3.034 | -1.029 | 0.957 | 306 | 331 |
| 1 | 2021 | 2.743 | 3.753 | -1.010 | 2.875 | 4.384 | -1.509 | 1.149 | 353 | 355 |
| 2 | 2022 | 3.747 | 4.716 | -0.968 | 3.881 | 5.273 | -1.392 | 0.955 | 347 | 352 |
| 3 | 2023 | 4.472 | 2.178 | 2.294 | 4.581 | 2.089 | 2.492 | 2.248 | 402 | 416 |
| 4 | 2024 | 2.620 | 1.951 | 0.669 | 2.755 | 3.728 | -0.973 | 1.743 | 640 | 698 |
| 5 | 2025 | 2.928 | 3.833 | -0.905 | 3.589 | 7.559 | -3.970 | 1.314 | 845 | 780 |
| MAE_pp | bias_pp | worst_pp | MAE_2023_2025_pp | |
|---|---|---|---|---|
| model | ||||
| All history pooled | 1.282 | -0.503 | 1.841 | 1.138 |
| Last year pooled | 1.295 | -0.373 | 2.538 | 1.549 |
| Trailing 3 years pooled | 1.409 | -0.372 | 1.754 | 1.421 |
| Our method (fixed) | 1.142 | -0.154 | 2.294 | 1.289 |
Honest reading. Over the six years 2020–2025, our method has a lower average error than the three simple comparators (about 1.1 points, against 1.3 to 1.4) and the lowest bias. But its worst year (2023, where it extends the strong 2022 increase while the market slows) is worse than that of the historical average, and on the required 2023–2025 window the whole-history average does slightly better in this extract. We display this without replacing the method with the winner of years already consulted: that would be choosing after the fact. The contribution of our method is operational: it breaks down by building, by event type, into contractual (face) rent and concessions, which makes it possible to budget and to explain. Its predictive superiority is not demonstrated, and we do not claim it.
Roster coverage (realised pairs whose previous lease was indeed in the roster) is a diagnostic; it never changes the weights after the fact.
display(all_folds[["target_year", "realized_in_roster", "target_roster_coverage", "roster_observed_fraction", "segment_target_coverage"]].round(3))
| target_year | realized_in_roster | target_roster_coverage | roster_observed_fraction | segment_target_coverage | |
|---|---|---|---|---|---|
| 0 | 2020 | 303 | 0.915 | 0.990 | 1.000 |
| 1 | 2021 | 347 | 0.977 | 0.983 | 1.000 |
| 2 | 2022 | 342 | 0.972 | 0.986 | 1.000 |
| 3 | 2023 | 398 | 0.957 | 0.990 | 0.998 |
| 4 | 2024 | 629 | 0.901 | 0.983 | 0.990 |
| 5 | 2025 | 763 | 0.978 | 0.903 | 1.000 |
7.6 Reconciliation with public data
In brief: our figures are set side by side with CMHC, the CPI, the TAL and Ontario's guideline, province by province, to check their plausibility.
Details
For each public source available at 31 December 2025, we place next to it the internal forecast for the same province, contractual (face) and effective. For the TAL and the Ontario guideline, which regulate renewals, the comparison is made on the renewal cells only. These gaps help defend the assumptions, without proving causality: our recent portfolio, its concessions and its mix differ from a CMA's market. Vacancy is not subtracted from a growth rate (that would make no sense). These few series are not added as model variables: with six years, that would be overfitting (section 9 confirms this with public variables).
def external_comparison(lease_table, external_table, target_year=TARGET_YEAR): cells = forecast(make_bundle(lease_table, target_year))["cells"] rows = [] for source in external_asof(external_table, target_year).itertuples(): geography = source.geography.lower() province = "Ontario" if ("ontario" in geography or "ottawa" in geography) else "Quebec" # regimes never mixed province_cells = cells[cells.province.eq(province)] regulatory = "TAL" in source.metric or "guideline" in source.metric.lower() if regulatory: province_cells = province_cells[province_cells.event_type.eq("renewal")] face = PERCENT * np.average(province_cells.face, weights=province_cells.weight) effective = PERCENT * np.average(province_cells.effective, weights=province_cells.weight) is_vacancy = "vacancy" in source.metric.lower() rows.append({"geography": source.geography, "metric": source.metric, "reference_period": source.reference_period, "release_date": source.release_date, "public_value": source.value, "internal_scope": "renewal" if regulatory else "all events", "internal_face_pct": face, "internal_effective_pct": effective, "face_minus_benchmark_pp": np.nan if is_vacancy else face - source.value}) return pd.DataFrame(rows) display(external_comparison(leases, external).round(3))
| geography | metric | reference_period | release_date | public_value | internal_scope | internal_face_pct | internal_effective_pct | face_minus_benchmark_pp | |
|---|---|---|---|---|---|---|---|---|---|
| 0 | Quebec | TAL estimated average basic adjustment (lagged) | TAL 2025 | 2025-01-21 | 5.900 | renewal | 5.633 | 3.831 | -0.267 |
| 1 | Ontario | Ontario rent increase guideline | 2026 guideline | 2025-06-30 | 2.100 | renewal | 5.448 | 3.011 | 3.348 |
| 2 | Montreal CMA | CMHC fixed-sample average rent change | October 2025 | 2025-12-11 | 7.400 | all events | 6.350 | 4.876 | -1.050 |
| 3 | Ottawa-Gatineau CMA (Ontario part) | CMHC fixed-sample average rent change | October 2025 | 2025-12-11 | 3.500 | all events | 6.566 | 4.317 | 3.066 |
| 4 | Montreal CMA | CMHC apartment vacancy rate | October 2025 | 2025-12-11 | 2.900 | all events | 6.350 | 4.876 | NaN |
| 5 | Ottawa-Gatineau CMA (Ontario part) | CMHC apartment vacancy rate | October 2025 | 2025-12-11 | 3.000 | all events | 6.566 | 4.317 | NaN |
| 6 | Montreal CMA | Statistics Canada rented accommodation CPI yea... | November 2025 | 2025-12-15 | 7.368 | all events | 6.350 | 4.876 | -1.017 |
| 7 | Ontario | Statistics Canada rent CPI year-over-year | November 2025 | 2025-12-15 | 4.069 | all events | 6.566 | 4.317 | 2.497 |
Reading.
- Quebec, renewals: our forecast contractual (face) increase for Quebec renewals (about 5.6%) is close to the last TAL figure known at the origin (5.9%). The two are consistent.
- Montréal, market: CMHC (7.4%) and the CPI rent component (7.4%) are above our contractual (face) figure (6.35%). Our effective figure (4.9%) is lower still, because of the concessions that market indices do not measure.
- Ontario, The Met: The Met's renewals rose by about 6.3% in contractual (face) terms in 2025 (section 6), well above the guideline (2.5% for 2025, 2.1% for 2026). This is consistent with an exempt building, first occupied after 15 November 2018: its first lease in the extract dates from 2023 (section 3). This is not proof; the date of first occupancy remains to be confirmed. We therefore do not cap The Met in the central forecast, and the Ontario scenario below remains conditional.
Details
Our trend against the market, year by year. The chart places, for each province, our realised same-unit growth rates (contractual (face) and effective) next to the public series of the same year: the CMHC fixed-sample average rent (CMA), Statistics Canada's CPI rent component, and the regulation of renewals (TAL in Quebec, guideline in Ontario). The 2026 diamonds are our forecast.
FIRST_MARKET_YEAR = 2021 REFERENCE_YEAR_PATTERN = r"(\d{4})" MARKET_PANELS = (("Quebec", "Montreal CMA", "Montreal CMA", "TAL", "Quebec (5 buildings)"), ("Ontario", "Ottawa-Gatineau CMA (Ontario part)", "Ontario", "guideline", "Ontario (The Met)")) internal_by_province = eligible_pairs.groupby(["province", "event_year"]).agg( face_pct=("face_growth", lambda growth: PERCENT * growth.mean()), effective_pct=("effective_growth", lambda growth: PERCENT * growth.mean())) forecast_cells = forecast_2026["cells"] public_series = external.assign(reference_year=external.reference_period.str.extract(REFERENCE_YEAR_PATTERN, expand=False).astype(int)) fig, axes = plt.subplots(1, 2, figsize=(14, 4.5), sharey=True) for ax, (province, cma, cpi_geography, regulator, title) in zip(axes, MARKET_PANELS): internal = internal_by_province.loc[province].loc[FIRST_MARKET_YEAR:LAST_OBSERVED_YEAR] province_cells = forecast_cells[forecast_cells.province.eq(province)] ax.plot(internal.index, internal.face_pct, "o-", color="tab:blue", label="Équinoxe, contractual (same unit)") ax.plot(internal.index, internal.effective_pct, "o-", color="tab:orange", label="Équinoxe, effective (same unit)") ax.plot([TARGET_YEAR], [PERCENT * np.average(province_cells.face, weights=province_cells.weight)], "D", color="tab:blue", ms=9) ax.plot([TARGET_YEAR], [PERCENT * np.average(province_cells.effective, weights=province_cells.weight)], "D", color="tab:orange", ms=9, label="Our 2026 forecast") cmhc = public_series[public_series.geography.eq(cma) & public_series.metric.str.contains("fixed-sample")] cpi = public_series[public_series.geography.eq(cpi_geography) & public_series.metric.str.contains("CPI")] rules = public_series[public_series.metric.str.contains(regulator)] ax.plot(cmhc.reference_year, cmhc.value, "s--", color="tab:green", label="CMHC, fixed-sample average rent (CMA)") ax.plot(cpi.reference_year, cpi.value, "^--", color="tab:purple", label="CPI rent component (Statistics Canada)") ax.plot(rules.reference_year, rules.value, "x:", color="black", ms=8, label="Renewal regulation (TAL / guideline)") ax.set(title=title, xlabel="Year", xticks=list(range(FIRST_MARKET_YEAR, TARGET_YEAR + 1))) ax.grid(alpha=0.3) axes[0].set_ylabel("% per year") axes[1].legend(loc="upper left", bbox_to_anchor=(1.02, 1), fontsize=9) plt.tight_layout() plt.show()
Reading. In Quebec, our same-unit contractual (face) growth is at the market level (CMHC) in 2021–2022, then falls clearly behind in 2023–2024: it then tracks the TAL almost exactly (2.1% against 2.3% in 2023, 3.7% against 4.0% in 2024), while CMHC and the CPI stay above 6%. In 2025 it catches up with the market (7.6% against 7.4% for CMHC). Our effective growth has stayed below the indices since 2023: the public indices do not measure free months, whereas we do. In Ontario, the Ottawa market slows in 2025 (CMHC 3.5%) while The Met's contractual (face) rents accelerate; effective growth, for its part, stays moderate. These public series serve to check the plausibility of our forecast, not to compute it: section 9 shows that adding them to the model does not improve the backtests.
Does the TAL help forecast Quebec renewals? Test fixed before running it. Over 2023–2025, the rule "last year + (this year's TAL − last year's TAL)" for the contractual (face) growth of Quebec renewals must beat both the average of earlier years and persistence, on average error and on worst year. No fitted slope: there are only three TAL values before the last year. We even assume the year's TAL is known (published in January), which favours the hypothesis. If the test fails, the TAL remains context and the regulatory scenarios are sensitivities, not forecasts.
def tal_passthrough_test(lease_table, external_table, test_years=REQUIRED_BACKTEST_YEARS): is_tal = external_table["metric"].str.contains("TAL", na=False) tal_by_year = {int(str(period).split()[-1]): float(value) for period, value in zip(external_table.loc[is_tal, "reference_period"], external_table.loc[is_tal, "value"])} all_pairs = build_pairs(lease_table) quebec_renewals = all_pairs[all_pairs["eligible"] & all_pairs["province"].eq("Quebec") & all_pairs["event_type"].eq("renewal")] actual_by_year = PERCENT * quebec_renewals.groupby("event_year")["face_growth"].mean() rows = [] for year in test_years: if year not in tal_by_year or year - 1 not in tal_by_year: raise DataContractError(f"TAL values for {year - 1} and {year} required") earlier = actual_by_year[actual_by_year.index < year] rows.append({"year": year, "actual_pct": float(actual_by_year[year]), "all_prior_mean_pct": float(earlier.mean()), "persistence_pct": float(earlier.iloc[-1]), "tal_passthrough_pct": float(earlier.iloc[-1]) + tal_by_year[year] - tal_by_year[year - 1], "tal_pct": tal_by_year[year]}) table = pd.DataFrame(rows) errors = {method: (table[f"{method}_pct"] - table["actual_pct"]).abs() for method in ("all_prior_mean", "persistence", "tal_passthrough")} best_baseline = min(("all_prior_mean", "persistence"), key=lambda method: errors[method].mean()) verdict = {"mae_pp": {method: float(error.mean()) for method, error in errors.items()}, "worst_pp": {method: float(error.max()) for method, error in errors.items()}, "criterion": "the TAL must beat both comparators on average error and on worst year"} verdict["retained"] = bool(errors["tal_passthrough"].mean() < min(errors["all_prior_mean"].mean(), errors["persistence"].mean()) and errors["tal_passthrough"].max() <= errors[best_baseline].max()) return table, verdict tal_table, tal_verdict = tal_passthrough_test(leases, external) display(tal_table.round(2)) print(tal_verdict)
| year | actual_pct | all_prior_mean_pct | persistence_pct | tal_passthrough_pct | tal_pct | |
|---|---|---|---|---|---|---|
| 0 | 2023 | 2.55 | 3.92 | 5.83 | 6.85 | 2.3 |
| 1 | 2024 | 2.91 | 3.69 | 2.55 | 4.25 | 4.0 |
| 2 | 2025 | 6.53 | 3.58 | 2.91 | 4.81 | 5.9 |
{'mae_pp': {'all_prior_mean': 1.700063443886726, 'persistence': 2.419597978689527, 'tal_passthrough': 2.449407693602618}, 'worst_pp': {'all_prior_mean': 2.9456759355395805, 'persistence': 3.6143143314996435, 'tal_passthrough': 4.299194176938574}, 'criterion': 'the TAL must beat both comparators on average error and on worst year', 'retained': False}
2026 regulatory sensitivities (published after the origin). The 2026 TAL figure (new method, three-year average of the CPI) and the 2026 Ontario guideline
are read from external_post_origin. The renewal cells are brought back to the anchor level when they exceed it, keeping the modelled change
in the concession gap. On the Ontario side, the guideline covers only rent-controlled units; buildings first occupied
after 15 November 2018 are exempt, and The Met's date of first occupancy is not in the extract: this scenario is conditional.
No cap is applied from one province to the other.
ANCHOR_PLAUSIBLE_RANGE_PCT = (-5.0, 25.0) # safeguard against an erroneous anchor entry TAL_2026_PCT = float(external_post_origin.loc[external_post_origin.metric.str.startswith("TAL 2026"), "value"].iloc[0]) ONTARIO_2026_PCT = float(external_post_origin.loc[external_post_origin.metric.eq("Ontario rent increase guideline"), "value"].iloc[0]) def regulatory_scenarios(cells, quebec_renewal_face_pct, ontario_renewal_face_pct): for name, anchor_pct in (("quebec", quebec_renewal_face_pct), ("ontario", ontario_renewal_face_pct)): if not np.isfinite(anchor_pct) or not ANCHOR_PLAUSIBLE_RANGE_PCT[0] < anchor_pct < ANCHOR_PLAUSIBLE_RANGE_PCT[1]: raise DataContractError(f"implausible {name} anchor: {anchor_pct}") def reprice(label, anchors): repriced = cells.copy() for province, anchor_pct in anchors.items(): renewal_cells = repriced["province"].eq(province) & repriced["event_type"].eq("renewal") excess = (repriced.loc[renewal_cells, "face"] - anchor_pct / PERCENT).clip(lower=0.0) repriced.loc[renewal_cells, "effective"] -= excess repriced.loc[renewal_cells, "face"] -= excess return {"scenario": label, "effective_pct": PERCENT * aggregate(repriced), "face_pct": PERCENT * aggregate(repriced, "face")} return pd.DataFrame([ reprice("Central forecast", {}), reprice(f"Quebec renewals at the 2026 TAL ({quebec_renewal_face_pct:g}%)", {"Quebec": quebec_renewal_face_pct}), reprice(f"Ontario renewals at the guideline ({ontario_renewal_face_pct:g}%), if The Met is rent-controlled", {"Ontario": ontario_renewal_face_pct}), reprice("Both anchors", {"Quebec": quebec_renewal_face_pct, "Ontario": ontario_renewal_face_pct})]) display(external_post_origin[["geography", "metric", "value", "release_date", "limitation"]]) display(regulatory_scenarios(forecast_2026["cells"], TAL_2026_PCT, ONTARIO_2026_PCT).round(3))
| geography | metric | value | release_date | limitation | |
|---|---|---|---|---|---|
| 0 | Quebec | TAL 2026 base rent-adjustment percentage | 3.10 | 2026-01 | Published AFTER the 2025-12-31 forecast origin... |
| 1 | Ontario | Ontario rent increase guideline | 2.10 | 2025-06-30 | Applies only to rent-controlled units; buildin... |
| 2 | Quebec | Statistics Canada rent CPI year-over-year, Aug... | 3.02 | 2026-09-14 | Single monthly reading; monthly rent CPI is no... |
| 3 | Ontario | Statistics Canada rent CPI year-over-year, Aug... | 2.37 | 2026-09-14 | Single monthly reading; not used in any estimate. |
| scenario | effective_pct | face_pct | |
|---|---|---|---|
| 0 | Central forecast | 4.805 | 6.378 |
| 1 | Quebec renewals at the 2026 TAL (3.1%) | 3.465 | 5.038 |
| 2 | Ontario renewals at the guideline (2.1%), if T... | 4.557 | 6.130 |
| 3 | Both anchors | 3.217 | 4.790 |
7.7 Scenarios and historical envelope
Concessions can separate contractual (face) from effective growth sharply: we shift the gap of new leases by ±2 points, and the turnover share by ±10 points. The gap scenarios are compared with the structural reference (BRIDGE alone with current concessions); the turnover ones, with the central forecast. These are transparent assumptions, not probabilities.
The envelope = central forecast ± the largest annual error observed in the backtest. With six years, a regime change and years already consulted, this is not a confidence interval: it is the order of magnitude of past error.
CONCESSION_GAP_SHOCK = 0.02 # ±2 points of concession gap on new leases TURNOVER_SHARE_SHOCK = 0.10 # ±10 points of turnover share def scenario_table(bundle): central = combine(component_cells(bundle)) rows = [] def add(label, cells): rows.append({"scenario": label, "effective_pct": PERCENT * aggregate(cells), "face_pct": PERCENT * aggregate(cells, "face")}) add("Central forecast", central) for label, gap_change in (("Structural reference: current concessions", 0.0), (f"Structural: concession gap +{PERCENT * CONCESSION_GAP_SHOCK:g} points", CONCESSION_GAP_SHOCK), (f"Structural: concession gap −{PERCENT * CONCESSION_GAP_SHOCK:g} points", -CONCESSION_GAP_SHOCK)): shocked = central.copy() shocked["effective"] = [roster_bridge(bundle, cell.property_code, cell.face, float(np.clip(cell.new_gap_persistence + gap_change, *SPEC.gap_clip))) for cell in shocked.itertuples()] add(label, shocked) for share_change in (TURNOVER_SHARE_SHOCK, -TURNOVER_SHARE_SHOCK): shocked = central.copy() for property_code in shocked.property_code.unique(): turnover = shocked.property_code.eq(property_code) & shocked.event_type.eq("turnover") renewal = shocked.property_code.eq(property_code) & shocked.event_type.eq("renewal") turnover_share = float(np.clip(shocked.loc[turnover, "event_share"].iloc[0] + share_change, 0, 1)) shocked.loc[turnover, "event_share"] = turnover_share shocked.loc[renewal, "event_share"] = 1 - turnover_share shocked["expected_units"] = shocked.roster_units * shocked.event_share shocked["weight"] = shocked.expected_units / shocked.expected_units.sum() add(f"Turnover share {PERCENT * share_change:+.0f} points", shocked) return pd.DataFrame(rows) scenarios = scenario_table(make_bundle(leases, TARGET_YEAR)) display(scenarios.round(3)) largest_backtest_error_pp = float(all_folds.abs_error_pp.max()) historical_envelope_pct = [estimate_pct - largest_backtest_error_pp, estimate_pct + largest_backtest_error_pp] print(f"Descriptive historical envelope: {historical_envelope_pct[0]:.2f}% to {historical_envelope_pct[1]:.2f}% " f"(central ± largest annual backtest error, {largest_backtest_error_pp:.2f} pts; not a confidence interval)")
| scenario | effective_pct | face_pct | |
|---|---|---|---|
| 0 | Central forecast | 4.805 | 6.378 |
| 1 | Structural reference: current concessions | 6.325 | 6.378 |
| 2 | Structural: concession gap +2 points | 4.012 | 6.378 |
| 3 | Structural: concession gap −2 points | 8.638 | 6.378 |
| 4 | Turnover share +10 points | 5.077 | 6.575 |
| 5 | Turnover share -10 points | 4.532 | 6.181 |
Descriptive historical envelope: 2.51% to 7.10% (central ± largest annual backtest error, 2.29 pts; not a confidence interval)
8. Bonus: forecast by building and by number of bedrooms, small dashboardBy building: the model's cells, aggregated by sBuilding.
8. Bonus: forecast by building and by number of bedrooms, small dashboard
By building: the model's cells, aggregated by sBuilding.
Details
By number of bedrooms: an offset per unit type, computed over the last three years known at the origin and shrunk toward zero (k = 20), is added and then
re-centred on each building's roster, so that each building reconciles exactly with its forecast.
This offset is only useful if it predicts better than "no bedroom effect": the second table checks this on the same origins as the backtest.
If it does not, the bedroom table remains descriptive.
BEDROOM_SHRINK_K = 20.0 # same shrinkage strength as the renewal share BEDROOM_TRAILING_YEARS = 3 # same window as the renewal share building_names = validated_leases.groupby("sPropCode")["sBuilding"].first().rename("building") def building_forecast(details_cells): cells = details_cells.merge(building_names, left_on="property_code", right_index=True, validate="many_to_one") return pd.DataFrame([{"building": name, "expected_events": building_cells.expected_units.sum(), "face_pct": PERCENT * np.average(building_cells.face, weights=building_cells.weight), "effective_pct": PERCENT * np.average(building_cells.effective, weights=building_cells.weight)} for name, building_cells in cells.groupby("building")]) def bedroom_offsets(pairs_table, target_year): recent = pairs_table[pairs_table["event_year"].between(target_year - BEDROOM_TRAILING_YEARS, target_year - 1)] if recent.empty: raise DataContractError("no recent pair to estimate the bedroom offsets") effective = clip_for_training(recent["effective_growth"], "effective_growth") face = clip_for_training(recent["face_growth"], "face_growth") rows = [] for bedrooms, index in recent.groupby("bedrooms").groups.items(): n_pairs = len(index) shrink = n_pairs / (n_pairs + BEDROOM_SHRINK_K) rows.append({"bedrooms": bedrooms, "n_pairs": n_pairs, "effective_offset": shrink * float(effective.loc[index].mean() - effective.mean()), "face_offset": shrink * float(face.loc[index].mean() - face.mean())}) return pd.DataFrame(rows) def bedroom_forecast(lease_table, details_cells, target_year=TARGET_YEAR): bundle = make_bundle(lease_table, target_year) offsets = bedroom_offsets(bundle.pairs, target_year).set_index("bedrooms") roster = bundle.roster[bundle.roster["comparable"]].merge(building_names, left_on="property_code", right_index=True, validate="many_to_one") cells = details_cells.merge(building_names, left_on="property_code", right_index=True, validate="many_to_one") rows = [] for building, units in roster.groupby("building"): building_cells = cells[cells["building"].eq(building)] base = {column: float(np.average(building_cells[column], weights=building_cells["weight"])) for column in ("effective", "face")} units_by_bedrooms = units.groupby("bedrooms").size() for column in ("effective", "face"): building_offsets = offsets[f"{column}_offset"].reindex(units_by_bedrooms.index).fillna(0.0) base[f"{column}_recentre"] = float(np.average(building_offsets, weights=units_by_bedrooms)) for bedrooms, n_units in units_by_bedrooms.items(): offset = offsets.reindex([bedrooms]).fillna(0.0).iloc[0] rows.append({"building": building, "bedrooms": bedrooms, "roster_units": int(n_units), "effective_pct": PERCENT * (base["effective"] + offset["effective_offset"] - base["effective_recentre"]), "face_pct": PERCENT * (base["face"] + offset["face_offset"] - base["face_recentre"])}) return pd.DataFrame(rows) def bedroom_offset_backtest(lease_table, target_years): # Do the bedroom offsets predict the realised spread of the following year better than "no effect"? rows = [] for year in target_years: bundle = make_bundle(lease_table, year) offsets = bedroom_offsets(bundle.pairs, year).set_index("bedrooms")["effective_offset"] actual_pairs = realized(lease_table, year) realized_spread = actual_pairs.groupby("bedrooms")["effective_growth"].mean() - actual_pairs["effective_growth"].mean() pairs_per_group = actual_pairs.groupby("bedrooms").size() common = realized_spread.index.intersection(offsets.index) weights = pairs_per_group.loc[common] rows.append({"target_year": year, "bedroom_groups": len(common), "mae_with_offsets_pp": PERCENT * float(np.average((offsets.loc[common] - realized_spread.loc[common]).abs(), weights=weights)), "mae_no_bedroom_effect_pp": PERCENT * float(np.average(realized_spread.loc[common].abs(), weights=weights))}) check = pd.DataFrame(rows) check["offsets_better"] = check["mae_with_offsets_pp"] < check["mae_no_bedroom_effect_pp"] return check by_building = building_forecast(forecast_2026["cells"]) display(by_building.round(3)) by_bedroom = bedroom_forecast(leases, forecast_2026["cells"]) display(by_bedroom.round(2)) bedroom_check = bedroom_offset_backtest(leases, SPEC.evaluation_years) display(bedroom_check.round(3)) print("Years in which the bedroom offsets beat \"no effect\":", int(bedroom_check.offsets_better.sum()), "out of", len(bedroom_check))
| building | expected_events | face_pct | effective_pct | |
|---|---|---|---|---|
| 0 | Daniel-Johnson | 128.0 | 6.055 | 4.937 |
| 1 | Le Carlyle | 180.0 | 6.428 | 4.522 |
| 2 | Levesque | 75.0 | 5.745 | 4.749 |
| 3 | Saint-Elzear | 267.0 | 6.017 | 4.967 |
| 4 | The Met | 119.0 | 6.566 | 4.317 |
| 5 | Westpark | 159.0 | 7.347 | 5.137 |
| building | bedrooms | roster_units | effective_pct | face_pct | |
|---|---|---|---|---|---|
| 0 | Daniel-Johnson | 0 | 12 | 4.98 | 6.34 |
| 1 | Daniel-Johnson | 1 | 54 | 4.94 | 6.24 |
| 2 | Daniel-Johnson | 2 | 58 | 4.94 | 5.82 |
| 3 | Daniel-Johnson | 3 | 4 | 4.71 | 6.09 |
| 4 | Le Carlyle | 1 | 68 | 4.53 | 6.69 |
| 5 | Le Carlyle | 2 | 107 | 4.53 | 6.26 |
| 6 | Le Carlyle | 3 | 5 | 4.30 | 6.53 |
| 7 | Levesque | 1 | 9 | 4.76 | 6.11 |
| 8 | Levesque | 2 | 64 | 4.75 | 5.69 |
| 9 | Levesque | 3 | 2 | 4.53 | 5.96 |
| 10 | Saint-Elzear | 1 | 77 | 4.98 | 6.31 |
| 11 | Saint-Elzear | 2 | 182 | 4.97 | 5.89 |
| 12 | Saint-Elzear | 3 | 8 | 4.75 | 6.16 |
| 13 | The Met | 0 | 37 | 4.34 | 6.76 |
| 14 | The Met | 1 | 46 | 4.31 | 6.67 |
| 15 | The Met | 2 | 36 | 4.30 | 6.24 |
| 16 | Westpark | 1 | 61 | 5.15 | 7.61 |
| 17 | Westpark | 2 | 95 | 5.14 | 7.18 |
| 18 | Westpark | 3 | 3 | 4.91 | 7.45 |
| target_year | bedroom_groups | mae_with_offsets_pp | mae_no_bedroom_effect_pp | offsets_better | |
|---|---|---|---|---|---|
| 0 | 2020 | 3 | 0.183 | 0.108 | False |
| 1 | 2021 | 3 | 0.071 | 0.073 | True |
| 2 | 2022 | 3 | 0.212 | 0.256 | True |
| 3 | 2023 | 3 | 0.162 | 0.087 | False |
| 4 | 2024 | 4 | 0.349 | 0.194 | False |
| 5 | 2025 | 4 | 0.124 | 0.122 | False |
Years in which the bedroom offsets beat "no effect": 2 out of 6
# Dashboard: 2026 forecast by building, contractual (face) and effective, with the descriptive historical envelope. dashboard = by_building.set_index("building") envelope_half_width_pp = (historical_envelope_pct[1] - historical_envelope_pct[0]) / 2 rows_position = np.arange(len(dashboard)) BAR_HEIGHT = 0.4 fig, ax = plt.subplots(figsize=(9, 4)) ax.barh(rows_position + BAR_HEIGHT / 2, dashboard.face_pct, BAR_HEIGHT, label="Contractual") ax.barh(rows_position - BAR_HEIGHT / 2, dashboard.effective_pct, BAR_HEIGHT, xerr=envelope_half_width_pp, capsize=3, label="Effective (± historical envelope)") ax.set_yticks(rows_position, [f"{name} ({events:.0f} events)" for name, events in zip(dashboard.index, dashboard.expected_events)]) ax.axvline(estimate_pct, color="black", ls="--", lw=1, label=f"Effective portfolio: {estimate_pct:.2f}%") ax.set(title="2026 forecast by building", xlabel="% annualised, same unit") ax.legend(loc="upper center", bbox_to_anchor=(0.5, -0.18), ncol=3) plt.tight_layout() plt.show()
8b. Why this figure? A building-by-building readingA manager does not only want "+4.9%": they want to know why. For each building, the forecast reconciles exactly into three pieces:
8b. Why this figure? A building-by-building reading
A manager does not only want "+4.9%": they want to know why. For each building, the forecast reconciles exactly into three pieces:
Details
- Renewals: expected share of 2026 leases that are renewals × the contractual (face) increase forecast for them;
- Turnovers: expected share of turnovers × the contractual (face) increase forecast for them;
- Concession adjustment: the difference between the building's effective forecast and its contractual (face) forecast, in percentage points.
Renewals + turnovers = the building's contractual (face) increase; plus the concession adjustment = effective increase. The cell checks that the sum lands exactly on the section 8 forecast. One honest precision: the contractual forecast combines two readings and the effective forecast combines three (section 7.3), so this difference is a reconciliation between the model's two forecasts, not an isolated measurement of the causal effect of free months. Read it as "what the model expects concessions to take back"; isolating a purely causal effect would need a counterfactual that changes only the concessions. We add two reference points taken from the data: the effective increase realised in 2025 (recent momentum) and the building's long-term average (the trend).
Finally, the public context around each building (public file building_context.csv: residential construction permitted within 2 km,
rapid transit, location score, flood zone, heat island). This context helps to discuss the figure; it does not enter the computation,
because none of these variables improved the backtests.
def explain_building(cells): # Exact reconciliation of one building: renewals + turnovers + concession adjustment (effective − contractual) = effective. weights = cells["weight"] renewal = cells["event_type"].eq("renewal") renewal_share = float(weights[renewal].sum() / weights.sum()) face_renewal = float(np.average(cells.loc[renewal, "face"], weights=weights[renewal])) face_turnover = float(np.average(cells.loc[~renewal, "face"], weights=weights[~renewal])) face = float(np.average(cells["face"], weights=weights)) effective = float(np.average(cells["effective"], weights=weights)) return {"renewal_share": renewal_share, "renewal_face_pct": PERCENT * face_renewal, "turnover_face_pct": PERCENT * face_turnover, "renewals_pp": PERCENT * renewal_share * face_renewal, "turnovers_pp": PERCENT * (1 - renewal_share) * face_turnover, "concessions_pp": -PERCENT * (face - effective), "face_pct": PERCENT * face, "effective_pct": PERCENT * effective, "momentum_DIRECT_pct": PERCENT * float(np.average(cells["DIRECT"], weights=weights)), "trend_ANCHOR_pct": PERCENT * float(np.average(cells["ANCHOR"], weights=weights))} cells_with_building = forecast_2026["cells"].merge(building_names, left_on="property_code", right_index=True, validate="many_to_one") why = pd.DataFrame({building: explain_building(cells) for building, cells in cells_with_building.groupby("building")}).T pairs_with_building = eligible_pairs.merge(building_names, left_on="property_code", right_index=True, validate="many_to_one") why["realised_2025_effective_pct"] = PERCENT * pairs_with_building[pairs_with_building.event_year.eq(LAST_OBSERVED_YEAR)].groupby( "building").effective_growth.mean() why = why.sort_values("effective_pct", ascending=False) # The three pieces land exactly on the by-building forecast of section 8 (an arithmetic check, not a causal attribution). np.testing.assert_allclose(why.renewals_pp + why.turnovers_pp + why.concessions_pp, why.effective_pct, atol=1e-9) np.testing.assert_allclose(why.effective_pct, by_building.set_index("building").loc[why.index, "effective_pct"], atol=1e-9) display(why.round(2))
| renewal_share | renewal_face_pct | turnover_face_pct | renewals_pp | turnovers_pp | concessions_pp | face_pct | effective_pct | momentum_DIRECT_pct | trend_ANCHOR_pct | realised_2025_effective_pct | |
|---|---|---|---|---|---|---|---|---|---|---|---|
| Westpark | 0.62 | 6.14 | 9.34 | 3.83 | 3.52 | -2.21 | 7.35 | 5.14 | 3.80 | 3.81 | 3.78 |
| Saint-Elzear | 0.60 | 5.47 | 6.84 | 3.29 | 2.73 | -1.05 | 6.02 | 4.97 | 4.39 | 3.17 | 4.64 |
| Daniel-Johnson | 0.60 | 5.20 | 7.33 | 3.11 | 2.94 | -1.12 | 6.05 | 4.94 | 4.14 | 2.96 | 4.41 |
| Levesque | 0.55 | 5.38 | 6.19 | 2.96 | 2.78 | -1.00 | 5.75 | 4.75 | 3.59 | 2.89 | 3.17 |
| Le Carlyle | 0.63 | 5.80 | 7.50 | 3.65 | 2.78 | -1.91 | 6.43 | 4.52 | 3.59 | 2.55 | 3.54 |
| The Met | 0.58 | 5.45 | 8.10 | 3.15 | 3.42 | -2.25 | 6.57 | 4.32 | 3.01 | 2.59 | 3.12 |
WATERFALL_STEPS = [("renewals_pp", "Renewals", "tab:blue"), ("turnovers_pp", "Turnovers", "tab:cyan"), ("concessions_pp", "Concession adjustment / Ajustement de concession", "tab:red")] fig, axes = plt.subplots(2, 3, figsize=(14, 7), sharey=True) for ax, (building, row) in zip(axes.ravel(), why.iterrows()): level = 0.0 for position, (column, label, color) in enumerate(WATERFALL_STEPS): ax.bar(position, row[column], bottom=level, color=color) ax.text(position, level + row[column] / 2, f"{row[column]:+.1f}", ha="center", va="center", color="white", fontweight="bold") level += row[column] ax.bar(len(WATERFALL_STEPS), row.effective_pct, color="black") ax.text(len(WATERFALL_STEPS), row.effective_pct / 2, f"{row.effective_pct:.1f}%", ha="center", va="center", color="white", fontweight="bold") ax.set_xticks(range(len(WATERFALL_STEPS) + 1), [label for _, label, _ in WATERFALL_STEPS] + ["Effective\n2026"]) ax.set_title(building) ax.axhline(0, color="grey", lw=0.8) axes[0, 0].set_ylabel("percentage points") axes[1, 0].set_ylabel("percentage points") fig.suptitle("What makes up each building's 2026 increase", fontsize=13) plt.tight_layout() plt.show()
In words, building by building. The text below is generated from the figures of the breakdown and from the public context file, so that it always stays consistent with them.
from IPython.display import Markdown FLOOD_ZONE_NEAR_M = 250 # below this distance, proximity to a flood zone is worth flagging building_context = pd.read_csv("building_context.csv").set_index("building") def en_number(value, decimals=1): return f"{value:,.{decimals}f}" def building_story(building, row, context): lines = [f"**{building}: {en_number(row.effective_pct)}% effective** ({en_number(row.face_pct)}% contractual).", f"On paper, rents rise by {en_number(row.face_pct)}%: about {en_number(PERCENT * row.renewal_share, 0)}% of the expected leases " f"are renewals (forecast at +{en_number(row.renewal_face_pct)}%) and the rest turnovers (+{en_number(row.turnover_face_pct)}%). " f"Concessions take back {en_number(-row.concessions_pp)} {'point' if en_number(-row.concessions_pp) == '1.0' else 'points'}.", f"Reference points: in {LAST_OBSERVED_YEAR}, its same-unit effective rents rose by {en_number(row.realised_2025_effective_pct)}%; " f"its long-term trend is {en_number(row.trend_ANCHOR_pct)}% a year."] notes = [f"{en_number(context.rapid_transit_distance_m, 0)} m from {context.nearest_rapid_transit}", f"location score {context.location_score_0_100}/100"] if pd.notna(context.units_permitted_2km_2023_2025): notes.append(f"{en_number(context.units_permitted_2km_2023_2025, 0)} units permitted within 2 km in 2023–2025 " "(new supply to watch: more supply tends to increase concessions)") else: notes.append("building permits not published by the city") if context.flood_zone_distance_m < FLOOD_ZONE_NEAR_M: notes.append(f"{context.flood_zone_distance_m} m from a mapped flood zone") if context.heat_island is True or str(context.heat_island) == "True": notes.append("urban heat island") lines.append("Public context (not used in the calculation): " + "; ".join(notes) + ".") return "\n\n".join(lines) display(Markdown("\n\n---\n\n".join(building_story(building, row, building_context.loc[building]) for building, row in why.iterrows())))
Westpark: 5.1% effective (7.3% contractual).
On paper, rents rise by 7.3%: about 62% of the expected leases are renewals (forecast at +6.1%) and the rest turnovers (+9.3%). The concession adjustment (the gap between the effective and contractual forecasts) takes back 2.2 points.
Reference points: in 2025, its same-unit effective rents rose by 3.8%; its long-term trend is 3.8% a year.
Public context (not used in the calculation): 1,143 m from Fairview-Pointe-Claire (REM); location score 43/100; building permits not published by the city.
Saint-Elzear: 5.0% effective (6.0% contractual).
On paper, rents rise by 6.0%: about 60% of the expected leases are renewals (forecast at +5.5%) and the rest turnovers (+6.8%). The concession adjustment (the gap between the effective and contractual forecasts) takes back 1.0 point.
Reference points: in 2025, its same-unit effective rents rose by 4.6%; its long-term trend is 3.2% a year.
Public context (not used in the calculation): 4,566 m from Montmorency (metro); location score 19/100; 2,173 units permitted within 2 km in 2023–2025 (new supply to watch: more supply tends to increase concessions).
Daniel-Johnson: 4.9% effective (6.1% contractual).
On paper, rents rise by 6.1%: about 60% of the expected leases are renewals (forecast at +5.2%) and the rest turnovers (+7.3%). The concession adjustment (the gap between the effective and contractual forecasts) takes back 1.1 points.
Reference points: in 2025, its same-unit effective rents rose by 4.4%; its long-term trend is 3.0% a year.
Public context (not used in the calculation): 2,127 m from Montmorency (metro); location score 58/100; 3,944 units permitted within 2 km in 2023–2025 (new supply to watch: more supply tends to increase concessions); urban heat island.
Levesque: 4.7% effective (5.7% contractual).
On paper, rents rise by 5.7%: about 55% of the expected leases are renewals (forecast at +5.4%) and the rest turnovers (+6.2%). The concession adjustment (the gap between the effective and contractual forecasts) takes back 1.0 point.
Reference points: in 2025, its same-unit effective rents rose by 3.2%; its long-term trend is 2.9% a year.
Public context (not used in the calculation): 2,047 m from Bois-Franc (REM); location score 32/100; 3,692 units permitted within 2 km in 2023–2025 (new supply to watch: more supply tends to increase concessions); 54 m from a mapped flood zone.
Le Carlyle: 4.5% effective (6.4% contractual).
On paper, rents rise by 6.4%: about 63% of the expected leases are renewals (forecast at +5.8%) and the rest turnovers (+7.5%). The concession adjustment (the gap between the effective and contractual forecasts) takes back 1.9 points.
Reference points: in 2025, its same-unit effective rents rose by 3.5%; its long-term trend is 2.5% a year.
Public context (not used in the calculation): 526 m from De La Savane (metro); location score 59/100; 1,077 units permitted within 2 km in 2023–2025 (new supply to watch: more supply tends to increase concessions).
The Met: 4.3% effective (6.6% contractual).
On paper, rents rise by 6.6%: about 58% of the expected leases are renewals (forecast at +5.4%) and the rest turnovers (+8.1%). The concession adjustment (the gap between the effective and contractual forecasts) takes back 2.2 points.
Reference points: in 2025, its same-unit effective rents rose by 3.1%; its long-term trend is 2.6% a year.
Public context (not used in the calculation): 479 m from Parliament (O-Train); location score 86/100; 526 units permitted within 2 km in 2023–2025 (new supply to watch: more supply tends to increase concessions).
9. Does a regression model do better? XGBoost and Ridge, tested and rejectedThe brief allows a regression model if it is justified. We tested it honestly, with a rule written before the first run:
9. Does a regression model do better? XGBoost and Ridge, tested and rejected
The brief allows a regression model if it is justified. We tested it honestly, with a rule written before the first run:
Details
- XGBoost (depth 3, 300 trees, learning rate 0.05, subsampling 0.8) and Ridge (alpha 10 on standardised variables), trained at the pair level, using only information known at the origin: event type, province, bedrooms, interval, previous concession gap, start month, building age, rent position within the building, and last year's averages (cell and portfolio).
- Same origins, same roster, same aggregation and same target as our method.
- Replacement rule: a model replaces our method only if its average error is lower than ours and than that of the historical average, over 2020–2025 and over 2023–2025, with no worst year worse than ours. No hyperparameter was tuned on these years.
The SHAP contributions explain XGBoost's 2026 forecast, variable by variable. Since the model is not retained, no trained model file is delivered
(the parameters of our method are in ForecastSpec, section 4b).
from sklearn.linear_model import Ridge from sklearn.pipeline import make_pipeline from sklearn.preprocessing import StandardScaler RANDOM_SEED = 20_260_101 XGB_PARAMS = dict(max_depth=3, n_estimators=300, learning_rate=0.05, subsample=0.8, colsample_bytree=0.8, min_child_weight=10, reg_lambda=1.0, random_state=RANDOM_SEED, n_jobs=1) RIDGE_ALPHA = 10.0 LAG_FEATURES = ["lag_cell_face", "lag_cell_eff", "lag_cell_gap", "lag_port_face", "lag_port_gap"] MODEL_FEATURES = ["is_renewal", "is_ontario", "bedrooms", "interval_years", "prior_gap", "start_month", "property_age", "rent_position"] + LAG_FEATURES def lagged_means(known_pairs): # Previous year's averages, by province × event and for the portfolio. cell = known_pairs.groupby(["event_year", "province", "event_type"]).agg( lag_cell_face=("face_growth", "mean"), lag_cell_eff=("effective_growth", "mean"), lag_cell_gap=("new_gap", "mean")).reset_index() portfolio = known_pairs.groupby("event_year").agg(lag_port_face=("face_growth", "mean"), lag_port_gap=("new_gap", "mean")).reset_index() cell["event_year"] += 1 portfolio["event_year"] += 1 return cell, portfolio def model_features(rows, known_pairs): cell, portfolio = lagged_means(known_pairs) first_year = known_pairs.groupby("property_code")["event_year"].min() mean_prior_rent = known_pairs.groupby("property_code")["prior_face_rent"].mean() features = rows.merge(cell, on=["event_year", "province", "event_type"], how="left").merge(portfolio, on="event_year", how="left") features["is_renewal"] = features["event_type"].eq("renewal").astype(float) features["is_ontario"] = features["province"].eq("Ontario").astype(float) features["property_age"] = features["event_year"] - features["property_code"].map(first_year).fillna(features["event_year"]) features["rent_position"] = features["prior_face_rent"] / features["property_code"].map(mean_prior_rent).fillna(features["prior_face_rent"]) features["bedrooms"] = features["bedrooms"].astype(float) return features def roster_rows(bundle): # One row per roster unit and per event type, weighted by the expected share. roster = bundle.roster[bundle.roster["comparable"]].reset_index(drop=True) shares = event_mix(bundle).set_index(["property_code", "event_type"])["event_share"] return pd.concat([pd.DataFrame({"unit": roster.index, "event_year": bundle.target_year, "province": roster["province"], "event_type": event_type, "property_code": roster["property_code"], "bedrooms": roster["bedrooms"], "interval_years": roster["expected_years"], "prior_gap": roster["prior_gap"], "start_month": (roster["lease_end"] + pd.Timedelta(days=1)).dt.month, "prior_face_rent": roster["face_rent"], "share": [shares[(code, event_type)] for code in roster["property_code"]]}) for event_type in ("renewal", "turnover")], ignore_index=True) def fit_predict(kind, train, test): target = clip_for_training(train["effective_growth"], "effective_growth").to_numpy() if kind == "xgboost": regressor = xgboost.XGBRegressor(**XGB_PARAMS) regressor.fit(train[MODEL_FEATURES].to_numpy(float), target) return regressor.predict(test[MODEL_FEATURES].to_numpy(float)), regressor has_lags = train[LAG_FEATURES].notna().all(axis=1) usable = [feature for feature in MODEL_FEATURES if train.loc[has_lags, feature].notna().any()] training_means = train.loc[has_lags, usable].mean() regressor = make_pipeline(StandardScaler(), Ridge(alpha=RIDGE_ALPHA)) regressor.fit(train.loc[has_lags, usable].fillna(training_means).to_numpy(float), target[has_lags.to_numpy()]) return regressor.predict(test[usable].fillna(training_means).to_numpy(float)), regressor def forecast_challenger(bundle, kind): known_pairs = bundle.pairs train = model_features(known_pairs.assign(start_month=known_pairs["lease_start"].dt.month), known_pairs) roster = model_features(roster_rows(bundle), known_pairs) predictions, regressor = fit_predict(kind, train, roster) roster = roster.assign(prediction=predictions) per_unit = roster.groupby("unit").apply(lambda unit: float(np.average(unit["prediction"], weights=unit["share"])), include_groups=False) return {"portfolio": float(per_unit.mean()), "roster": roster, "model": regressor} challenger_rows = [] for year in evaluation_years: bundle = make_bundle(leases, year) challenger_rows.append({"target_year": year, "actual_pct": PERCENT * float(realized(leases, year)["effective_growth"].mean()), "our_method_pct": all_folds.set_index("target_year").loc[year, "predicted_pct"], "xgboost_pct": PERCENT * forecast_challenger(bundle, "xgboost")["portfolio"], "ridge_pct": PERCENT * forecast_challenger(bundle, "ridge")["portfolio"], "historical_mean_pct": baselines.query("model == 'All history pooled' and target_year == @year").predicted_pct.iloc[0]}) challenger_backtest = pd.DataFrame(challenger_rows) display(challenger_backtest.round(2)) scoreboard_rows = [] for method in ("our_method", "xgboost", "ridge", "historical_mean"): error = challenger_backtest[f"{method}_pct"] - challenger_backtest["actual_pct"] in_required_window = challenger_backtest["target_year"].isin(REQUIRED_BACKTEST_YEARS) scoreboard_rows.append({"method": method, "MAE_2020_2025_pp": error.abs().mean(), "MAE_2023_2025_pp": error[in_required_window].abs().mean(), "worst_year_pp": error.abs().max()}) challenger_scoreboard = pd.DataFrame(scoreboard_rows).set_index("method") display(challenger_scoreboard.round(3)) for candidate in ("xgboost", "ridge"): board = challenger_scoreboard replaces = bool(board.loc[candidate, "MAE_2020_2025_pp"] < board.loc[["our_method", "historical_mean"], "MAE_2020_2025_pp"].min() and board.loc[candidate, "MAE_2023_2025_pp"] < board.loc[["our_method", "historical_mean"], "MAE_2023_2025_pp"].min() and board.loc[candidate, "worst_year_pp"] <= board.loc["our_method", "worst_year_pp"]) print(f"{candidate} replaces our method: {replaces}")
| target_year | actual_pct | our_method_pct | xgboost_pct | ridge_pct | historical_mean_pct | |
|---|---|---|---|---|---|---|
| 0 | 2020 | 2.77 | 1.76 | 2.06 | 2.22 | 1.75 |
| 1 | 2021 | 3.75 | 2.74 | 2.94 | 1.63 | 2.33 |
| 2 | 2022 | 4.72 | 3.75 | 4.54 | 5.87 | 2.87 |
| 3 | 2023 | 2.18 | 4.47 | 4.91 | 6.52 | 3.38 |
| 4 | 2024 | 1.95 | 2.62 | 3.11 | -0.15 | 3.09 |
| 5 | 2025 | 3.83 | 2.93 | 4.61 | 5.53 | 2.76 |
| MAE_2020_2025_pp | MAE_2023_2025_pp | worst_year_pp | |
|---|---|---|---|
| method | |||
| our_method | 1.142 | 1.289 | 2.294 |
| xgboost | 1.063 | 1.559 | 2.733 |
| ridge | 1.994 | 2.714 | 4.343 |
| historical_mean | 1.282 | 1.138 | 1.841 |
xgboost replaces our method: False ridge replaces our method: False
# Why XGBoost forecasts this figure for 2026: SHAP contributions (TreeSHAP) weighted like the forecast. TOP_FEATURES_SHOWN = 10 xgb_2026 = forecast_challenger(make_bundle(leases, TARGET_YEAR), "xgboost") roster_2026 = xgb_2026["roster"] contributions = xgb_2026["model"].get_booster().predict( xgboost.DMatrix(roster_2026[MODEL_FEATURES].to_numpy(float), feature_names=MODEL_FEATURES), pred_contribs=True) row_weights = roster_2026["share"].to_numpy() / roster_2026["unit"].nunique() shap_2026 = (pd.DataFrame({"feature": MODEL_FEATURES, "contribution_to_2026_pp": PERCENT * (contributions[:, :-1] * row_weights[:, None]).sum(axis=0), "mean_abs_pp": PERCENT * np.abs(contributions[:, :-1]).mean(axis=0)}) .sort_values("mean_abs_pp", ascending=False).reset_index(drop=True)) display(shap_2026.round(3)) top_features = shap_2026.head(TOP_FEATURES_SHOWN).iloc[::-1] plt.figure(figsize=(8, 4)) plt.barh(top_features.feature, top_features.contribution_to_2026_pp) plt.title("XGBoost 2026: mean SHAP contribution by variable (points)") plt.xlabel("percentage points") plt.tight_layout() plt.show() print(f"2026 — XGBoost: {PERCENT * xgb_2026['portfolio']:.2f}% | our method: {estimate_pct:.2f}%")
| feature | contribution_to_2026_pp | mean_abs_pp | |
|---|---|---|---|
| 0 | prior_gap | 2.405 | 3.427 |
| 1 | lag_port_gap | -0.464 | 0.463 |
| 2 | lag_cell_face | 0.454 | 0.454 |
| 3 | lag_port_face | -0.354 | 0.362 |
| 4 | property_age | 0.036 | 0.312 |
| 5 | lag_cell_gap | -0.275 | 0.280 |
| 6 | rent_position | 0.079 | 0.267 |
| 7 | start_month | -0.012 | 0.247 |
| 8 | interval_years | 0.145 | 0.226 |
| 9 | is_renewal | 0.014 | 0.164 |
| 10 | lag_cell_eff | 0.121 | 0.120 |
| 11 | is_ontario | -0.026 | 0.059 |
| 12 | bedrooms | -0.002 | 0.036 |
2026 — XGBoost: 5.15% | our method: 4.80%
Reading. The tables apply the declared rule mechanically. Neither model replaces our method. XGBoost does slightly better over 2020–2025, but worse on the required 2023–2025 window and on its worst year; Ridge does worse everywhere. With about 3,000 pairs and six years of different regimes, a more flexible model mostly learns the noise of years already seen. SHAP shows that XGBoost relies first on the concession gap of the previous lease: a unit leased with a large concession "recovers" part of that concession on the next lease. This is the mechanism that our BRIDGE component models explicitly, unit by unit. The rejected result stays displayed, because it says what the data do not support.
Details
Other avenues were evaluated in the same way and rejected: public variables (vacancy, supply, population, unemployment, interest rates) added to these models,
the asking-rent signal, renewal propensity, a damped trend in concessions, a Monte Carlo simulation of the portfolio, and TAL pass-through.
Their code is in the optional Python modules attached (challenger.py, behavior.py, simulation.py, drivers.py), which are not needed to run this notebook.
10. Beyond the brief: what we built in additionThe brief makes the application optional. We deliver an optional prototype (04Prototypefacultatifcockpitsimulation.zip: cockpit and simulation)
10. Beyond the brief: what we built in addition
The brief makes the application optional. We deliver an optional prototype (04_Prototype_facultatif_cockpit_simulation.zip: cockpit and simulation)
and a one-minute film with the presentation (03_Film_presentation_1min.mp4). Neither is needed to assess this notebook, and neither contains any
CRM data: they use only the building-level aggregate results presented here. Click each line with a triangle to see a screenshot.
1. Pricing cockpit (cockpit/index.html in the prototype, opens with a double-click): the portfolio map with each building's forecast,
a renewal simulator (rent, term, free months, TAL or Ontario regime) and the calendar of leases expiring in 2026.
See the screenshot: portfolio map and forecast by building
2. Resident simulation (simulation/index.html in the prototype): 150 fictional residents, generated by AI, react to market shocks
(lower immigration, a wave of new towers, a new REM station). This is a demonstration of mechanisms, not a forecast:
the tested forecast remains 4.8%, and the page displays it permanently.
See the screenshot: residents reacting to an immigration cut
3. One-minute presentation film (03_Film_presentation_1min.mp4): the approach, from the median trap to 4.8%.
See a still from the film
11. Assumptions, limitations, and how to improve without adding noise1. The next lease of a roster unit starts the day after the current lease ends (vacancy and early departures are unmodelled risks).
11. Assumptions, limitations, and how to improve without adding noise
Stated assumptions.
- The next lease of a roster unit starts the day after the current lease ends (vacancy and early departures are unmodelled risks).
- The 2026 renewal share resembles that of each building's last three years, shrunk toward its province.
- The concession gap of 2026 new leases resembles that of 2025 (BRIDGE); the ±2 point scenarios show the sensitivity.
- The parameters (
ForecastSpec) are inherited from an earlier revision and were not optimised on 2023–2025. sRentEffectiveis exact as supplied; the concession ledger is used to check it, not to replace it.
Details
Limitations. Same unit does not guarantee constant quality after a renovation. The current extract does not prove that past values were never corrected: the reconstruction at the origins is a simulation. Six backtest years and a regime change in 2024–2025 make any fine precision illusory.
To improve: keep real dated snapshots; time-stamp offers and concessions; add occupancy, vacancy, renovation status and The Met's first occupancy (to know whether the Ontario guideline applies); test one extension at a time, with a rule fixed in advance, and confirm it on years not yet seen (2026).
References
Public data (exact values, periods, release dates and links in external_context.csv and external_postorigin_2026.csv):
- CMHC — Rental Market Survey, Montréal and Ottawa CMAs: https://www.cmhc-schl.gc.ca
- Statistics Canada — CPI, rent component, Quebec and Ontario (table 18-10-0004-01): https://www150.statcan.gc.ca
- Tribunal administratif du logement — annual rent increase calculation: https://www.tal.gouv.qc.ca
- Ontario — rent increase guideline: https://www.ontario.ca/page/rent-increase-guideline
Details
Method: scikit-learn, Common pitfalls and recommended practices (data leakage and selection): https://scikit-learn.org/stable/common_pitfalls.html ·
Hyndman & Athanasopoulos, Forecasting: Principles and Practice, time-series cross-validation: https://otexts.com/fpp3/tscv.html ·
Lundberg et al., TreeSHAP, via XGBoost's pred_contribs: https://xgboost.readthedocs.io
AI tools used (permitted by the brief, cited here): Codex (OpenAI) helped audit the components, correct the original protocol and prepare the code and tests. Claude Code (Anthropic; Claude Opus 5.5 and Sonnet 5.5 models) helped verify the public sources, write the analyses of sections 5c, 7.6, 8 and 9, and reorganise this notebook around the starter. Each public value was re-verified against its official source, and each computation is covered by a test. The team remains responsible for the choices, their validation and the presentation.