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é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é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.13.16', '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;
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 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 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 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ê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ç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 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é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.7000634438867257, 'persistence': 2.4195979786895268, 'tal_passthrough': 2.4494076936026183}, 'worst_pp': {'all_prior_mean': 2.94567593553958, 'persistence': 3.614314331499643, 'tal_passthrough': 4.299194176938575}, '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 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 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", "Ajustement de\nconcession", "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"L'ajustement de concession (écart entre la prévision effective et la prévision contractuelle) en reprend {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é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.05 | 2.22 | 1.75 |
| 1 | 2021 | 3.75 | 2.74 | 2.83 | 1.63 | 2.33 |
| 2 | 2022 | 4.72 | 3.75 | 4.52 | 5.87 | 2.87 |
| 3 | 2023 | 2.18 | 4.47 | 4.82 | 6.52 | 3.38 |
| 4 | 2024 | 1.95 | 2.62 | 3.16 | -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.075 | 1.540 | 2.639 |
| 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.509 | 3.546 |
| 1 | lag_port_gap | -0.417 | 0.416 |
| 2 | property_age | -0.009 | 0.332 |
| 3 | lag_port_face | -0.303 | 0.305 |
| 4 | lag_cell_face | 0.301 | 0.302 |
| 5 | lag_cell_gap | -0.260 | 0.267 |
| 6 | interval_years | 0.150 | 0.233 |
| 7 | rent_position | 0.010 | 0.207 |
| 8 | start_month | -0.006 | 0.177 |
| 9 | is_renewal | 0.004 | 0.174 |
| 10 | lag_cell_eff | 0.108 | 0.109 |
| 11 | is_ontario | -0.021 | 0.053 |
| 12 | bedrooms | -0.007 | 0.046 |
2026 — XGBoost : 5.09 % | 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 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 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.