Collection Équinoxe · 2026CodeML 2026 · JADCO
Hausse de loyer 2026 · même logement, après concessions
4,80 %

hausse effective prévue pour 2026 · 6,38 % en loyer contractuel · de 4,3 % (The Met) à 5,1 % (Westpark) selon l'immeuble

+10,8 % → +2,2 %La médiane de 2023 mesure l'arrivée de trois immeubles haut de gamme; le même logement n'a augmenté que de 2,2 %.
7,6 % → 3,8 %En 2025, ce qui est écrit au bail contre ce qui est encaissé : les mois gratuits reprennent la moitié de la hausse.
6 ans testésLa méthode est refaite comme au 31 décembre de chaque année depuis 2020 et comparée à la réalité.
Ce qui fait la hausse 2026 de chaque immeuble (section 8b)

Le notebook complet, étape par étape. Cliquez sur une étape pour la déplier.

RésuméRéponse : 4,80 % de hausse effective à unité identique pour 2026, c'est-à-dire ce qu'Équinoxe encaissera de plus, après les mois gratuits,

Collection Équinoxe — hausse de loyer 2026

Résumé en une page

Réponse : 4,80 % de hausse effective à unité identique pour 2026, c'est-à-dire ce qu'Équinoxe encaissera de plus, après les mois gratuits, sur un même logement. En loyer contractuel (celui écrit au bail) : 6,38 %. Par immeuble : de 4,3 % (The Met, Ottawa) à 5,1 % (Westpark, Pointe-Claire).

Ce que nous avons trouvé, en cinq points

  1. 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).
  2. 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).
  3. 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).
  4. 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).
  5. Chaque chiffre s'explique : pour chaque immeuble, nous montrons ce qui pousse le loyer vers le haut ou le bas, avec le contexte public autour (chantiers, transport, risques) (section 8b). XGBoost et Ridge ont été testés et ne font pas mieux de façon stable (section 9).

Lire en cinq minutes : ce résumé → le graphique de la section 4b (médiane vs même unité) → section 5c (concessions) → section 7.4 (le chiffre) → section 8b (pourquoi, immeuble par immeuble) → section 7.5 (validation).

La valeur exacte est calculée par estimate_2026() à la section 7.4, et la validation sur 2023, 2024 et 2025 est à la section 7.5.

Ce notebook part du starter.ipynb fourni et en garde la structure et les cellules de code : chargement, lecture des colonnes, l'approche évidente, la comparaison d'une unité à elle-même, contractuel vs perçu, renouvellements vs relocations, puis « À vous de jouer » avec estimate_2026() et backtest() complétées. Tout le code de la méthode est dans ce notebook : aucune importation de fichiers de l'équipe.

Pour l'exécuter : placer les quatre CSV d'origine à côté de ce notebook (comme pour le starter), avec les trois fichiers publics fournis (external_context.csv, external_postorigin_2026.csv, building_context.csv), installer requirements.txt, puis Restart Kernel / Run All. Durée : moins d'une minute.

Critère de la grille Section
Définition de la hausse (10) 0 et 7.1
Analyse des données et effet de mix (15) 1b, 1c, 2, 3
Appariement à unité constante (15) 4
Concessions (10) 5
Renouvellements vs relocations, Ontario (10) 6, 7.6
Données externes (15) 7.2, 7.6
Prévision et backtest (15) 7.3 à 7.7, 9
Bonus : immeuble × chambres, scénarios, tableau de bord 7.7, 8, 8b
Au-delà de la consigne (prototype facultatif) 10

Confidentialité : aucune ligne du CRM n'est affichée. Les sorties sont des agrégats (comptes, moyennes, graphiques).

0. La question à laquelle nous répondonsDéfinition retenue : hausse effective à unité identique. Pour chaque unité (clé sPropCode + sUnitCode), on compare le nouveau bail au bail

0. La question à laquelle nous répondons

Définition retenue : hausse effective à unité identique. Pour chaque unité (clé sPropCode + sUnitCode), on compare le nouveau bail au bail immédiatement précédent de la même unité, avec le loyer effectif (sRentEffective, après concessions), on annualise la variation, et on fait la moyenne de ces variations pour toutes les paires dont le nouveau bail commence en 2026. Renouvellements et relocations sont inclus, chacun avec son poids attendu.

Dans tout le notebook, « perçu » ou « encaissé » désigne le champ sRentEffective fourni, c'est-à-dire le loyer après les concessions enregistrées dans les données (c'est ainsi que le starter le décrit). Ce n'est pas un relevé des encaissements réels : l'extrait ne contient ni paiements, ni arriérés.

Détails

Pourquoi ce choix plutôt que les autres définitions proposées dans le starter :

Définition possible Pourquoi nous ne la retenons pas comme chiffre principal
Médiane annuelle de tout le portefeuille Mesure surtout le mix : l'arrivée de trois immeubles haut de gamme crée une « hausse » de 10,8 % en 2023 qui n'a pas eu lieu (section 3).
Même unité, loyer contractuel (sRent) Ignore les mois gratuits. Depuis 2024 l'écart entre contractuel et perçu se creuse (section 5) : le contractuel surestime ce qu'Équinoxe encaisse. Présenté à côté (6,38 %).
Renouvellements seulement / relocations seulement Utiles pour fixer les loyers, mais un budget de revenus a besoin des deux. Les deux sont séparés aux sections 6 et 7.3 et recombinés selon leur part attendue.
Revenu total ou NOI Impossible avec cet extrait : pas d'occupation ni de vacance (section 2).

Moyenne plutôt que médiane : la moyenne des paires se recompose exactement à partir des segments (immeuble × type d'événement), ce que la médiane ne permet pas. La médiane reste affichée comme diagnostic de robustesse.

1. Charger les donnéesLes quatre fichiers sources restent intacts : toute transformation se fait en mémoire, sur des copies. Les noms de tables et les versions des librairies sont affichés pour la repro…

1. Charger les données

Les quatre fichiers sources restent intacts : toute transformation se fait en mémoire, sur des copies. Les noms de tables et les versions des librairies sont affichés pour la reproductibilité.

import os
import platform
from dataclasses import dataclass
from pathlib import Path

import matplotlib
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import sklearn
import xgboost
from IPython.display import display

pd.set_option("display.width", 160)
pd.set_option("display.max_columns", 40)

TARGET_YEAR = 2026                          # l'année à prévoir
LAST_OBSERVED_YEAR = TARGET_YEAR - 1        # l'extrait s'arrête au 2025-12-31
REQUIRED_BACKTEST_YEARS = (2023, 2024, 2025)  # exigés par la consigne
PERCENT = 100.0                             # fraction -> pourcentage
DAYS_PER_YEAR = 365.25                      # annualisation des intervalles entre baux

# Les CSV d'origine se placent à côté du notebook, comme pour le starter.
# Variable d'environnement facultative si on préfère les garder ailleurs.
DATA_DIR = Path(os.environ.get("EQUINOXE_DATA_DIR", "."))
SOURCE_FILES = {"listings": "equinoxe_listings.csv", "leases": "equinoxe_lease_history.csv",
                "concessions": "equinoxe_concessions.csv", "asking": "equinoxe_asking_history.csv"}
missing_files = [file_name for file_name in SOURCE_FILES.values() if not (DATA_DIR / file_name).is_file()]
if missing_files:
    raise FileNotFoundError(f"Placer les CSV d'origine à côté du notebook (ou définir EQUINOXE_DATA_DIR). Manquants : {missing_files}")

source_tables = {table: pd.read_csv(DATA_DIR / file_name) for table, file_name in SOURCE_FILES.items()}
listings, leases = source_tables["listings"], source_tables["leases"]
concessions, asking = source_tables["concessions"], source_tables["asking"]

for name, table in source_tables.items():
    print(f"{name:<12} {len(table):>6,} lignes   {table.shape[1]:>2} colonnes")
print({"python": platform.python_version(), "pandas": pd.__version__, "numpy": np.__version__,
       "matplotlib": matplotlib.__version__, "scikit-learn": sklearn.__version__, "xgboost": xgboost.__version__})
listings      1,061 lignes   29 colonnes
leases        4,302 lignes   35 colonnes
concessions   3,560 lignes   18 colonnes
asking        3,857 lignes   14 colonnes
{'python': '3.12.7', 'pandas': '2.2.3', 'numpy': '2.3.5', 'matplotlib': '3.11.2', 'scikit-learn': '1.9.1', 'xgboost': '2.1.4'}

Comme dans le starter, les dates sont converties une seule fois, dès le départ, et l'année de début de bail est ajoutée. Au lieu d'afficher des lignes du CRM (leases.head()), nous affichons le profil des colonnes : type, valeurs manquantes et nombre de valeurs distinctes.

LEASE_DATE_COLUMNS = ["sLeaseFrom", "sLeaseTo", "sSignDate", "sAvailable"]
for column in LEASE_DATE_COLUMNS:
    if column in leases.columns:
        leases[column] = pd.to_datetime(leases[column], errors="coerce")

leases["year"] = leases.sLeaseFrom.dt.year
asking["date"] = pd.to_datetime(asking.sMonth + "-01")

lease_column_profile = pd.DataFrame({"type": leases.dtypes.astype(str),
                                     "valeurs_manquantes": leases.isna().sum(),
                                     "valeurs_distinctes": leases.nunique()})
lease_column_profile
type valeurs_manquantes valeurs_distinctes
hProperty int64 0 9
hUnit int64 0 1061
hBuilding int64 0 6
sPropCode object 0 9
sUnitCode object 0 519
sBuilding object 0 6
sCity object 0 4
sState object 0 2
sAddr1 object 0 6
sLatitude float64 0 6
sLongitude float64 0 6
sLeaseTerm object 0 4
sPropType object 0 1
sRent int64 0 666
sBeds int64 0 4
sBaths float64 0 3
sSqft int64 0 199
sLink object 0 1061
sFurnishing object 0 1
sAvailable datetime64[ns] 0 1302
sSmoking object 0 1
sCats bool 0 1
sDogs bool 0 1
sRentEffective float64 0 3178
sConcession int64 0 2
sSite object 0 6
sUnitSubtype object 0 4
sUnitType object 0 11
sSignDate datetime64[ns] 0 2067
sLeaseFrom datetime64[ns] 0 1302
sLeaseTo datetime64[ns] 0 1341
sTermMonths float64 0 4
sTermSeq int64 0 8
sRenewal int64 0 2
sFloor int64 0 27
year int32 0 9
1b. Lire les colonnes : champs `h` et champs `s`Convention Yardi rappelée par le starter : les colonnes h (handle) sont des identifiants numériques qui servent aux jointures;

1b. Lire les colonnes : champs h et champs s

Convention Yardi rappelée par le starter : les colonnes h (handle) sont des identifiants numériques qui servent aux jointures; les colonnes s (stored value) sont les valeurs lisibles qui servent à l'affichage et au regroupement. Joindre sur h, étiqueter avec s. Le préfixe s ne veut pas dire « texte » : sRent et sSqft sont numériques.

Détails

Pour l'unité, nous utilisons la clé recommandée sPropCode + sUnitCode (jamais sSite + sUnitCode, voir section 4). Les codes de propriété peuvent désigner des phases d'un même immeuble; les résultats par immeuble sont regroupés par sBuilding.

Les codes de frais (sChargeCode) sont tronqués à huit caractères par Yardi (Freeothe, Referenc). Nous les classons ainsi :

sChargeCode Ce que c'est Classement
PromoPay, freerent Rabais sur le loyer Locatif
freepark, freelock, freeappl Stationnement, casier, électroménagers offerts Service (pas du loyer)
Freeothe, Indem, Referenc Autre, indemnité, référencement Autre, gardé à part
1c. Ce que les colonnes représentent vraiment, et comment nous les renommonsLa consigne prévient que les descriptions ne correspondent pas toujours aux données. Nos vérifications :

1c. Ce que les colonnes représentent vraiment, et comment nous les renommons

La consigne prévient que les descriptions ne correspondent pas toujours aux données. Nos vérifications :

Détails
  • PromoPay est 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.
  • sAmount est négatif pour un avantage. Le crédit total d'une écriture vaut −sAmount × sMonths.
  • sRentEffective est 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.
  • sTermSeq numérote les baux successifs d'une même unité : c'est lui qui définit le « bail précédent ».
  • sRenewal vaut 1 pour un locataire en place qui renouvelle et 0 pour une relocation.
  • Loyer affiché (asking) : un prix annoncé, pas un prix signé.

Les colonnes utilisées par le modèle sont renommées une seule fois, avec le dictionnaire COLUMN_RENAMES ci-dessous. Les fichiers sources ne sont jamais modifiés.

UNIT_KEY = ["sPropCode", "sUnitCode"]       # clé d'unité sûre (jamais sSite + sUnitCode)
PROMO_CODE = "PromoPay"

COLUMN_RENAMES = {"sPropCode": "property_code", "sUnitCode": "unit_code", "sState": "province", "sBeds": "bedrooms",
                  "sSignDate": "sign_date", "sLeaseFrom": "lease_start", "sLeaseTo": "lease_end", "sTermSeq": "term_seq",
                  "sRent": "face_rent", "sRentEffective": "effective_rent"}


def normalise_unit_key(table):
    # Copie avec la clé d'unité nettoyée : espaces retirés, code de propriété en minuscules.
    keyed = table.copy()
    keyed["sPropCode"] = keyed["sPropCode"].astype(str).str.strip().str.lower()
    keyed["sUnitCode"] = keyed["sUnitCode"].astype(str).str.strip()
    return keyed


promo_entries = normalise_unit_key(concessions[concessions.sChargeCode.eq(PROMO_CODE)])
unit_listed_rent = normalise_unit_key(listings)[UNIT_KEY + ["sRent"]]
promo_entries = promo_entries.merge(unit_listed_rent, on=UNIT_KEY, how="left", validate="many_to_one")
promo_check = pd.Series({
    "écritures PromoPay": len(promo_entries),
    "part avec sMonths = 1": (promo_entries.sMonths == 1).mean(),
    "part avec sAmount négatif": (promo_entries.sAmount < 0).mean(),
    "crédit médian / loyer de l'unité": (-promo_entries.sAmount / promo_entries.sRent).median(),
})
display(promo_check.round(3))

COLUMN_MEANINGS = {
    "sPropCode": "code de propriété (une phase d'immeuble); moitié de la clé d'unité",
    "sUnitCode": "numéro d'unité dans la propriété; autre moitié de la clé d'unité",
    "sState": "province : Quebec (TAL) ou Ontario (ligne directrice)",
    "sBeds": "nombre de chambres",
    "sSignDate": "date de signature : sert à savoir ce qui était connu à une date donnée",
    "sLeaseFrom": "début du bail : date de l'événement mesuré",
    "sLeaseTo": "fin contractuelle du bail : sert à anticiper les baux qui expirent en 2026",
    "sTermSeq": "rang du bail dans l'historique de l'unité : définit le bail précédent",
    "sRent": "loyer contractuel mensuel, celui écrit au bail",
    "sRentEffective": "loyer effectif mensuel, après concessions amorties sur le bail : ce qui est encaissé",
}
column_dictionary = pd.DataFrame({"nouveau nom": COLUMN_RENAMES, "ce que la colonne représente": COLUMN_MEANINGS})
column_dictionary.index.name = "colonne d'origine"
column_dictionary
écritures PromoPay                  1494.000
part avec sMonths = 1                  0.882
part avec sAmount négatif              1.000
crédit médian / loyer de l'unité       0.711
dtype: float64
nouveau nom ce que la colonne représente
colonne d'origine
sPropCode property_code code de propriété (une phase d'immeuble); moit...
sUnitCode unit_code numéro d'unité dans la propriété; autre moitié...
sState province province : Quebec (TAL) ou Ontario (ligne dire...
sBeds bedrooms nombre de chambres
sSignDate sign_date date de signature : sert à savoir ce qui était...
sLeaseFrom lease_start début du bail : date de l'événement mesuré
sLeaseTo lease_end fin contractuelle du bail : sert à anticiper l...
sTermSeq term_seq rang du bail dans l'historique de l'unité : dé...
sRent face_rent loyer contractuel mensuel, celui écrit au bail
sRentEffective effective_rent loyer effectif mensuel, après concessions amor...

Contrat de données. Avant toute mesure, le modèle vérifie que les baux respectent les hypothèses dont il dépend, et s'arrête sinon : pas de clé vide, pas de séquence dupliquée ou non entière, dates cohérentes (signature ≤ début ≤ fin), loyers positifs, loyer effectif ≤ loyer contractuel, province connue et stable par unité. Rien n'est imputé. La validation travaille sur une copie, avec la clé nettoyée.

REQUIRED_LEASE_COLUMNS = {"sPropCode", "sUnitCode", "sState", "sBeds", "sRent", "sRentEffective",
                          "sSignDate", "sLeaseFrom", "sLeaseTo", "sTermSeq", "sRenewal"}
KNOWN_PROVINCES = ["Quebec", "Ontario"]
LEASE_NUMERIC_COLUMNS = ["sRent", "sRentEffective", "sTermSeq", "sRenewal", "sBeds"]


class DataContractError(ValueError):
    # Une entrée ne respecte pas une hypothèse dont le modèle dépend.
    pass


def validate_leases(lease_table):
    # Copie validée des baux : dates et nombres convertis, clé nettoyée, règles vérifiées.
    if not isinstance(lease_table, pd.DataFrame) or lease_table.empty:
        raise DataContractError("leases doit être un DataFrame non vide de l'historique des baux")
    missing_columns = REQUIRED_LEASE_COLUMNS - set(lease_table.columns)
    if missing_columns:
        raise DataContractError(f"colonnes manquantes : {sorted(missing_columns)}")
    checked = lease_table.copy()
    for column in ["sSignDate", "sLeaseFrom", "sLeaseTo"]:
        checked[column] = pd.to_datetime(checked[column], errors="coerce")
    for column in LEASE_NUMERIC_COLUMNS:
        checked[column] = pd.to_numeric(checked[column], errors="coerce")
    if checked[list(REQUIRED_LEASE_COLUMNS)].isna().any().any():
        raise DataContractError("valeurs manquantes ou illisibles dans les colonnes requises")
    checked = normalise_unit_key(checked)
    if checked[UNIT_KEY].eq("").any().any() or not checked["sState"].isin(KNOWN_PROVINCES).all():
        raise DataContractError("clé d'unité vide ou province inconnue")
    if not np.isfinite(checked[LEASE_NUMERIC_COLUMNS].to_numpy()).all():
        raise DataContractError("valeur numérique non finie")
    if not checked["sRenewal"].isin([0, 1]).all():
        raise DataContractError("sRenewal doit valoir 0 ou 1")
    if ((checked["sTermSeq"] < 1) | (checked["sTermSeq"] % 1 != 0)).any():
        raise DataContractError("sTermSeq doit être un entier positif")
    if checked.duplicated(UNIT_KEY + ["sTermSeq"]).any():
        raise DataContractError("séquence de bail dupliquée pour une même unité")
    if (checked[["sRent", "sRentEffective"]] <= 0).any().any():
        raise DataContractError("les loyers doivent être positifs")
    if (checked["sRentEffective"] > checked["sRent"]).any():
        raise DataContractError("loyer effectif supérieur au loyer contractuel")
    if ((checked["sLeaseTo"] < checked["sLeaseFrom"]) | (checked["sSignDate"] > checked["sLeaseFrom"])).any():
        raise DataContractError("dates de bail ou de signature incohérentes")
    if checked.groupby(UNIT_KEY)["sState"].nunique().gt(1).any():
        raise DataContractError("une unité ne peut pas changer de province")
    return checked


validated_leases = validate_leases(leases)
print(f"Contrat de données respecté : {len(validated_leases):,} baux, {validated_leases.groupby(UNIT_KEY).ngroups:,} unités.")
Contrat de données respecté : 4,302 baux, 1,061 unités.
2. Ce que nous avons sous les yeuxSix immeubles : Daniel-Johnson, Lévesque et Saint-Elzéar (Laval), Le Carlyle (Mont-Royal), Westpark (Pointe-Claire) et The Met (Ottawa).

2. Ce que nous avons sous les yeux

Six immeubles : Daniel-Johnson, Lévesque et Saint-Elzéar (Laval), Le Carlyle (Mont-Royal), Westpark (Pointe-Claire) et The Met (Ottawa). Tout est arrêté au 31 décembre 2025; 2026 est ce que nous prévoyons. Cinq immeubles relèvent du Québec (TAL) et The Met de l'Ontario : les deux régimes sont traités séparément partout.

Détails

C'est un extrait : une part fixe des unités de chaque immeuble et de chaque typologie. La répartition est représentative, pas les volumes. Nous ne calculons donc ni occupation, ni inoccupation, ni absorption, ni rôle de loyers total : ce serait un artefact de l'extrait. Ces données mesurent la variation des loyers, et c'est elle qu'on nous demande.

print(listings.groupby(["sState", "sCity", "sBuilding"]).size().to_string())
print()
print(listings.groupby(["sBeds", "sBaths"])
      .agg(units=("sRent", "size"),
           median_rent=("sRent", "median"),
           median_sqft=("sSqft", "median"))
      .to_string())
sState   sCity          sBuilding     
Ontario  Ottawa         The Met           124
Quebec   Laval          Daniel-Johnson    134
                        Levesque           84
                        Saint-Elzear      361
         Mont-Royal     Le Carlyle        192
         Pointe-Claire  Westpark          166

              units  median_rent  median_sqft
sBeds sBaths                                 
0     1.0        49       2565.0        550.0
1     1.0       350       1380.0        600.0
2     1.0       358       2440.0       1150.0
      2.0       281       2605.0       1265.0
3     1.0         2       2687.5       1597.5
      2.0        20       3237.5       1545.0
      3.0         1       4115.0       1905.0
3. L'approche évidente, et pourquoi elle trompePremier réflexe : le loyer médian de tous les baux, année par année.

3. L'approche évidente, et pourquoi elle trompe

Premier réflexe : le loyer médian de tous les baux, année par année.

FIRST_YEAR_SHOWN = 2019

naive = (leases[leases.year.between(FIRST_YEAR_SHOWN, LAST_OBSERVED_YEAR)]
         .groupby("year")
         .agg(leases=("sRent", "size"), median_rent=("sRent", "median")))
naive["yoy_pct"] = (naive.median_rent.pct_change() * PERCENT).round(2)
naive
leases median_rent yoy_pct
year
2019 328 1730.0 NaN
2020 373 1775.0 2.60
2021 361 1850.0 4.23
2022 422 1912.5 3.38
2023 693 2120.0 10.85
2024 902 2215.0 4.48
2025 958 2305.0 4.06

Selon ce tableau, les loyers auraient augmenté de 10,8 % en 2023. Ce n'est pas le cas. Une médiane annuelle compare des ensembles d'unités différents : elle bouge dès que la composition du portefeuille bouge. Les deux cellules suivantes regardent sous la médiane : taille, typologie et immeubles loués chaque année.

TWO_BEDROOMS = 2

window = leases[leases.year.between(FIRST_YEAR_SHOWN, LAST_OBSERVED_YEAR)]
mix = window.groupby("year").agg(
    leases=("sRent", "size"),
    median_sqft=("sSqft", "median"),
    pct_2bed_plus=("sBeds", lambda beds: (beds >= TWO_BEDROOMS).mean() * PERCENT),
    buildings=("sBuilding", "nunique"))
mix["rent_per_sqft"] = (window.assign(psf=window.sRent / window.sSqft)
                        .groupby("year").psf.median())
mix.round(2)
leases median_sqft pct_2bed_plus buildings rent_per_sqft
year
2019 328 1177.5 78.66 3 1.48
2020 373 1180.0 77.75 3 1.53
2021 361 1175.0 78.39 3 1.60
2022 422 1160.0 72.51 3 1.71
2023 693 1095.0 62.63 5 2.04
2024 902 1045.0 62.20 6 2.20
2025 958 1025.0 60.23 6 2.33
SNAPSHOT_YEAR = 2024   # première année où les trois nouveaux immeubles louent tous

# À partir de quand chaque immeuble a-t-il commencé à générer des baux?
print(window.groupby("sBuilding").year.min().sort_values().to_string())
print()
# Et à quel niveau de loyer? (baux de SNAPSHOT_YEAR)
print(window[window.year == SNAPSHOT_YEAR].groupby("sBuilding")
      .agg(leases=("sRent", "size"), median_rent=("sRent", "median"))
      .sort_values("median_rent").to_string())
sBuilding
Daniel-Johnson    2019
Levesque          2019
Saint-Elzear      2019
Le Carlyle        2023
The Met           2023
Westpark          2024

                leases  median_rent
sBuilding                          
Levesque            84       1672.5
Daniel-Johnson     136       1870.0
Saint-Elzear       215       2145.0
Westpark           144       2202.5
Le Carlyle         195       2350.0
The Met            128       2752.5

Ce qui s'est passé : Le Carlyle, The Met et Westpark ont été mis en service en 2023 et 2024. Ce sont des immeubles haut de gamme, aux unités plus petites et plus chères au pied carré. Les unités louées sont devenues plus petites et la part des 2 chambres et plus a baissé, alors que la médiane a monté : l'inverse de ce que la taille laisserait prévoir. La hausse de la médiane vient surtout de quels immeubles louent, presque pas d'un même locataire qui paie plus.

Le graphique ci-dessous montre ce changement de composition : la part des baux par immeuble, année par année. La comparaison avec la croissance à unité identique suit à la section 4, une fois les paires construites.

building_mix = pd.crosstab(validated_leases.sLeaseFrom.dt.year, validated_leases.sBuilding, normalize="index")
ax = building_mix.plot.area(stacked=True, figsize=(9, 4))
ax.set(title="Composition des baux par immeuble", ylabel="Part des baux de l'année", xlabel="Année de début du bail")
ax.legend(loc="center left", bbox_to_anchor=(1, 0.5))
plt.tight_layout()
plt.show()
4. La méthode qui fonctionne : comparer une unité à elle-mêmeChaque logement est comparé à son bail précédent, avec des règles écrites une seule fois et des exclusions comptées. Résultat : 3 179 paires comparables.

4. La méthode qui fonctionne : comparer une unité à elle-même

On associe les baux consécutifs de chaque unité et on annualise la variation : même appartement, même immeuble, ce qui reste, c'est le prix.

La clé : sPropCode + sUnitCode. Pas sSite + sUnitCode : plusieurs sites regroupent plusieurs codes de propriété aux numéros d'unité qui se chevauchent. La cellule suivante le démontre, sans afficher de ligne du CRM.

COLLISION_EXAMPLE_SITE, COLLISION_EXAMPLE_UNIT = "saint-elzear", "0203"   # exemple donné par le starter

site_key_collisions = listings[listings.duplicated(["sSite", "sUnitCode"], keep=False)]
print(f"{len(site_key_collisions):,} unités sur {len(listings):,} entrent en collision sur sSite + sUnitCode")
example = listings[(listings.sSite == COLLISION_EXAMPLE_SITE) & (listings.sUnitCode == COLLISION_EXAMPLE_UNIT)]
print(f"Exemple {COLLISION_EXAMPLE_SITE} / {COLLISION_EXAMPLE_UNIT} : {example.hUnit.nunique()} appartements distincts (hUnit), "
      f"{example.sPropCode.nunique()} codes de propriété différents")
print(f"Doublons sur sPropCode + sUnitCode dans les listings : {listings.duplicated(UNIT_KEY).sum()}")
248 unités sur 1,061 entrent en collision sur sSite + sUnitCode
Exemple saint-elzear / 0203 : 3 appartements distincts (hUnit), 3 codes de propriété différents
Doublons sur sPropCode + sUnitCode dans les listings : 0

Le starter propose une première version de l'appariement (same_unit_growth, conservée ci-dessous) : bail précédent de la même unité, écart entre débuts de 0,5 à 2,5 ans, croissance annualisée, médiane par année. Elle confirme déjà que la médiane naïve mesure surtout la composition.

GAP_MIN_YEARS, GAP_MAX_YEARS = 0.5, 2.5   # plus court : souvent le même bail enregistré deux fois; plus long : trop ancien
FIRST_PAIR_YEAR_SHOWN = 2021


def same_unit_growth(df, price_col="sRent", date_col="sLeaseFrom", min_gap=GAP_MIN_YEARS, max_gap=GAP_MAX_YEARS):
    # Version du starter : variation annualisée du loyer, chaque unité comparée à son propre bail précédent.
    d = df.sort_values(UNIT_KEY + [date_col]).copy()
    d["prev_price"] = d.groupby(UNIT_KEY)[price_col].shift(1)
    d["prev_date"] = d.groupby(UNIT_KEY)[date_col].shift(1)
    d = d.dropna(subset=["prev_price", "prev_date"])
    d["gap_years"] = (d[date_col] - d.prev_date).dt.days / DAYS_PER_YEAR
    d = d[d.gap_years.between(min_gap, max_gap, inclusive="neither")]
    d = d[(d.prev_price > 0) & (d[price_col] > 0)]
    d["growth_pct"] = ((d[price_col] / d.prev_price) ** (1 / d.gap_years) - 1) * PERCENT
    return d


pairs = same_unit_growth(leases)
print(f"{len(pairs):,} paires utilisables sur {pairs.groupby(UNIT_KEY).ngroups:,} unités")

honest = (pairs[pairs.year.between(FIRST_PAIR_YEAR_SHOWN, LAST_OBSERVED_YEAR)]
          .groupby("year")
          .growth_pct.agg(pairs="size", median="median").round(2))
honest
3,179 paires utilisables sur 890 unités
pairs median
year
2021 355 4.46
2022 352 5.40
2023 416 2.29
2024 698 3.39
2025 780 7.04
fig, ax = plt.subplots(figsize=(9, 4.5))
years_shown = range(FIRST_PAIR_YEAR_SHOWN, TARGET_YEAR)
ax.plot(years_shown, naive.loc[years_shown, "yoy_pct"], "o--", label="naïf : médiane de tous les baux", lw=2)
ax.plot(years_shown, honest.loc[years_shown, "median"], "o-", label="même unité comparée à elle-même", lw=2)
ax.axhline(0, color="grey", lw=0.8)
ax.set_ylabel("croissance annuelle des loyers (%)")
ax.set_title("La méthode naïve mesure surtout la composition")
ax.set_xticks(list(years_shown))
ax.legend()
ax.grid(alpha=0.3)
plt.tight_layout()
plt.show()

4b. Notre appariement : règles fixées une fois, exclusions visibles

En bref : chaque logement est comparé à son bail précédent, avec des règles écrites une seule fois et des exclusions comptées. Résultat : 3 179 paires comparables.

Détails

Nous resserrons la version du starter sur trois points :

  1. Bail immédiatement précédent : la paire n'est retenue que si les séquences sont consécutives (sTermSeq augmente de 1). Un bail manquant n'est pas « sauté ».
  2. Contractuel et effectif mesurés ensemble sur les mêmes paires, avec l'écart de concession écart = 1 − sRentEffective / sRent du bail précédent et du nouveau.
  3. Toutes les règles et tous les paramètres du modèle sont fixés ici, une seule fois, dans ForecastSpec. Aucun n'a été optimisé sur les années de backtest.

Annualisation : t = jours entre les deux débuts / 365,25, croissance = (loyer_nouveau / loyer_précédent) ** (1/t) − 1, paire retenue si 0,5 < t < 2,5.

Identité exacte au niveau de l'unité : (1 + croissance effective) = (1 + croissance contractuelle) × ((1 − écart_nouveau) / (1 − écart_précédent)) ** (1/t). La croissance effective est la croissance contractuelle corrigée de la variation de l'écart de concession. La cellule la vérifie sur toutes les paires.

@dataclass(frozen=True)
class ForecastSpec:
    # Paramètres figés avant les backtests de cette révision.
    origin_month: int = 12                         # origine de chaque prévision : 31 décembre de l'année précédente
    origin_day: int = 31
    gap_min_years: float = GAP_MIN_YEARS           # intervalle admissible entre deux débuts de bail
    gap_max_years: float = GAP_MAX_YEARS
    growth_clip: tuple = (-0.30, 0.60)             # bornes de robustesse, moyennes d'entraînement seulement
    gap_clip: tuple = (0.0, 0.75)                  # bornes de l'écart de concession prévu
    shrink_k_cell: float = 10.0                    # rétrécissement immeuble × événement vers province × événement
    shrink_k_mix: float = 20.0                     # rétrécissement de la part de renouvellements vers la province
    trailing_years_mix: int = 3                    # fenêtre de la part de renouvellements
    components: tuple = ("DIRECT", "BRIDGE", "ANCHOR")
    component_weights: tuple = (1 / 3, 1 / 3, 1 / 3)
    face_persistence_weight: float = 0.5           # contractuel : moitié dernière année, moitié historique
    evaluation_years: tuple = (2020, 2021, 2022, 2023, 2024, 2025)


SPEC = ForecastSpec()
PAIR_COLUMNS = ["property_code", "unit_code", "province", "bedrooms", "term_seq", "prior_seq", "sign_date", "lease_start",
                "lease_end", "prior_start", "prior_sign", "event_year", "event_type", "prior_face_rent", "prior_effective_rent",
                "face_rent", "effective_rent", "interval_years", "face_growth", "effective_growth", "prior_gap", "new_gap", "eligible"]


def build_pairs(lease_table):
    # Paires de baux consécutifs d'une même unité, croissances annualisées et écarts de concession.
    ordered = validate_leases(lease_table).sort_values(UNIT_KEY + ["sTermSeq", "sLeaseFrom"])
    by_unit = ordered.groupby(UNIT_KEY, sort=False)
    paired = ordered.assign(prior_start=by_unit["sLeaseFrom"].shift(1), prior_sign=by_unit["sSignDate"].shift(1),
                            prior_face_rent=by_unit["sRent"].shift(1), prior_effective_rent=by_unit["sRentEffective"].shift(1),
                            prior_seq=by_unit["sTermSeq"].shift(1))
    paired = paired[paired["prior_start"].notna()].copy()
    paired["interval_years"] = (paired["sLeaseFrom"] - paired["prior_start"]).dt.days / DAYS_PER_YEAR
    paired["face_growth"] = (paired["sRent"] / paired["prior_face_rent"]) ** (1.0 / paired["interval_years"]) - 1.0
    paired["effective_growth"] = (paired["sRentEffective"] / paired["prior_effective_rent"]) ** (1.0 / paired["interval_years"]) - 1.0
    paired["prior_gap"] = 1.0 - paired["prior_effective_rent"] / paired["prior_face_rent"]
    paired["new_gap"] = 1.0 - paired["sRentEffective"] / paired["sRent"]
    rents_positive = (paired[["sRent", "prior_face_rent", "sRentEffective", "prior_effective_rent"]] > 0).all(axis=1)
    consecutive = paired["sTermSeq"].sub(paired["prior_seq"]).eq(1)
    paired["eligible"] = (paired["interval_years"].gt(SPEC.gap_min_years) & paired["interval_years"].lt(SPEC.gap_max_years)
                          & rents_positive & consecutive)
    paired["event_year"] = paired["sLeaseFrom"].dt.year
    paired["event_type"] = np.where(paired["sRenewal"].eq(1), "renewal", "turnover")
    return paired.rename(columns=COLUMN_RENAMES)[PAIR_COLUMNS].reset_index(drop=True)


def exact_bridge(face_growth, prior_gap, new_gap, interval_years=1.0):
    # Identité exacte : (1 + g_contractuel) × ((1 − écart_nouveau) / (1 − écart_précédent)) ** (1/t) − 1.
    inputs = (interval_years, prior_gap, new_gap, face_growth)
    if (any(not np.isfinite(np.asarray(value)).all() for value in inputs)
            or np.any(np.asarray(interval_years) <= 0) or np.any(np.asarray(prior_gap) >= 1)
            or np.any(np.asarray(prior_gap) < 0) or np.any(np.asarray(new_gap) < 0)
            or np.any(np.asarray(new_gap) >= 1) or np.any(np.asarray(face_growth) <= -1)):
        raise DataContractError("le pont exige des durées positives et des ratios de loyer valides")
    return (1.0 + face_growth) * ((1.0 - new_gap) / (1.0 - prior_gap)) ** (1.0 / interval_years) - 1.0


unit_pairs = build_pairs(leases)
eligible_pairs = unit_pairs[unit_pairs["eligible"]]
exclusion_reasons = pd.Series({
    "intervalle ≤ 0,5 an": int((unit_pairs.interval_years <= SPEC.gap_min_years).sum()),
    "intervalle ≥ 2,5 ans": int((unit_pairs.interval_years >= SPEC.gap_max_years).sum()),
    "séquences non consécutives": int(unit_pairs.term_seq.sub(unit_pairs.prior_seq).ne(1).sum()),
}, name="paires (un cas peut cumuler deux raisons)")
display(pd.DataFrame([{"paires_candidates": len(unit_pairs), "admissibles": len(eligible_pairs),
                       "exclues": len(unit_pairs) - len(eligible_pairs)}]))
display(exclusion_reasons)

assert eligible_pairs["term_seq"].sub(eligible_pairs["prior_seq"]).eq(1).all()
np.testing.assert_allclose(exact_bridge(eligible_pairs.face_growth, eligible_pairs.prior_gap,
                                        eligible_pairs.new_gap, eligible_pairs.interval_years),
                           eligible_pairs.effective_growth, atol=1e-12)
print("Identité contractuel / effectif vérifiée sur toutes les paires admissibles.")
paires_candidates admissibles exclues
0 3241 3179 62
intervalle ≤ 0,5 an           62
intervalle ≥ 2,5 ans           0
séquences non consécutives     0
Name: paires (un cas peut cumuler deux raisons), dtype: int64
Identité contractuel / effectif vérifiée sur toutes les paires admissibles.

Médiane naïve vs même unité, par année. Le tableau donne, pour chaque année de début du nouveau bail : le nombre de paires, la croissance moyenne contractuelle et effective, la médiane effective (robustesse), la part de renouvellements, et la variation de la médiane naïve. L'écart entre la médiane naïve et la moyenne appariée illustre l'effet de mix. Ce n'est pas une décomposition causale exacte : statistiques et échantillons diffèrent.

annual_growth = eligible_pairs.groupby("event_year").agg(
    pairs=("effective_growth", "size"),
    face_pct=("face_growth", lambda growth: PERCENT * growth.mean()),
    effective_pct=("effective_growth", lambda growth: PERCENT * growth.mean()),
    median_effective_pct=("effective_growth", lambda growth: PERCENT * growth.median()),
    renewal_share=("event_type", lambda event: event.eq("renewal").mean())).reset_index()
naive_median_change = validated_leases.groupby(validated_leases.sLeaseFrom.dt.year).sRent.median().pct_change() * PERCENT
annual_growth["naive_median_face_change_pct"] = annual_growth.event_year.map(naive_median_change)
display(annual_growth.round(3))

ax = annual_growth.set_index("event_year")[["naive_median_face_change_pct", "face_pct", "effective_pct"]].rename(columns={
    "naive_median_face_change_pct": "Médiane naïve (tous les baux)", "face_pct": "Même unité, contractuel",
    "effective_pct": "Même unité, effectif"}).plot(marker="o", figsize=(9, 4))
ax.set(title="Médiane naïve vs croissance à unité identique", ylabel="% annualisé / variation annuelle", xlabel="Année de début du nouveau bail")
ax.axhline(0, color="grey", lw=0.8)
plt.tight_layout()
plt.show()
event_year pairs face_pct effective_pct median_effective_pct renewal_share naive_median_face_change_pct
0 2018 88 2.126 2.041 1.976 0.534 -11.295
1 2019 159 2.000 1.593 1.629 0.579 7.453
2 2020 331 3.034 2.769 3.019 0.604 2.601
3 2021 355 4.384 3.753 3.543 0.620 4.225
4 2022 352 5.273 4.716 4.586 0.642 3.378
5 2023 416 2.089 2.178 2.019 0.575 10.850
6 2024 698 3.728 1.951 1.772 0.605 4.481
7 2025 780 7.559 3.833 3.575 0.606 4.063
5. Loyer contractuel n'est pas loyer perçuLe registre des concessions confirme la direction de l'écart sans le reconstruire exactement; nous utilisons donc le loyer effectif fourni, tel quel.

5. Loyer contractuel n'est pas loyer perçu

sRent est ce que le bail indique; sRentEffective est le loyer après mois gratuits et autres avantages, tel que fourni dans les données (le starter le décrit comme ce qu'Équinoxe perçoit réellement). Les concessions sont devenues la norme. Le premier tableau (starter) compare les niveaux; le second compare les croissances sur les paires du starter.

conc = (leases[leases.year.between(FIRST_PAIR_YEAR_SHOWN, LAST_OBSERVED_YEAR)]
        .groupby("year")
        .agg(leases=("sRent", "size"),
             pct_with_concession=("sConcession", lambda flag: flag.mean() * PERCENT),
             median_contracted=("sRent", "median"),
             median_effective=("sRentEffective", "median")))
conc["gap_pct"] = ((conc.median_effective / conc.median_contracted - 1) * PERCENT)
conc.round(2)
leases pct_with_concession median_contracted median_effective gap_pct
year
2021 361 54.02 1850.0 1785.00 -3.51
2022 422 57.35 1912.5 1835.00 -4.05
2023 693 54.26 2120.0 2025.40 -4.46
2024 902 73.06 2215.0 2084.46 -5.89
2025 958 85.39 2305.0 2105.59 -8.65
# La concession change-t-elle l'évolution, ou seulement le niveau?
eff = same_unit_growth(leases, price_col="sRentEffective")
compare = pd.DataFrame({
    "contracted": honest["median"],
    "effective": eff[eff.year.between(FIRST_PAIR_YEAR_SHOWN, LAST_OBSERVED_YEAR)].groupby("year").growth_pct.median(),
}).round(2)
compare
contracted effective
year
2021 4.46 3.54
2022 5.40 4.59
2023 2.29 2.02
2024 3.39 1.77
2025 7.04 3.58

5b. Vérifier sRentEffective avec le registre des concessions

En bref : le registre des concessions confirme la direction de l'écart sans le reconstruire exactement; nous utilisons donc le loyer effectif fourni, tel quel.

Détails

Le loyer effectif fourni reste notre mesure. Pour le vérifier, chaque écriture de concession est rattachée au bail de la même unité dont la période contient sa date de début. Une écriture qui correspondrait à plusieurs baux arrête le calcul. Le crédit d'une écriture vaut −sAmount × sMonths; seuls les codes locatifs (PromoPay, freerent) sont retranchés du loyer, étalés sur la durée du bail (sTermMonths). Les services (stationnement, casier, électroménagers) ne sont pas du loyer.

Le premier tableau montre ce que le registre explique et ce qu'il n'explique pas : écritures sans bail englobant, et baux marqués sConcession = 1 sans écriture. Le second compare, sur un échantillon commun où les deux baux de la paire ont une reconstruction positive et un drapeau cohérent, trois définitions du loyer : contractuel, contractuel moins les seuls crédits locatifs du registre, et effectif fourni. Ces écritures n'ont pas de date d'enregistrement fiable : cette reconstruction est un diagnostic et n'entre pas dans les backtests.

RENT_CREDIT_CODES = ("promopay", "freerent")
AMENITY_CODES = ("freepark", "freelock", "freeappl")


def match_concessions_to_leases(lease_table, concession_table):
    # Rattache chaque écriture au bail de la même unité qui contient sa date de début.
    lease_rows = validate_leases(lease_table).reset_index(drop=True).reset_index(names="lease_id")
    ledger = normalise_unit_key(concession_table).reset_index(names="concession_id")
    for column in ("sDateFrom", "sDateTo"):
        ledger[column] = pd.to_datetime(ledger[column])
    matched = ledger.merge(lease_rows[["lease_id", *UNIT_KEY, "sLeaseFrom", "sLeaseTo"]], on=UNIT_KEY, how="left")
    matched = matched[matched.sDateFrom.between(matched.sLeaseFrom, matched.sLeaseTo)].copy()
    if matched.groupby("concession_id").lease_id.nunique().gt(1).any():
        raise DataContractError("une écriture de concession correspond à plusieurs baux")
    charge_code = matched.sChargeCode.str.lower()
    matched["code"] = charge_code
    matched["category"] = np.select([charge_code.isin(RENT_CREDIT_CODES), charge_code.isin(AMENITY_CODES)],
                                    ["rent", "amenity"], default="other")
    matched["total_credit"] = -matched.sAmount * matched.sMonths
    if (matched.total_credit < 0).any():
        raise DataContractError("une écriture de concession est une charge positive")
    return lease_rows, matched


lease_rows, matched_concessions = match_concessions_to_leases(leases, concessions)
rent_credit_by_lease = matched_concessions[matched_concessions.category.eq("rent")].groupby("lease_id").total_credit.sum()
entries_per_lease = matched_concessions.groupby("lease_id").size()
lease_rows["matched_entries"] = lease_rows.lease_id.map(entries_per_lease).fillna(0)
lease_rows["rent_only"] = lease_rows.sRent - lease_rows.lease_id.map(rent_credit_by_lease).fillna(0) / lease_rows.sTermMonths
lease_rows["flag_consistent"] = ~(lease_rows.sConcession.eq(1) & lease_rows.matched_entries.eq(0))
by_unit_sequence = lease_rows.sort_values(UNIT_KEY + ["sTermSeq"]).groupby(UNIT_KEY)
lease_rows["prior_rent_only"] = by_unit_sequence.rent_only.shift()
lease_rows["prior_flag_consistent"] = by_unit_sequence.flag_consistent.shift(fill_value=False)

concession_reconciliation = pd.DataFrame([{
    "écritures": len(concessions),
    "rattachées à un bail": matched_concessions.concession_id.nunique(),
    "non rattachées": len(concessions) - matched_concessions.concession_id.nunique(),
    "baux marqués sans écriture": int((lease_rows.sConcession.eq(1) & lease_rows.matched_entries.eq(0)).sum()),
    "baux non marqués avec écriture": int((lease_rows.sConcession.eq(0) & lease_rows.matched_entries.gt(0)).sum())}])
display(concession_reconciliation)

pairs_with_reconstruction = eligible_pairs.merge(
    lease_rows[[*UNIT_KEY, "sTermSeq", "rent_only", "prior_rent_only", "flag_consistent", "prior_flag_consistent"]],
    left_on=["property_code", "unit_code", "term_seq"], right_on=[*UNIT_KEY, "sTermSeq"], validate="one_to_one")
common_sample = pairs_with_reconstruction[pairs_with_reconstruction.flag_consistent & pairs_with_reconstruction.prior_flag_consistent
                                          & pairs_with_reconstruction.rent_only.gt(0)
                                          & pairs_with_reconstruction.prior_rent_only.gt(0)].copy()
common_sample["rent_only_pct"] = PERCENT * ((common_sample.rent_only / common_sample.prior_rent_only) ** (1 / common_sample.interval_years) - 1)
common_sample_by_year = common_sample.groupby("event_year").agg(
    n=("effective_growth", "size"), face_pct=("face_growth", lambda growth: PERCENT * growth.mean()),
    rent_only_pct=("rent_only_pct", "mean"),
    supplied_effective_pct=("effective_growth", lambda growth: PERCENT * growth.mean())).reset_index()
display(common_sample_by_year.round(3))
ax = common_sample_by_year.set_index("event_year")[["face_pct", "rent_only_pct", "supplied_effective_pct"]].rename(columns={
    "face_pct": "Contractuel", "rent_only_pct": "Contractuel − crédits locatifs du registre",
    "supplied_effective_pct": "Effectif fourni"}).plot(marker="o", figsize=(9, 4))
ax.set(title="Même échantillon : effet de la définition du loyer", ylabel="Croissance annualisée (%)", xlabel="Année de début du nouveau bail")
plt.tight_layout()
plt.show()
écritures rattachées à un bail non rattachées baux marqués sans écriture baux non marqués avec écriture
0 3560 3356 204 497 264
event_year n face_pct rent_only_pct supplied_effective_pct
0 2018 84 2.038 1.893 2.003
1 2019 142 2.034 1.361 1.740
2 2020 280 2.998 3.116 2.843
3 2021 283 4.503 3.717 3.813
4 2022 273 5.252 5.689 4.641
5 2023 336 2.078 1.693 2.003
6 2024 534 3.761 2.551 2.232
7 2025 502 7.542 5.942 3.775

5c. Pourquoi contractuel et effectif divergent à partir de 2024

D'après l'identité de la section 4b, la croissance effective est la croissance contractuelle moins la variation de l'écart de concession entre le bail précédent et le nouveau. L'écart moyen se décompose en incidence (part des baux avec concession) × profondeur (écart moyen quand il y en a une). La variation de l'écart se sépare donc exactement, au point milieu, en une part « incidence » et une part « profondeur ».

Détails

Lecture : si les deux croissances divergent, ce n'est pas que le prix contractuel cesse de monter; c'est que l'écart s'élargit au nouveau bail. Le registre indique quels codes portent l'écart, mais ne le réconcilie pas exactement : il contient aussi des services qui ne sont pas du loyer, et il lui manque des écritures (non rattachées, baux marqués sans écriture). Le chiffre fiable reste l'écart sRent / sRentEffective; la ventilation par code est indicative.

def concession_divergence(pairs_table):
    # Par année : croissance contractuelle vs effective, et variation de l'écart = incidence + profondeur.
    rows = []
    for year, year_pairs in pairs_table[pairs_table["eligible"]].groupby("event_year"):
        incidence_prior, incidence_new = float((year_pairs["prior_gap"] > 0).mean()), float((year_pairs["new_gap"] > 0).mean())
        depth_prior = float(year_pairs.loc[year_pairs["prior_gap"] > 0, "prior_gap"].mean()) if incidence_prior else 0.0
        depth_new = float(year_pairs.loc[year_pairs["new_gap"] > 0, "new_gap"].mean()) if incidence_new else 0.0
        gap_prior, gap_new = float(year_pairs["prior_gap"].mean()), float(year_pairs["new_gap"].mean())
        incidence_part = (incidence_new - incidence_prior) * (depth_prior + depth_new) / 2.0
        depth_part = (depth_new - depth_prior) * (incidence_prior + incidence_new) / 2.0
        rows.append({
            "event_year": int(year), "pairs": len(year_pairs),
            "face_pct": PERCENT * year_pairs["face_growth"].mean(), "effective_pct": PERCENT * year_pairs["effective_growth"].mean(),
            "face_minus_effective_pp": PERCENT * (year_pairs["face_growth"].mean() - year_pairs["effective_growth"].mean()),
            "prior_gap_pct": PERCENT * gap_prior, "new_gap_pct": PERCENT * gap_new, "gap_change_pp": PERCENT * (gap_new - gap_prior),
            "incidence_prior": incidence_prior, "incidence_new": incidence_new,
            "depth_prior_pct": PERCENT * depth_prior, "depth_new_pct": PERCENT * depth_new,
            "gap_change_from_incidence_pp": PERCENT * incidence_part, "gap_change_from_depth_pp": PERCENT * depth_part})
    return pd.DataFrame(rows)


def concession_code_contribution(lease_rows_table, matched_table):
    # Concession amortie moyenne en % du loyer contractuel, sur tous les baux, par année et par code.
    credit_by_code = matched_table.groupby(["lease_id", "code"])["total_credit"].sum().unstack(fill_value=0.0)
    codes = list(credit_by_code.columns)
    per_lease = lease_rows_table[["lease_id", "sLeaseFrom", "sTermMonths", "sRent", "sRentEffective"]].join(credit_by_code, on="lease_id")
    per_lease[codes] = per_lease[codes].fillna(0.0).div(per_lease["sTermMonths"], axis=0).div(per_lease["sRent"], axis=0)
    start_year = per_lease["sLeaseFrom"].dt.year
    contribution = per_lease.groupby(start_year)[codes].mean().mul(PERCENT)
    contribution["total_matched_pct"] = contribution[codes].sum(axis=1)
    contribution["supplied_gap_pct"] = PERCENT * (1 - per_lease["sRentEffective"] / per_lease["sRent"]).groupby(start_year).mean()
    contribution["unexplained_by_ledger_pct"] = contribution["supplied_gap_pct"] - contribution["total_matched_pct"]
    contribution.index.name = "lease_start_year"
    return contribution.reset_index()


divergence = concession_divergence(unit_pairs)
display(divergence.round(2))
fig, axes = plt.subplots(1, 2, figsize=(13, 4))
divergence.set_index("event_year")[["face_pct", "effective_pct"]].rename(
    columns={"face_pct": "Contractuel", "effective_pct": "Effectif"}).plot(ax=axes[0], marker="o", title="Contractuel vs effectif, même unité")
axes[0].set(ylabel="% annualisé", xlabel="Année de début du nouveau bail")
divergence.set_index("event_year")[["gap_change_from_incidence_pp", "gap_change_from_depth_pp"]].rename(
    columns={"gap_change_from_incidence_pp": "Incidence", "gap_change_from_depth_pp": "Profondeur"}).plot.bar(
    ax=axes[1], stacked=True, title="Variation de l'écart de concession : incidence + profondeur")
axes[1].set(ylabel="points de pourcentage", xlabel="Année de début du nouveau bail")
plt.tight_layout()
plt.show()
display(concession_code_contribution(lease_rows, matched_concessions).round(2))
event_year pairs face_pct effective_pct face_minus_effective_pp prior_gap_pct new_gap_pct gap_change_pp incidence_prior incidence_new depth_prior_pct depth_new_pct gap_change_from_incidence_pp gap_change_from_depth_pp
0 2018 88 2.13 2.04 0.08 1.21 1.36 0.15 0.40 0.38 3.04 3.62 -0.08 0.22
1 2019 159 2.00 1.59 0.41 1.36 1.78 0.42 0.38 0.47 3.54 3.77 0.32 0.10
2 2020 331 3.03 2.77 0.27 1.77 2.07 0.30 0.46 0.48 3.84 4.28 0.10 0.20
3 2021 355 4.38 3.75 0.63 2.02 2.68 0.66 0.47 0.54 4.29 4.99 0.31 0.35
4 2022 352 5.27 4.72 0.56 2.74 3.35 0.61 0.56 0.58 4.92 5.78 0.12 0.49
5 2023 416 2.09 2.18 -0.09 3.21 3.24 0.03 0.55 0.49 5.81 6.64 -0.40 0.43
6 2024 698 3.73 1.95 1.78 3.66 5.46 1.79 0.55 0.70 6.63 7.79 1.06 0.73
7 2025 780 7.56 3.83 3.73 5.79 9.24 3.46 0.74 0.86 7.79 10.70 1.13 2.33
lease_start_year freeappl freelock freeothe freepark freerent indem promopay referenc total_matched_pct supplied_gap_pct unexplained_by_ledger_pct
0 2017 0.00 0.12 0.19 0.49 0.03 0.00 0.64 0.00 1.47 1.18 -0.29
1 2018 0.07 0.17 0.22 0.44 0.07 0.03 0.73 0.06 1.79 1.36 -0.43
2 2019 0.00 0.12 0.37 0.65 0.09 0.05 1.21 0.00 2.50 1.70 -0.80
3 2020 0.03 0.11 0.41 0.47 0.09 0.04 1.08 0.03 2.25 2.09 -0.16
4 2021 0.06 0.09 0.43 0.80 0.11 0.04 1.82 0.00 3.35 2.69 -0.66
5 2022 0.02 0.13 0.55 0.96 0.12 0.05 1.58 0.06 3.47 3.33 -0.15
6 2023 0.04 0.27 0.82 1.72 0.30 0.11 1.77 0.05 5.09 3.58 -1.51
7 2024 0.09 0.34 1.25 1.67 0.33 0.10 2.56 0.06 6.39 5.71 -0.69
8 2025 0.07 0.27 0.77 1.62 0.19 0.10 3.77 0.06 6.83 9.13 2.30

Lecture. Jusqu'en 2023, contractuel et effectif restent à moins d'un point l'un de l'autre. Ensuite l'écart de concession du nouveau bail s'élargit, pour deux raisons successives :

  • en 2024, surtout l'incidence : la part des nouveaux baux avec concession passe d'environ 55 % à 70 %;
  • en 2025, surtout la profondeur : l'écart moyen d'un bail avec concession passe d'environ 7,8 % à 10,7 % du loyer, pendant que l'incidence monte encore.

Le registre montre que PromoPay porte l'essentiel de cette hausse. Le contractuel continue de monter, mais une partie croissante est rendue en mois gratuits. C'est pourquoi nous citons l'effectif : c'est ce qu'un propriétaire encaisse. Le contractuel reste présenté, car c'est lui qui compte pour l'encadrement des renouvellements.

6. Les renouvellements ne se comportent pas comme les relocationssRenewal = 1 : un locataire en place renouvelle. sRenewal = 0 : l'unité se libère et se reloue au prix du marché. Au Québec, les deux sont régis par des règles

6. Les renouvellements ne se comportent pas comme les relocations

sRenewal = 1 : un locataire en place renouvelle. sRenewal = 0 : l'unité se libère et se reloue au prix du marché. Au Québec, les deux sont régis par des règles différentes (le TAL encadre la hausse d'un locataire en place); en Ontario, la ligne directrice ne vise que les logements sous contrôle des loyers. Le tableau du starter montre que l'écart change de camp : les renouvellements mènent en 2022, les relocations en 2025.

FIRST_SPLIT_YEAR = 2022

split = (pairs[pairs.year.between(FIRST_SPLIT_YEAR, LAST_OBSERVED_YEAR)]
         .groupby(["year", "sRenewal"])
         .growth_pct.agg(pairs="size", median="median").round(2)
         .unstack("sRenewal"))
split.columns = [f"{stat}_{'renewal' if is_renewal else 'turnover'}" for stat, is_renewal in split.columns]
split
pairs_turnover pairs_renewal median_turnover median_renewal
year
2022 126 226 4.22 5.69
2023 177 239 1.85 2.58
2024 276 422 4.85 2.94
2025 307 473 8.98 6.41

Séparation par province et par type d'événement, sur nos paires. Moyennes contractuelle et effective par année × province × événement. The Met (Ontario) est toujours traité à part : le plafond d'une province n'est jamais appliqué à l'autre. Le modèle (section 7.3) prévoit chaque immeuble × type d'événement séparément, puis les recombine selon la part attendue de renouvellements.

Loyer affiché. C'est un prix annoncé, pas signé. Le dernier loyer affiché connu au 31 décembre 2025 est très proche du loyer des listings : dans cet extrait, il n'apporte pas d'information indépendante. Sans offre horodatée avant la signature et reliée au bail réalisé, nous ne l'utilisons pas comme variable.

segments = eligible_pairs.groupby(["event_year", "province", "event_type"]).agg(
    n=("effective_growth", "size"),
    face_pct=("face_growth", lambda growth: PERCENT * growth.mean()),
    effective_pct=("effective_growth", lambda growth: PERCENT * growth.mean())).reset_index()
display(segments[segments.event_year.ge(REQUIRED_BACKTEST_YEARS[0])].round(3))

asking_known = asking.copy()
asking_known["sMonth"] = pd.to_datetime(asking_known.sMonth)
asking_known = asking_known[asking_known.sMonth.le(pd.Timestamp(LAST_OBSERVED_YEAR, 12, 31))]
latest_asking = asking_known.sort_values("sMonth").drop_duplicates(UNIT_KEY, keep="last")
asking_vs_listing = latest_asking.merge(listings[UNIT_KEY + ["sRent"]], on=UNIT_KEY, validate="one_to_one")
display(pd.DataFrame([{"origine": f"{LAST_OBSERVED_YEAR}-12-31", "unités avec loyer affiché": len(latest_asking),
                       "appariées aux listings": len(asking_vs_listing),
                       "ratio médian affiché / listing": float((asking_vs_listing.sAskingRent / asking_vs_listing.sRent).median())}]).round(4))
event_year province event_type n face_pct effective_pct
10 2023 Quebec renewal 239 2.550 2.747
11 2023 Quebec turnover 177 1.467 1.409
12 2024 Ontario renewal 67 2.899 0.328
13 2024 Ontario turnover 41 5.080 4.756
14 2024 Quebec renewal 355 2.915 1.368
15 2024 Quebec turnover 235 4.956 2.803
16 2025 Ontario renewal 63 6.333 1.893
17 2025 Ontario turnover 54 8.925 4.547
18 2025 Quebec renewal 410 6.529 2.603
19 2025 Quebec turnover 253 9.243 6.157
origine unités avec loyer affiché appariées aux listings ratio médian affiché / listing
0 2025-12-31 1061 1061 0.9824
7. À vous de jouer : notre prévisionPour chaque immeuble et chaque type de bail (renouvellement ou relocation), nous faisons la moyenne de trois lectures simples :

7. À vous de jouer : notre prévision

7.1 La question (rappel)

Nous prévoyons la moyenne des croissances annualisées du loyer effectif, à unité identique, pour les paires dont le nouveau bail commence en 2026 (section 0). Le contractuel est prévu à côté. Ce chiffre décrit les baux comparables; ce n'est ni la croissance du revenu total, ni celle du NOI.

7.2 Données publiques et règle de date

external_context.csv rassemble les sources publiques demandées, chacune avec sa période, sa date de publication et son lien : SCHL (loyers à échantillon fixe et inoccupation, RMR de Montréal et d'Ottawa), Statistique Canada (IPC, composante loyers, Québec et Ontario), TAL (ajustement de base des renouvellements au Québec) et la ligne directrice de l'Ontario.

Détails

Règle : une prévision faite au 31 décembre ne peut utiliser qu'une donnée publiée avant cette date. La fonction external_asof refuse toute publication postérieure, sans lien, ou non numérique. C'est pourquoi le TAL 2026, publié en janvier 2026, est rangé à part dans external_postorigin_2026.csv et ne sert qu'aux sensibilités de la section 7.6.

external = pd.read_csv("external_context.csv")
external_post_origin = pd.read_csv("external_postorigin_2026.csv")
EXTERNAL_REQUIRED_COLUMNS = {"target_year", "release_date", "source_url", "metric", "value"}


def origin_date(target_year):
    # Origine de la prévision : le 31 décembre de l'année précédant la cible.
    return pd.Timestamp(target_year - 1, SPEC.origin_month, SPEC.origin_day)


def external_asof(external_table, target_year):
    # Sources publiques disponibles à l'origine; refuse les publications postérieures ou sans provenance.
    if external_table is None:
        return pd.DataFrame()
    if not isinstance(external_table, pd.DataFrame):
        raise DataContractError("external doit être un DataFrame de sources publiques avec provenance")
    if EXTERNAL_REQUIRED_COLUMNS - set(external_table):
        raise DataContractError(f"external : colonnes manquantes {sorted(EXTERNAL_REQUIRED_COLUMNS - set(external_table))}")
    available = external_table[external_table.target_year.eq(target_year)].copy()
    release = pd.to_datetime(available.release_date, errors="coerce")
    if release.isna().any() or (release > origin_date(target_year)).any():
        raise DataContractError("publication manquante ou postérieure à l'origine de la prévision")
    if available.source_url.isna().any() or not available.source_url.astype(str).str.match(r"https?://\S+").all():
        raise DataContractError("provenance manquante")
    if not np.isfinite(pd.to_numeric(available.value, errors="coerce")).all():
        raise DataContractError("valeurs publiques non finies")
    return available


display(external_asof(external, TARGET_YEAR)[["geography", "metric", "reference_period", "release_date", "value", "unit"]])
geography metric reference_period release_date value unit
3 Quebec TAL estimated average basic adjustment (lagged) TAL 2025 2025-01-21 5.9000 percent
7 Ontario Ontario rent increase guideline 2026 guideline 2025-06-30 2.1000 percent
11 Montreal CMA CMHC fixed-sample average rent change October 2025 2025-12-11 7.4000 percent
15 Ottawa-Gatineau CMA (Ontario part) CMHC fixed-sample average rent change October 2025 2025-12-11 3.5000 percent
19 Montreal CMA CMHC apartment vacancy rate October 2025 2025-12-11 2.9000 percent
23 Ottawa-Gatineau CMA (Ontario part) CMHC apartment vacancy rate October 2025 2025-12-11 3.0000 percent
27 Montreal CMA Statistics Canada rented accommodation CPI yea... November 2025 2025-12-15 7.3678 percent YoY
31 Ontario Statistics Canada rent CPI year-over-year November 2025 2025-12-15 4.0687 percent YoY

7.3 Méthode : trois composantes simples, prévues par immeuble × type d'événement

En bref : pour chaque immeuble et chaque type de bail (renouvellement ou relocation), nous faisons la moyenne de trois lectures simples : l'élan de l'an dernier, le même élan converti en effectif logement par logement, et la tendance de long terme. Puis nous pondérons par le nombre de baux attendus en 2026.

Détails

Ce que la prévision a le droit de voir. Pour une cible Y, l'origine est le 31 décembre de Y − 1. On garde les baux signés et commencés avant l'origine, puis on construit les paires. Le roster est l'ensemble des unités dont le dernier bail connu à l'origine se termine en Y : ce sont les baux qui seront renouvelés ou reloués. Le roster anticipe la composition des événements; ce n'est pas un rôle de loyers complet.

Trois composantes de poids égaux (un tiers chacune, fixés à l'avance), calculées pour chaque immeuble × type d'événement :

  • DIRECT : la croissance effective moyenne de l'année précédente (persistance).
  • BRIDGE : la croissance contractuelle de l'année précédente, convertie en effective par l'identité exacte de la section 4b, appliquée unité par unité au roster, avec l'écart de concession actuel de chaque unité et l'écart des nouveaux baux de l'année précédente.
  • ANCHOR : la moyenne effective de tout l'historique admissible (ancrage de long terme).

Petits échantillons. Chaque moyenne immeuble × événement est rétrécie vers la moyenne province × événement : (n × moyenne + k × parent) / (n + k), avec k = 10. Une cellule absente utilise la province × événement, puis l'événement, puis le portefeuille. Les moyennes d'entraînement sont bornées à −30 % / +60 % pour qu'un bail aberrant ne domine pas un petit immeuble. Les résultats réalisés ne sont jamais bornés.

Part de renouvellements. Pour chaque immeuble, la part des trois dernières années est rétrécie vers la province (k = 20). Les renouvellements du Québec et de l'Ontario ne sont jamais mélangés.

Contractuel. Moitié dernière année, moitié historique, par cellule.

@dataclass(frozen=True)
class OriginBundle:
    # Tout ce que la prévision a le droit de voir pour une année cible.
    target_year: int
    origin: pd.Timestamp
    pairs: pd.DataFrame     # paires admissibles signées et commencées avant l'origine
    roster: pd.DataFrame    # unités dont le dernier bail connu se termine pendant l'année cible

    @property
    def summary(self):
        return {"target_year": self.target_year, "origin": self.origin.date().isoformat(),
                "n_training_pairs": int(len(self.pairs)), "n_roster_units": int(len(self.roster)),
                "training_years": sorted(int(year) for year in self.pairs["event_year"].unique())}


ROSTER_COLUMNS = ["property_code", "unit_code", "province", "bedrooms", "term_seq", "lease_end", "face_rent", "effective_rent",
                  "prior_gap", "expected_years", "comparable"]


def build_roster(lease_table, target_year):
    # Unités dont le dernier bail connu à l'origine se termine pendant l'année cible.
    # Le filtre d'origine passe AVANT le choix du dernier bail : un extrait plus récent ne change pas le roster.
    checked = validate_leases(lease_table)
    origin = origin_date(target_year)
    known_at_origin = checked[(checked["sSignDate"] <= origin) & (checked["sLeaseFrom"] <= origin)]
    latest_lease = known_at_origin.sort_values(UNIT_KEY + ["sTermSeq", "sLeaseFrom"]).drop_duplicates(UNIT_KEY, keep="last")
    roster = latest_lease[latest_lease["sLeaseTo"].dt.year.eq(target_year)].rename(columns=COLUMN_RENAMES)
    roster["prior_gap"] = 1.0 - roster["effective_rent"] / roster["face_rent"]
    # Hypothèse : le prochain bail commence le lendemain de la fin du bail actuel.
    roster["expected_years"] = ((roster["lease_end"] + pd.Timedelta(days=1)) - roster["lease_start"]).dt.days / DAYS_PER_YEAR
    roster["comparable"] = roster["expected_years"].between(SPEC.gap_min_years, SPEC.gap_max_years, inclusive="neither")
    return roster[ROSTER_COLUMNS].reset_index(drop=True)


def make_bundle(lease_table, target_year):
    # Filtre signature ET début avant d'apparier : une séquence manquante ne peut pas être enjambée.
    checked = validate_leases(lease_table)
    origin = origin_date(target_year)
    observed = checked[(checked["sSignDate"] <= origin) & (checked["sLeaseFrom"] <= origin)]
    observed_pairs = build_pairs(observed)
    known_pairs = observed_pairs[observed_pairs["eligible"]].copy()
    roster = build_roster(checked, target_year)
    if known_pairs.empty:
        raise DataContractError(f"aucune paire admissible connue avant le {origin.date()}")
    if roster.empty or not roster["comparable"].any():
        raise DataContractError(f"aucune unité expirant en {target_year} n'est connue au {origin.date()}")
    return OriginBundle(target_year, origin, known_pairs, roster)


def clip_for_training(values, column):
    # Bornes de robustesse : écart de concession prévu, ou croissance.
    lower, upper = SPEC.gap_clip if column == "new_gap" else SPEC.growth_clip
    return values.clip(lower, upper)


def shrunk_cell_means(pairs_table, column, shrink_k):
    # Moyennes immeuble × événement rétrécies vers province × événement (puis événement, puis portefeuille).
    if pairs_table.empty:
        return {}
    bounded = pairs_table.assign(_value=clip_for_training(pairs_table[column], column))
    portfolio_mean = float(bounded["_value"].mean())
    event_means = bounded.groupby("event_type")["_value"].mean().to_dict()
    province_event_means = bounded.groupby(["province", "event_type"])["_value"].mean()
    province_of = bounded.groupby("property_code")["province"].first().to_dict()
    cell_means = {}
    for (property_code, event_type), cell in bounded.groupby(["property_code", "event_type"]):
        parent = province_event_means.get((province_of[property_code], event_type), event_means.get(event_type, portfolio_mean))
        n_pairs = len(cell)
        cell_means[(property_code, event_type)] = (n_pairs * float(cell["_value"].mean()) + shrink_k * parent) / (n_pairs + shrink_k)
    return cell_means


def cell_value(cell_means, pairs_table, column, property_code, event_type, province):
    # Valeur de la cellule, avec repli hiérarchique pour une cellule absente.
    if (property_code, event_type) in cell_means:
        return cell_means[(property_code, event_type)]
    bounded = pairs_table.assign(_value=clip_for_training(pairs_table[column], column))
    province_event = bounded[bounded["province"].eq(province) & bounded["event_type"].eq(event_type)]["_value"]
    if len(province_event):
        return float(province_event.mean())
    same_event = bounded[bounded["event_type"].eq(event_type)]["_value"]
    return float(same_event.mean()) if len(same_event) else float(bounded["_value"].mean())


def event_mix(bundle):
    # Part attendue de renouvellements / relocations par immeuble, rétrécie vers la province.
    roster = bundle.roster[bundle.roster["comparable"]]
    recent = bundle.pairs[bundle.pairs["event_year"].between(bundle.target_year - SPEC.trailing_years_mix, bundle.target_year - 1)]
    overall_rate = (float(recent["event_type"].eq("renewal").mean()) if len(recent)
                    else float(bundle.pairs["event_type"].eq("renewal").mean()))
    rows = []
    for property_code, units in roster.groupby("property_code"):
        province = units["province"].iloc[0]
        property_recent = recent[recent["property_code"].eq(property_code)]
        province_recent = recent[recent["province"].eq(province)]
        prior_rate = float(province_recent["event_type"].eq("renewal").mean()) if len(province_recent) else overall_rate
        renewal_rate = ((property_recent["event_type"].eq("renewal").sum() + SPEC.shrink_k_mix * prior_rate)
                        / (len(property_recent) + SPEC.shrink_k_mix))
        for event_type, share in (("renewal", renewal_rate), ("turnover", 1.0 - renewal_rate)):
            rows.append({"property_code": property_code, "province": province, "event_type": event_type,
                         "roster_units": len(units), "event_share": float(share), "expected_units": len(units) * float(share),
                         "roster_prior_gap": float(units["prior_gap"].mean()), "n_recent_pairs": int(len(property_recent))})
    mix_table = pd.DataFrame(rows)
    mix_table["weight"] = mix_table["expected_units"] / mix_table["expected_units"].sum()
    return mix_table


def roster_bridge(bundle, property_code, face_growth, new_gap):
    # Moyenne des identités unité par unité du roster d'un immeuble (prochain début = fin actuelle + 1 jour).
    units = bundle.roster[bundle.roster["property_code"].eq(property_code) & bundle.roster["comparable"]]
    return float(np.mean(exact_bridge(face_growth, units["prior_gap"], new_gap, units["expected_years"])))

Les composantes sont calculées pour chaque cellule immeuble × événement, combinées à poids égaux, puis agrégées avec le poids attendu de chaque cellule (unités du roster × part de l'événement). Calculer l'identité par unité puis faire la moyenne évite de remplacer une moyenne de ratios par un ratio de moyennes.

def component_cells(bundle):
    # DIRECT, BRIDGE, ANCHOR et contractuel pour chaque immeuble × événement.
    known_pairs, target_year = bundle.pairs, bundle.target_year
    last_year = known_pairs[known_pairs["event_year"].eq(target_year - 1)]
    if last_year.empty:   # aucun bail commencé l'an dernier : année la plus récente disponible
        last_year = known_pairs[known_pairs["event_year"].eq(known_pairs["event_year"].max())]
    weights = event_mix(bundle)
    direct = shrunk_cell_means(last_year, "effective_growth", SPEC.shrink_k_cell)
    face_last = shrunk_cell_means(last_year, "face_growth", SPEC.shrink_k_cell)
    gap_last = shrunk_cell_means(last_year, "new_gap", SPEC.shrink_k_cell)
    anchor = shrunk_cell_means(known_pairs, "effective_growth", SPEC.shrink_k_cell)
    face_anchor = shrunk_cell_means(known_pairs, "face_growth", SPEC.shrink_k_cell)
    rows = []
    for _, cell in weights.iterrows():
        key = (cell["property_code"], cell["event_type"], cell["province"])
        face_persistence = cell_value(face_last, last_year, "face_growth", *key)
        new_gap_persistence = cell_value(gap_last, last_year, "new_gap", *key)
        rows.append({**cell.to_dict(),
                     "DIRECT": cell_value(direct, last_year, "effective_growth", *key),
                     "BRIDGE": roster_bridge(bundle, cell["property_code"], face_persistence, new_gap_persistence),
                     "ANCHOR": cell_value(anchor, known_pairs, "effective_growth", *key),
                     "face_persistence": face_persistence, "face_anchor": cell_value(face_anchor, known_pairs, "face_growth", *key),
                     "new_gap_persistence": new_gap_persistence,
                     "n_last_year_pairs": int((last_year["property_code"].eq(cell["property_code"])
                                               & last_year["event_type"].eq(cell["event_type"])).sum())})
    return pd.DataFrame(rows)


def combine(cells, components=None, weights=None):
    # Combinaison à poids fixés : effectif = moyenne des composantes; contractuel = mélange dernière année / historique.
    components = SPEC.components if components is None else components
    weights = SPEC.component_weights if weights is None else weights
    if (not components or len(components) != len(weights) or not np.isfinite(weights).all() or min(weights) < 0
            or abs(sum(weights) - 1.0) > 1e-9 or len(set(components)) != len(components)
            or not set(components).issubset(SPEC.components)):
        raise DataContractError("les poids doivent correspondre aux composantes et sommer à 1")
    combined = cells.copy()
    combined["effective"] = sum(weight * combined[name] for name, weight in zip(components, weights))
    combined["face"] = (SPEC.face_persistence_weight * combined["face_persistence"]
                        + (1 - SPEC.face_persistence_weight) * combined["face_anchor"])
    return combined


def aggregate(cells, column="effective"):
    # Moyenne pondérée par le poids attendu de chaque cellule.
    return float(np.average(cells[column], weights=cells["weight"]))


def forecast(bundle, components=None, weights=None):
    cells = combine(component_cells(bundle), components, weights)
    return {"target_year": bundle.target_year, "origin": bundle.origin.date().isoformat(),
            "effective": aggregate(cells, "effective"), "face": aggregate(cells, "face"),
            "components": {name: aggregate(cells, name) for name in SPEC.components},
            "renewal_share": float(cells.loc[cells["event_type"].eq("renewal"), "weight"].sum()),
            "roster_units": int(bundle.roster["comparable"].sum()),
            "roster_excluded": int((~bundle.roster["comparable"]).sum()),
            "cells": cells, "bundle": bundle.summary}

7.4 estimate_2026() et backtest() complétées

Les deux fonctions du starter, avec la même signature. estimate_2026 renvoie un pourcentage (4,8 veut dire 4,8 %). backtest(leases, target_year) exécute la même méthode comme si nous étions au 31 décembre de target_year − 1, puis la compare à ce qui s'est réellement passé pendant target_year. Le résultat réalisé contient toutes les paires admissibles de l'année, sans aucune borne.

def realized(lease_table, target_year):
    # Ce qui s'est réellement passé : toutes les paires admissibles dont le nouveau bail commence pendant l'année cible.
    all_pairs = build_pairs(lease_table)
    return all_pairs[all_pairs["eligible"] & all_pairs["event_year"].eq(target_year)].copy()


def backtest_detail(lease_table, target_year, components=None, weights=None):
    bundle = make_bundle(lease_table, target_year)
    prediction = forecast(bundle, components, weights)
    actual_pairs = realized(lease_table, target_year)
    if actual_pairs.empty:
        raise DataContractError(f"aucune paire réalisée en {target_year}")
    actual_effective, actual_face = float(actual_pairs["effective_growth"].mean()), float(actual_pairs["face_growth"].mean())
    actual_by_cell = actual_pairs.groupby(["property_code", "event_type"]).agg(
        actual_effective=("effective_growth", "mean"), actual_face=("face_growth", "mean"),
        realized_n=("effective_growth", "size")).reset_index()
    cells = prediction["cells"].merge(actual_by_cell, on=["property_code", "event_type"], how="left")
    cells["abs_error"] = (cells["effective"] - cells["actual_effective"]).abs()
    scored = cells.dropna(subset=["actual_effective"])
    segment_mae = float(np.average(scored["abs_error"], weights=scored["realized_n"])) if len(scored) else np.nan
    # Diagnostics seulement : le roster n'est jamais repondéré avec le mix réalisé.
    comparable_roster = bundle.roster[bundle.roster.comparable]
    in_roster = actual_pairs.merge(comparable_roster[["property_code", "unit_code", "term_seq"]],
                                   left_on=["property_code", "unit_code", "prior_seq"],
                                   right_on=["property_code", "unit_code", "term_seq"], how="inner", validate="many_to_one")
    roster_units_seen = in_roster[["property_code", "unit_code"]].drop_duplicates().shape[0]
    return {"target_year": target_year, "origin": prediction["origin"], "predicted": prediction["effective"],
            "actual": actual_effective, "error": prediction["effective"] - actual_effective,
            "predicted_face": prediction["face"], "actual_face": actual_face, "face_error": prediction["face"] - actual_face,
            "components": prediction["components"],
            "component_errors": {name: value - actual_effective for name, value in prediction["components"].items()},
            "segment_mae": segment_mae, "realized_pairs": int(len(actual_pairs)), "roster_units": prediction["roster_units"],
            "realized_in_roster": int(len(in_roster)), "target_roster_coverage": len(in_roster) / len(actual_pairs),
            "roster_observed_fraction": roster_units_seen / len(comparable_roster),
            "segment_target_coverage": float(scored.realized_n.sum() / len(actual_pairs)),
            "by_segment": cells.to_dict("records"), "cells": cells, "bundle": prediction["bundle"]}


def estimate_2026(leases, asking, external=None):
    # Retourne notre estimation de la hausse de loyer 2026, en pourcentage.
    # Définition : moyenne des croissances annualisées du loyer effectif (sRentEffective), même unité (sPropCode + sUnitCode),
    # baux consécutifs dont le nouveau bail commence en 2026. `asking` est accepté pour respecter la signature du starter :
    # le loyer affiché reste un contexte (section 6). `external` est vérifié par la règle de date de la section 7.2.
    external_asof(external, TARGET_YEAR)
    return PERCENT * forecast(make_bundle(leases, TARGET_YEAR))["effective"]


def backtest(leases, target_year):
    # Exécute la méthode comme si nous étions à la fin de target_year - 1, et compare avec ce qui s'est passé en target_year.
    detail = backtest_detail(leases, target_year)
    return {"target_year": target_year, "predicted_pct": PERCENT * detail["predicted"],
            "actual_pct": PERCENT * detail["actual"], "error_pp": PERCENT * detail["error"],
            "by_segment": detail["by_segment"], "segment_rate_units": "fraction"}


estimate_pct = estimate_2026(leases, asking, external)
forecast_2026 = forecast(make_bundle(leases, TARGET_YEAR))
display(pd.Series({"Hausse effective 2026 (%)": estimate_pct,
                   "Hausse contractuelle 2026 (%)": PERCENT * forecast_2026["face"],
                   **{f"Composante {name} (%)": PERCENT * value for name, value in forecast_2026["components"].items()},
                   "Part de renouvellements attendue": forecast_2026["renewal_share"],
                   "Unités du roster comparables": forecast_2026["roster_units"],
                   "Unités exclues (durée hors 0,5–2,5 ans)": forecast_2026["roster_excluded"]}).round(4))
Hausse effective 2026 (%)                    4.8047
Hausse contractuelle 2026 (%)                6.3780
Composante DIRECT (%)                        3.8592
Composante BRIDGE (%)                        7.5240
Composante ANCHOR (%)                        3.0309
Part de renouvellements attendue             0.6031
Unités du roster comparables               928.0000
Unités exclues (durée hors 0,5–2,5 ans)      3.0000
dtype: float64

7.5 Backtest sur 2023, 2024 et 2025, et comparaison à des méthodes simples

Le starter suggère for yr in (2023, 2024, 2025): backtest(leases, yr). Nous affichons le résultat en tableau. Pour voir plus de régimes, nous rejouons aussi 2020 à 2022, sans les présenter comme des échantillons indépendants.

Détails

Comparateurs, avec exactement les mêmes origines et la même cible : persistance (moyenne de l'année précédente), moyenne des trois dernières années, moyenne de tout l'historique. Aucun gagnant n'est choisi sur ces scores : les années 2023–2025 étaient déjà connues quand la méthode a été conçue, et trois points ne suffisent pas à départager des méthodes.

def rolling_evaluation(lease_table, target_years, components=None, weights=None):
    rows = []
    for year in target_years:
        detail = backtest_detail(lease_table, year, components, weights)
        rows.append({"target_year": year, "origin": detail["origin"],
                     "predicted_pct": PERCENT * detail["predicted"], "actual_pct": PERCENT * detail["actual"],
                     "error_pp": PERCENT * detail["error"], "abs_error_pp": PERCENT * abs(detail["error"]),
                     "predicted_face_pct": PERCENT * detail["predicted_face"], "actual_face_pct": PERCENT * detail["actual_face"],
                     "face_error_pp": PERCENT * detail["face_error"], "segment_mae_pp": PERCENT * detail["segment_mae"],
                     **{f"{name}_pct": PERCENT * value for name, value in detail["components"].items()},
                     **{f"{name}_error_pp": PERCENT * value for name, value in detail["component_errors"].items()},
                     "realized_pairs": detail["realized_pairs"], "roster_units": detail["roster_units"],
                     **{key: detail[key] for key in ["realized_in_roster", "target_roster_coverage",
                                                     "roster_observed_fraction", "segment_target_coverage"]}})
    return pd.DataFrame(rows)


def baseline_evaluation(lease_table, target_years):
    # Trois comparateurs simples, même origine et même cible; aucun choix du gagnant.
    rows = []
    for year in target_years:
        bundle = make_bundle(lease_table, year)
        actual = float(realized(lease_table, year)["effective_growth"].mean())
        for name, training in (("Last year pooled", bundle.pairs[bundle.pairs.event_year.eq(year - 1)]),
                               ("Trailing 3 years pooled", bundle.pairs[bundle.pairs.event_year.ge(year - 3)]),
                               ("All history pooled", bundle.pairs)):
            if training.empty:
                training = bundle.pairs
            prediction = float(training.effective_growth.mean())
            rows.append({"target_year": year, "model": name, "predicted_pct": PERCENT * prediction,
                         "actual_pct": PERCENT * actual, "error_pp": PERCENT * (prediction - actual)})
    return pd.DataFrame(rows)


required_backtests = pd.DataFrame([{key: value for key, value in backtest(leases, year).items()
                                    if key not in ("by_segment", "segment_rate_units")} for year in REQUIRED_BACKTEST_YEARS])
display(required_backtests.round(3))

evaluation_years = tuple(year for year in SPEC.evaluation_years if year < TARGET_YEAR)
all_folds = rolling_evaluation(leases, evaluation_years)
display(all_folds[["target_year", "predicted_pct", "actual_pct", "error_pp", "predicted_face_pct", "actual_face_pct",
                   "face_error_pp", "segment_mae_pp", "roster_units", "realized_pairs"]].round(3))

baselines = baseline_evaluation(leases, evaluation_years)
scores = baselines.groupby("model").error_pp.agg(MAE_pp=lambda error: error.abs().mean(), bias_pp="mean",
                                                 worst_pp=lambda error: error.abs().max())
scores.loc["Notre méthode (fixe)"] = [all_folds.abs_error_pp.mean(), all_folds.error_pp.mean(), all_folds.abs_error_pp.max()]
scores["MAE_2023_2025_pp"] = baselines[baselines.target_year.isin(REQUIRED_BACKTEST_YEARS)].groupby("model").error_pp.apply(
    lambda error: error.abs().mean())
scores.loc["Notre méthode (fixe)", "MAE_2023_2025_pp"] = all_folds[all_folds.target_year.isin(REQUIRED_BACKTEST_YEARS)].abs_error_pp.mean()
display(scores.round(3))

ax = all_folds.set_index("target_year")[["predicted_pct", "actual_pct"]].rename(
    columns={"predicted_pct": "Prévu au 31 décembre précédent", "actual_pct": "Réalisé"}).plot(marker="o", figsize=(9, 4))
ax.set(title="Backtest : prévision simulée vs réalisé (effectif, même unité)", ylabel="% annualisé", xlabel="Année cible",
       xticks=list(evaluation_years))
plt.tight_layout()
plt.show()
target_year predicted_pct actual_pct error_pp
0 2023 4.472 2.178 2.294
1 2024 2.620 1.951 0.669
2 2025 2.928 3.833 -0.905
target_year predicted_pct actual_pct error_pp predicted_face_pct actual_face_pct face_error_pp segment_mae_pp roster_units realized_pairs
0 2020 1.764 2.769 -1.005 2.005 3.034 -1.029 0.957 306 331
1 2021 2.743 3.753 -1.010 2.875 4.384 -1.509 1.149 353 355
2 2022 3.747 4.716 -0.968 3.881 5.273 -1.392 0.955 347 352
3 2023 4.472 2.178 2.294 4.581 2.089 2.492 2.248 402 416
4 2024 2.620 1.951 0.669 2.755 3.728 -0.973 1.743 640 698
5 2025 2.928 3.833 -0.905 3.589 7.559 -3.970 1.314 845 780
MAE_pp bias_pp worst_pp MAE_2023_2025_pp
model
All history pooled 1.282 -0.503 1.841 1.138
Last year pooled 1.295 -0.373 2.538 1.549
Trailing 3 years pooled 1.409 -0.372 1.754 1.421
Notre méthode (fixe) 1.142 -0.154 2.294 1.289

Lecture honnête. Sur les six années 2020–2025, notre méthode a une erreur moyenne plus faible que les trois comparateurs simples (environ 1,1 point, contre 1,3 à 1,4) et le biais le plus faible. Mais sa pire année (2023, où elle prolonge la forte hausse de 2022 alors que le marché ralentit) est plus mauvaise que celle de la moyenne historique, et sur la fenêtre exigée 2023–2025 la moyenne de tout l'historique fait un peu mieux dans cet extrait. Nous l'affichons sans remplacer la méthode par le gagnant d'années déjà consultées : ce serait choisir après coup. L'apport de notre méthode est opérationnel : elle se décompose par immeuble, par type d'événement, en contractuel et en concessions, ce qui permet de budgéter et d'expliquer. Sa supériorité prédictive n'est pas démontrée, et nous ne la prétendons pas.

La couverture du roster (paires réalisées dont le bail précédent était bien dans le roster) est un diagnostic; elle ne modifie jamais les poids après coup.

display(all_folds[["target_year", "realized_in_roster", "target_roster_coverage", "roster_observed_fraction",
                   "segment_target_coverage"]].round(3))
target_year realized_in_roster target_roster_coverage roster_observed_fraction segment_target_coverage
0 2020 303 0.915 0.990 1.000
1 2021 347 0.977 0.983 1.000
2 2022 342 0.972 0.986 1.000
3 2023 398 0.957 0.990 0.998
4 2024 629 0.901 0.983 0.990
5 2025 763 0.978 0.903 1.000

7.6 Réconciliation avec les données publiques

En bref : nos chiffres sont mis côte à côte avec la SCHL, l'IPC, le TAL et la ligne directrice de l'Ontario, province par province, pour en vérifier la vraisemblance.

Détails

Pour chaque source publique disponible au 31 décembre 2025, nous plaçons à côté la prévision interne de la même province, contractuelle et effective. Pour le TAL et la ligne directrice de l'Ontario, qui encadrent les renouvellements, la comparaison se fait sur les seules cellules de renouvellement. Ces écarts aident à défendre les hypothèses, sans prouver de causalité : notre parc récent, ses concessions et son mix diffèrent du marché d'une RMR. L'inoccupation n'est pas soustraite d'une croissance (cela n'aurait pas de sens). Ces quelques séries ne sont pas ajoutées comme variables du modèle : avec six années, ce serait du surajustement (la section 9 le confirme avec des variables publiques).

def external_comparison(lease_table, external_table, target_year=TARGET_YEAR):
    cells = forecast(make_bundle(lease_table, target_year))["cells"]
    rows = []
    for source in external_asof(external_table, target_year).itertuples():
        geography = source.geography.lower()
        province = "Ontario" if ("ontario" in geography or "ottawa" in geography) else "Quebec"   # régimes jamais mélangés
        province_cells = cells[cells.province.eq(province)]
        regulatory = "TAL" in source.metric or "guideline" in source.metric.lower()
        if regulatory:
            province_cells = province_cells[province_cells.event_type.eq("renewal")]
        face = PERCENT * np.average(province_cells.face, weights=province_cells.weight)
        effective = PERCENT * np.average(province_cells.effective, weights=province_cells.weight)
        is_vacancy = "vacancy" in source.metric.lower()
        rows.append({"geography": source.geography, "metric": source.metric, "reference_period": source.reference_period,
                     "release_date": source.release_date, "public_value": source.value,
                     "internal_scope": "renewal" if regulatory else "all events",
                     "internal_face_pct": face, "internal_effective_pct": effective,
                     "face_minus_benchmark_pp": np.nan if is_vacancy else face - source.value})
    return pd.DataFrame(rows)


display(external_comparison(leases, external).round(3))
geography metric reference_period release_date public_value internal_scope internal_face_pct internal_effective_pct face_minus_benchmark_pp
0 Quebec TAL estimated average basic adjustment (lagged) TAL 2025 2025-01-21 5.900 renewal 5.633 3.831 -0.267
1 Ontario Ontario rent increase guideline 2026 guideline 2025-06-30 2.100 renewal 5.448 3.011 3.348
2 Montreal CMA CMHC fixed-sample average rent change October 2025 2025-12-11 7.400 all events 6.350 4.876 -1.050
3 Ottawa-Gatineau CMA (Ontario part) CMHC fixed-sample average rent change October 2025 2025-12-11 3.500 all events 6.566 4.317 3.066
4 Montreal CMA CMHC apartment vacancy rate October 2025 2025-12-11 2.900 all events 6.350 4.876 NaN
5 Ottawa-Gatineau CMA (Ontario part) CMHC apartment vacancy rate October 2025 2025-12-11 3.000 all events 6.566 4.317 NaN
6 Montreal CMA Statistics Canada rented accommodation CPI yea... November 2025 2025-12-15 7.368 all events 6.350 4.876 -1.017
7 Ontario Statistics Canada rent CPI year-over-year November 2025 2025-12-15 4.069 all events 6.566 4.317 2.497

Lecture.

  • Québec, renouvellements : notre prévision contractuelle des renouvellements québécois (environ 5,6 %) est proche du dernier TAL connu à l'origine (5,9 %). Les deux sont cohérents.
  • Montréal, marché : la SCHL (7,4 %) et l'IPC loyers (7,4 %) sont au-dessus de notre contractuel (6,35 %). Notre effectif (4,9 %) est plus bas encore, à cause des concessions que les indices de marché ne mesurent pas.
  • Ontario, The Met : les renouvellements de The Met ont augmenté d'environ 6,3 % en contractuel en 2025 (section 6), bien au-dessus de la ligne directrice (2,5 % pour 2025, 2,1 % pour 2026). C'est cohérent avec un immeuble exempté, occupé pour la première fois après le 15 novembre 2018 : son premier bail dans l'extrait date de 2023 (section 3). Ce n'est pas une preuve; la date de première occupation reste à confirmer. Nous ne plafonnons donc pas The Met dans la prévision centrale, et le scénario Ontario ci-dessous reste conditionnel.
Détails

Notre tendance face au marché, année par année. Le graphique place, pour chaque province, nos croissances à unité identique réalisées (contractuel et effectif) à côté des séries publiques de la même année : loyer moyen à échantillon fixe de la SCHL (RMR), IPC loyers de Statistique Canada, et l'encadrement des renouvellements (TAL au Québec, ligne directrice en Ontario). Les losanges de 2026 sont notre prévision.

FIRST_MARKET_YEAR = 2021
REFERENCE_YEAR_PATTERN = r"(\d{4})"
MARKET_PANELS = (("Quebec", "Montreal CMA", "Montreal CMA", "TAL", "Québec (5 immeubles)"),
                 ("Ontario", "Ottawa-Gatineau CMA (Ontario part)", "Ontario", "guideline", "Ontario (The Met)"))

internal_by_province = eligible_pairs.groupby(["province", "event_year"]).agg(
    face_pct=("face_growth", lambda growth: PERCENT * growth.mean()),
    effective_pct=("effective_growth", lambda growth: PERCENT * growth.mean()))
forecast_cells = forecast_2026["cells"]
public_series = external.assign(reference_year=external.reference_period.str.extract(REFERENCE_YEAR_PATTERN, expand=False).astype(int))

fig, axes = plt.subplots(1, 2, figsize=(14, 4.5), sharey=True)
for ax, (province, cma, cpi_geography, regulator, title) in zip(axes, MARKET_PANELS):
    internal = internal_by_province.loc[province].loc[FIRST_MARKET_YEAR:LAST_OBSERVED_YEAR]
    province_cells = forecast_cells[forecast_cells.province.eq(province)]
    ax.plot(internal.index, internal.face_pct, "o-", color="tab:blue", label="Équinoxe, contractuel (même unité)")
    ax.plot(internal.index, internal.effective_pct, "o-", color="tab:orange", label="Équinoxe, effectif (même unité)")
    ax.plot([TARGET_YEAR], [PERCENT * np.average(province_cells.face, weights=province_cells.weight)], "D", color="tab:blue", ms=9)
    ax.plot([TARGET_YEAR], [PERCENT * np.average(province_cells.effective, weights=province_cells.weight)], "D", color="tab:orange",
            ms=9, label="Notre prévision 2026")
    cmhc = public_series[public_series.geography.eq(cma) & public_series.metric.str.contains("fixed-sample")]
    cpi = public_series[public_series.geography.eq(cpi_geography) & public_series.metric.str.contains("CPI")]
    rules = public_series[public_series.metric.str.contains(regulator)]
    ax.plot(cmhc.reference_year, cmhc.value, "s--", color="tab:green", label="SCHL, loyer moyen à échantillon fixe (RMR)")
    ax.plot(cpi.reference_year, cpi.value, "^--", color="tab:purple", label="IPC loyers (Statistique Canada)")
    ax.plot(rules.reference_year, rules.value, "x:", color="black", ms=8, label="Encadrement des renouvellements (TAL / ligne directrice)")
    ax.set(title=title, xlabel="Année", xticks=list(range(FIRST_MARKET_YEAR, TARGET_YEAR + 1)))
    ax.grid(alpha=0.3)
axes[0].set_ylabel("% par an")
axes[1].legend(loc="upper left", bbox_to_anchor=(1.02, 1), fontsize=9)
plt.tight_layout()
plt.show()

Lecture. Au Québec, notre contractuel à unité identique est au niveau du marché (SCHL) en 2021–2022, puis décroche nettement en 2023–2024 : il colle alors presque au TAL (2,1 % contre 2,3 % en 2023, 3,7 % contre 4,0 % en 2024), pendant que la SCHL et l'IPC restent au-dessus de 6 %. En 2025, il rejoint le marché (7,6 % contre 7,4 % pour la SCHL). Notre effectif reste sous les indices depuis 2023 : les indices publics ne mesurent pas les mois gratuits, alors que nous les mesurons. En Ontario, le marché d'Ottawa ralentit en 2025 (SCHL 3,5 %) alors que les loyers contractuels de The Met accélèrent; l'effectif, lui, reste modéré. Ces séries publiques servent à vérifier la vraisemblance de notre prévision, pas à la calculer : la section 9 montre que les ajouter au modèle n'améliore pas les backtests.

Le TAL aide-t-il à prévoir les renouvellements québécois? Test fixé avant de l'exécuter. Sur 2023–2025, la règle « dernière année + (TAL de l'année − TAL de l'année précédente) » pour la croissance contractuelle des renouvellements au Québec doit battre à la fois la moyenne des années antérieures et la persistance, en erreur moyenne et en pire année. Pas de pente ajustée : il n'existe que trois valeurs TAL avant la dernière année. On suppose même le TAL de l'année connu (publié en janvier), ce qui avantage l'hypothèse. Si le test échoue, le TAL reste un contexte et les scénarios réglementaires sont des sensibilités, pas des prévisions.

def tal_passthrough_test(lease_table, external_table, test_years=REQUIRED_BACKTEST_YEARS):
    is_tal = external_table["metric"].str.contains("TAL", na=False)
    tal_by_year = {int(str(period).split()[-1]): float(value)
                   for period, value in zip(external_table.loc[is_tal, "reference_period"], external_table.loc[is_tal, "value"])}
    all_pairs = build_pairs(lease_table)
    quebec_renewals = all_pairs[all_pairs["eligible"] & all_pairs["province"].eq("Quebec") & all_pairs["event_type"].eq("renewal")]
    actual_by_year = PERCENT * quebec_renewals.groupby("event_year")["face_growth"].mean()
    rows = []
    for year in test_years:
        if year not in tal_by_year or year - 1 not in tal_by_year:
            raise DataContractError(f"valeurs TAL {year - 1} et {year} requises")
        earlier = actual_by_year[actual_by_year.index < year]
        rows.append({"year": year, "actual_pct": float(actual_by_year[year]), "all_prior_mean_pct": float(earlier.mean()),
                     "persistence_pct": float(earlier.iloc[-1]),
                     "tal_passthrough_pct": float(earlier.iloc[-1]) + tal_by_year[year] - tal_by_year[year - 1],
                     "tal_pct": tal_by_year[year]})
    table = pd.DataFrame(rows)
    errors = {method: (table[f"{method}_pct"] - table["actual_pct"]).abs()
              for method in ("all_prior_mean", "persistence", "tal_passthrough")}
    best_baseline = min(("all_prior_mean", "persistence"), key=lambda method: errors[method].mean())
    verdict = {"mae_pp": {method: float(error.mean()) for method, error in errors.items()},
               "worst_pp": {method: float(error.max()) for method, error in errors.items()},
               "criterion": "le TAL doit battre les deux comparateurs en erreur moyenne et en pire année"}
    verdict["retained"] = bool(errors["tal_passthrough"].mean() < min(errors["all_prior_mean"].mean(), errors["persistence"].mean())
                               and errors["tal_passthrough"].max() <= errors[best_baseline].max())
    return table, verdict


tal_table, tal_verdict = tal_passthrough_test(leases, external)
display(tal_table.round(2))
print(tal_verdict)
year actual_pct all_prior_mean_pct persistence_pct tal_passthrough_pct tal_pct
0 2023 2.55 3.92 5.83 6.85 2.3
1 2024 2.91 3.69 2.55 4.25 4.0
2 2025 6.53 3.58 2.91 4.81 5.9
{'mae_pp': {'all_prior_mean': 1.700063443886726, 'persistence': 2.419597978689527, 'tal_passthrough': 2.449407693602618}, 'worst_pp': {'all_prior_mean': 2.9456759355395805, 'persistence': 3.6143143314996435, 'tal_passthrough': 4.299194176938574}, 'criterion': 'le TAL doit battre les deux comparateurs en erreur moyenne et en pire année', 'retained': False}

Sensibilités réglementaires 2026 (publiées après l'origine). Le TAL 2026 (nouvelle méthode, moyenne triennale de l'IPC) et la ligne directrice de l'Ontario 2026 sont lus dans external_post_origin. Les cellules de renouvellement sont reprises au niveau de l'ancrage quand elles le dépassent, en gardant la variation d'écart de concession modélisée. Côté Ontario, la ligne directrice ne couvre que les logements sous contrôle des loyers; les immeubles occupés pour la première fois après le 15 novembre 2018 en sont exemptés, et la date de première occupation de The Met n'est pas dans l'extrait : ce scénario est conditionnel. Aucun plafond n'est appliqué d'une province à l'autre.

ANCHOR_PLAUSIBLE_RANGE_PCT = (-5.0, 25.0)   # garde-fou contre une saisie erronée de l'ancrage
TAL_2026_PCT = float(external_post_origin.loc[external_post_origin.metric.str.startswith("TAL 2026"), "value"].iloc[0])
ONTARIO_2026_PCT = float(external_post_origin.loc[external_post_origin.metric.eq("Ontario rent increase guideline"), "value"].iloc[0])


def regulatory_scenarios(cells, quebec_renewal_face_pct, ontario_renewal_face_pct):
    for name, anchor_pct in (("québec", quebec_renewal_face_pct), ("ontario", ontario_renewal_face_pct)):
        if not np.isfinite(anchor_pct) or not ANCHOR_PLAUSIBLE_RANGE_PCT[0] < anchor_pct < ANCHOR_PLAUSIBLE_RANGE_PCT[1]:
            raise DataContractError(f"ancrage {name} invraisemblable : {anchor_pct}")

    def reprice(label, anchors):
        repriced = cells.copy()
        for province, anchor_pct in anchors.items():
            renewal_cells = repriced["province"].eq(province) & repriced["event_type"].eq("renewal")
            excess = (repriced.loc[renewal_cells, "face"] - anchor_pct / PERCENT).clip(lower=0.0)
            repriced.loc[renewal_cells, "effective"] -= excess
            repriced.loc[renewal_cells, "face"] -= excess
        return {"scenario": label, "effective_pct": PERCENT * aggregate(repriced), "face_pct": PERCENT * aggregate(repriced, "face")}

    return pd.DataFrame([
        reprice("Prévision centrale", {}),
        reprice(f"Renouvellements Québec au TAL 2026 ({quebec_renewal_face_pct:g} %)", {"Quebec": quebec_renewal_face_pct}),
        reprice(f"Renouvellements Ontario à la ligne directrice ({ontario_renewal_face_pct:g} %), si The Met est sous contrôle",
                {"Ontario": ontario_renewal_face_pct}),
        reprice("Les deux ancrages", {"Quebec": quebec_renewal_face_pct, "Ontario": ontario_renewal_face_pct})])


display(external_post_origin[["geography", "metric", "value", "release_date", "limitation"]])
display(regulatory_scenarios(forecast_2026["cells"], TAL_2026_PCT, ONTARIO_2026_PCT).round(3))
geography metric value release_date limitation
0 Quebec TAL 2026 base rent-adjustment percentage 3.10 2026-01 Published AFTER the 2025-12-31 forecast origin...
1 Ontario Ontario rent increase guideline 2.10 2025-06-30 Applies only to rent-controlled units; buildin...
2 Quebec Statistics Canada rent CPI year-over-year, Aug... 3.02 2026-09-14 Single monthly reading; monthly rent CPI is no...
3 Ontario Statistics Canada rent CPI year-over-year, Aug... 2.37 2026-09-14 Single monthly reading; not used in any estimate.
scenario effective_pct face_pct
0 Prévision centrale 4.805 6.378
1 Renouvellements Québec au TAL 2026 (3.1 %) 3.465 5.038
2 Renouvellements Ontario à la ligne directrice ... 4.557 6.130
3 Les deux ancrages 3.217 4.790

7.7 Scénarios et enveloppe historique

Les concessions peuvent séparer fortement le contractuel de l'effectif : nous déplaçons l'écart des nouveaux baux de ±2 points, et la part de relocations de ±10 points. Les scénarios d'écart se comparent à la référence structurelle (BRIDGE seul avec les concessions actuelles); ceux de relocation, à la prévision centrale. Ce sont des hypothèses transparentes, pas des probabilités.

L'enveloppe = prévision centrale ± plus grande erreur annuelle observée en backtest. Avec six années, un changement de régime et des années déjà consultées, ce n'est pas un intervalle de confiance : c'est l'ordre de grandeur de l'erreur passée.

CONCESSION_GAP_SHOCK = 0.02     # ±2 points d'écart de concession sur les nouveaux baux
TURNOVER_SHARE_SHOCK = 0.10     # ±10 points de part de relocations


def scenario_table(bundle):
    central = combine(component_cells(bundle))
    rows = []

    def add(label, cells):
        rows.append({"scenario": label, "effective_pct": PERCENT * aggregate(cells), "face_pct": PERCENT * aggregate(cells, "face")})

    add("Prévision centrale", central)
    for label, gap_change in (("Référence structurelle : concessions actuelles", 0.0),
                              (f"Structurel : écart de concession +{PERCENT * CONCESSION_GAP_SHOCK:g} points", CONCESSION_GAP_SHOCK),
                              (f"Structurel : écart de concession −{PERCENT * CONCESSION_GAP_SHOCK:g} points", -CONCESSION_GAP_SHOCK)):
        shocked = central.copy()
        shocked["effective"] = [roster_bridge(bundle, cell.property_code, cell.face,
                                              float(np.clip(cell.new_gap_persistence + gap_change, *SPEC.gap_clip)))
                                for cell in shocked.itertuples()]
        add(label, shocked)
    for share_change in (TURNOVER_SHARE_SHOCK, -TURNOVER_SHARE_SHOCK):
        shocked = central.copy()
        for property_code in shocked.property_code.unique():
            turnover = shocked.property_code.eq(property_code) & shocked.event_type.eq("turnover")
            renewal = shocked.property_code.eq(property_code) & shocked.event_type.eq("renewal")
            turnover_share = float(np.clip(shocked.loc[turnover, "event_share"].iloc[0] + share_change, 0, 1))
            shocked.loc[turnover, "event_share"] = turnover_share
            shocked.loc[renewal, "event_share"] = 1 - turnover_share
        shocked["expected_units"] = shocked.roster_units * shocked.event_share
        shocked["weight"] = shocked.expected_units / shocked.expected_units.sum()
        add(f"Part de relocations {PERCENT * share_change:+.0f} points", shocked)
    return pd.DataFrame(rows)


scenarios = scenario_table(make_bundle(leases, TARGET_YEAR))
display(scenarios.round(3))
largest_backtest_error_pp = float(all_folds.abs_error_pp.max())
historical_envelope_pct = [estimate_pct - largest_backtest_error_pp, estimate_pct + largest_backtest_error_pp]
print(f"Enveloppe historique descriptive : {historical_envelope_pct[0]:.2f} % à {historical_envelope_pct[1]:.2f} % "
      f"(centrale ± plus grande erreur annuelle de backtest, {largest_backtest_error_pp:.2f} pts; pas un intervalle de confiance)")
scenario effective_pct face_pct
0 Prévision centrale 4.805 6.378
1 Référence structurelle : concessions actuelles 6.325 6.378
2 Structurel : écart de concession +2 points 4.012 6.378
3 Structurel : écart de concession −2 points 8.638 6.378
4 Part de relocations +10 points 5.077 6.575
5 Part de relocations -10 points 4.532 6.181
Enveloppe historique descriptive : 2.51 % à 7.10 % (centrale ± plus grande erreur annuelle de backtest, 2.29 pts; pas un intervalle de confiance)
8. Bonus : prévision par immeuble et par nombre de chambres, petit tableau de bordPar immeuble : les cellules du modèle, agrégées par sBuilding.

8. Bonus : prévision par immeuble et par nombre de chambres, petit tableau de bord

Par immeuble : les cellules du modèle, agrégées par sBuilding.

Détails

Par nombre de chambres : un écart par typologie, calculé sur les trois dernières années connues à l'origine et rétréci vers zéro (k = 20), est ajouté puis recentré sur le roster de chaque immeuble, de sorte que chaque immeuble se réconcilie exactement avec sa prévision. Cet écart n'est utile que s'il prédit mieux que « aucun effet de chambres » : le second tableau le vérifie sur les mêmes origines que le backtest. S'il ne le fait pas, le tableau par chambres reste descriptif.

BEDROOM_SHRINK_K = 20.0          # même force de rétrécissement que la part de renouvellements
BEDROOM_TRAILING_YEARS = 3       # même fenêtre que la part de renouvellements
building_names = validated_leases.groupby("sPropCode")["sBuilding"].first().rename("building")


def building_forecast(details_cells):
    cells = details_cells.merge(building_names, left_on="property_code", right_index=True, validate="many_to_one")
    return pd.DataFrame([{"building": name, "expected_events": building_cells.expected_units.sum(),
                          "face_pct": PERCENT * np.average(building_cells.face, weights=building_cells.weight),
                          "effective_pct": PERCENT * np.average(building_cells.effective, weights=building_cells.weight)}
                         for name, building_cells in cells.groupby("building")])


def bedroom_offsets(pairs_table, target_year):
    recent = pairs_table[pairs_table["event_year"].between(target_year - BEDROOM_TRAILING_YEARS, target_year - 1)]
    if recent.empty:
        raise DataContractError("aucune paire récente pour estimer les écarts par chambres")
    effective = clip_for_training(recent["effective_growth"], "effective_growth")
    face = clip_for_training(recent["face_growth"], "face_growth")
    rows = []
    for bedrooms, index in recent.groupby("bedrooms").groups.items():
        n_pairs = len(index)
        shrink = n_pairs / (n_pairs + BEDROOM_SHRINK_K)
        rows.append({"bedrooms": bedrooms, "n_pairs": n_pairs,
                     "effective_offset": shrink * float(effective.loc[index].mean() - effective.mean()),
                     "face_offset": shrink * float(face.loc[index].mean() - face.mean())})
    return pd.DataFrame(rows)


def bedroom_forecast(lease_table, details_cells, target_year=TARGET_YEAR):
    bundle = make_bundle(lease_table, target_year)
    offsets = bedroom_offsets(bundle.pairs, target_year).set_index("bedrooms")
    roster = bundle.roster[bundle.roster["comparable"]].merge(building_names, left_on="property_code", right_index=True,
                                                              validate="many_to_one")
    cells = details_cells.merge(building_names, left_on="property_code", right_index=True, validate="many_to_one")
    rows = []
    for building, units in roster.groupby("building"):
        building_cells = cells[cells["building"].eq(building)]
        base = {column: float(np.average(building_cells[column], weights=building_cells["weight"])) for column in ("effective", "face")}
        units_by_bedrooms = units.groupby("bedrooms").size()
        for column in ("effective", "face"):
            building_offsets = offsets[f"{column}_offset"].reindex(units_by_bedrooms.index).fillna(0.0)
            base[f"{column}_recentre"] = float(np.average(building_offsets, weights=units_by_bedrooms))
        for bedrooms, n_units in units_by_bedrooms.items():
            offset = offsets.reindex([bedrooms]).fillna(0.0).iloc[0]
            rows.append({"building": building, "bedrooms": bedrooms, "roster_units": int(n_units),
                         "effective_pct": PERCENT * (base["effective"] + offset["effective_offset"] - base["effective_recentre"]),
                         "face_pct": PERCENT * (base["face"] + offset["face_offset"] - base["face_recentre"])})
    return pd.DataFrame(rows)


def bedroom_offset_backtest(lease_table, target_years):
    # Les écarts par chambres prédisent-ils l'écart réalisé l'année suivante mieux que « aucun effet »?
    rows = []
    for year in target_years:
        bundle = make_bundle(lease_table, year)
        offsets = bedroom_offsets(bundle.pairs, year).set_index("bedrooms")["effective_offset"]
        actual_pairs = realized(lease_table, year)
        realized_spread = actual_pairs.groupby("bedrooms")["effective_growth"].mean() - actual_pairs["effective_growth"].mean()
        pairs_per_group = actual_pairs.groupby("bedrooms").size()
        common = realized_spread.index.intersection(offsets.index)
        weights = pairs_per_group.loc[common]
        rows.append({"target_year": year, "bedroom_groups": len(common),
                     "mae_with_offsets_pp": PERCENT * float(np.average((offsets.loc[common] - realized_spread.loc[common]).abs(), weights=weights)),
                     "mae_no_bedroom_effect_pp": PERCENT * float(np.average(realized_spread.loc[common].abs(), weights=weights))})
    check = pd.DataFrame(rows)
    check["offsets_better"] = check["mae_with_offsets_pp"] < check["mae_no_bedroom_effect_pp"]
    return check


by_building = building_forecast(forecast_2026["cells"])
display(by_building.round(3))
by_bedroom = bedroom_forecast(leases, forecast_2026["cells"])
display(by_bedroom.round(2))
bedroom_check = bedroom_offset_backtest(leases, SPEC.evaluation_years)
display(bedroom_check.round(3))
print("Années où les écarts par chambres battent « aucun effet » :", int(bedroom_check.offsets_better.sum()), "sur", len(bedroom_check))
building expected_events face_pct effective_pct
0 Daniel-Johnson 128.0 6.055 4.937
1 Le Carlyle 180.0 6.428 4.522
2 Levesque 75.0 5.745 4.749
3 Saint-Elzear 267.0 6.017 4.967
4 The Met 119.0 6.566 4.317
5 Westpark 159.0 7.347 5.137
building bedrooms roster_units effective_pct face_pct
0 Daniel-Johnson 0 12 4.98 6.34
1 Daniel-Johnson 1 54 4.94 6.24
2 Daniel-Johnson 2 58 4.94 5.82
3 Daniel-Johnson 3 4 4.71 6.09
4 Le Carlyle 1 68 4.53 6.69
5 Le Carlyle 2 107 4.53 6.26
6 Le Carlyle 3 5 4.30 6.53
7 Levesque 1 9 4.76 6.11
8 Levesque 2 64 4.75 5.69
9 Levesque 3 2 4.53 5.96
10 Saint-Elzear 1 77 4.98 6.31
11 Saint-Elzear 2 182 4.97 5.89
12 Saint-Elzear 3 8 4.75 6.16
13 The Met 0 37 4.34 6.76
14 The Met 1 46 4.31 6.67
15 The Met 2 36 4.30 6.24
16 Westpark 1 61 5.15 7.61
17 Westpark 2 95 5.14 7.18
18 Westpark 3 3 4.91 7.45
target_year bedroom_groups mae_with_offsets_pp mae_no_bedroom_effect_pp offsets_better
0 2020 3 0.183 0.108 False
1 2021 3 0.071 0.073 True
2 2022 3 0.212 0.256 True
3 2023 3 0.162 0.087 False
4 2024 4 0.349 0.194 False
5 2025 4 0.124 0.122 False
Années où les écarts par chambres battent « aucun effet » : 2 sur 6
# Tableau de bord : prévision 2026 par immeuble, contractuel et effectif, avec l'enveloppe historique descriptive.
dashboard = by_building.set_index("building")
envelope_half_width_pp = (historical_envelope_pct[1] - historical_envelope_pct[0]) / 2
rows_position = np.arange(len(dashboard))
BAR_HEIGHT = 0.4
fig, ax = plt.subplots(figsize=(9, 4))
ax.barh(rows_position + BAR_HEIGHT / 2, dashboard.face_pct, BAR_HEIGHT, label="Contractuel")
ax.barh(rows_position - BAR_HEIGHT / 2, dashboard.effective_pct, BAR_HEIGHT, xerr=envelope_half_width_pp, capsize=3,
        label="Effectif (± enveloppe historique)")
ax.set_yticks(rows_position, [f"{name} ({events:.0f} év.)" for name, events in zip(dashboard.index, dashboard.expected_events)])
ax.axvline(estimate_pct, color="black", ls="--", lw=1, label=f"Portefeuille effectif : {estimate_pct:.2f} %")
ax.set(title="Prévision 2026 par immeuble", xlabel="% annualisé, même unité")
ax.legend(loc="upper center", bbox_to_anchor=(0.5, -0.18), ncol=3)
plt.tight_layout()
plt.show()
8b. Pourquoi ce chiffre? Lecture immeuble par immeubleUn gestionnaire ne veut pas seulement « +4,9 % » : il veut savoir pourquoi. Pour chaque immeuble, la prévision se réconcilie exactement en trois morceaux :

8b. Pourquoi ce chiffre? Lecture immeuble par immeuble

Un gestionnaire ne veut pas seulement « +4,9 % » : il veut savoir pourquoi. Pour chaque immeuble, la prévision se réconcilie exactement en trois morceaux :

Détails
  • Renouvellements : part attendue des baux 2026 qui sont des renouvellements × hausse contractuelle prévue pour eux;
  • Relocations : part attendue des relocations × hausse contractuelle prévue pour elles;
  • Ajustement de concession : l'écart entre la prévision effective et la prévision contractuelle de l'immeuble, en points de pourcentage.

Renouvellements + relocations = hausse contractuelle de l'immeuble; plus l'ajustement de concession = hausse effective. La cellule vérifie que la somme retombe exactement sur la prévision de la section 8. Une précision honnête : la prévision contractuelle combine deux lectures et la prévision effective en combine trois (section 7.3), donc cet écart est une réconciliation entre les deux prévisions du modèle, pas une mesure isolée de l'effet causal des mois gratuits. Il s'interprète comme « ce que le modèle s'attend à voir repris par les concessions »; pour isoler un effet purement causal, il faudrait un contrefactuel qui ne change que les concessions. Nous ajoutons deux repères tirés des données : la hausse effective réalisée en 2025 (l'élan récent) et la moyenne de long terme de l'immeuble (la tendance).

Enfin, le contexte public autour de chaque immeuble (fichier public building_context.csv : chantiers résidentiels autorisés à moins de 2 km, transport rapide, score d'emplacement, zone inondable, îlot de chaleur). Ce contexte aide à discuter le chiffre; il n'entre pas dans le calcul, car aucune de ces variables n'a amélioré les backtests.

def explain_building(cells):
    # Réconciliation exacte d'un immeuble : renouvellements + relocations + ajustement de concession (effectif − contractuel) = effectif.
    weights = cells["weight"]
    renewal = cells["event_type"].eq("renewal")
    renewal_share = float(weights[renewal].sum() / weights.sum())
    face_renewal = float(np.average(cells.loc[renewal, "face"], weights=weights[renewal]))
    face_turnover = float(np.average(cells.loc[~renewal, "face"], weights=weights[~renewal]))
    face = float(np.average(cells["face"], weights=weights))
    effective = float(np.average(cells["effective"], weights=weights))
    return {"renewal_share": renewal_share,
            "renewal_face_pct": PERCENT * face_renewal, "turnover_face_pct": PERCENT * face_turnover,
            "renewals_pp": PERCENT * renewal_share * face_renewal,
            "turnovers_pp": PERCENT * (1 - renewal_share) * face_turnover,
            "concessions_pp": -PERCENT * (face - effective),
            "face_pct": PERCENT * face, "effective_pct": PERCENT * effective,
            "momentum_DIRECT_pct": PERCENT * float(np.average(cells["DIRECT"], weights=weights)),
            "trend_ANCHOR_pct": PERCENT * float(np.average(cells["ANCHOR"], weights=weights))}


cells_with_building = forecast_2026["cells"].merge(building_names, left_on="property_code", right_index=True, validate="many_to_one")
why = pd.DataFrame({building: explain_building(cells) for building, cells in cells_with_building.groupby("building")}).T
pairs_with_building = eligible_pairs.merge(building_names, left_on="property_code", right_index=True, validate="many_to_one")
why["realised_2025_effective_pct"] = PERCENT * pairs_with_building[pairs_with_building.event_year.eq(LAST_OBSERVED_YEAR)].groupby(
    "building").effective_growth.mean()
why = why.sort_values("effective_pct", ascending=False)

# Les trois morceaux retombent exactement sur la prévision par immeuble de la section 8 (vérification arithmétique, pas une attribution causale).
np.testing.assert_allclose(why.renewals_pp + why.turnovers_pp + why.concessions_pp, why.effective_pct, atol=1e-9)
np.testing.assert_allclose(why.effective_pct, by_building.set_index("building").loc[why.index, "effective_pct"], atol=1e-9)
display(why.round(2))
renewal_share renewal_face_pct turnover_face_pct renewals_pp turnovers_pp concessions_pp face_pct effective_pct momentum_DIRECT_pct trend_ANCHOR_pct realised_2025_effective_pct
Westpark 0.62 6.14 9.34 3.83 3.52 -2.21 7.35 5.14 3.80 3.81 3.78
Saint-Elzear 0.60 5.47 6.84 3.29 2.73 -1.05 6.02 4.97 4.39 3.17 4.64
Daniel-Johnson 0.60 5.20 7.33 3.11 2.94 -1.12 6.05 4.94 4.14 2.96 4.41
Levesque 0.55 5.38 6.19 2.96 2.78 -1.00 5.75 4.75 3.59 2.89 3.17
Le Carlyle 0.63 5.80 7.50 3.65 2.78 -1.91 6.43 4.52 3.59 2.55 3.54
The Met 0.58 5.45 8.10 3.15 3.42 -2.25 6.57 4.32 3.01 2.59 3.12
WATERFALL_STEPS = [("renewals_pp", "Renouvel-\nlements", "tab:blue"), ("turnovers_pp", "Relocations", "tab:cyan"),
                   ("concessions_pp", "Concession adjustment / Ajustement de concession", "tab:red")]
fig, axes = plt.subplots(2, 3, figsize=(14, 7), sharey=True)
for ax, (building, row) in zip(axes.ravel(), why.iterrows()):
    level = 0.0
    for position, (column, label, color) in enumerate(WATERFALL_STEPS):
        ax.bar(position, row[column], bottom=level, color=color)
        ax.text(position, level + row[column] / 2, f"{row[column]:+.1f}", ha="center", va="center", color="white", fontweight="bold")
        level += row[column]
    ax.bar(len(WATERFALL_STEPS), row.effective_pct, color="black")
    ax.text(len(WATERFALL_STEPS), row.effective_pct / 2, f"{row.effective_pct:.1f} %", ha="center", va="center", color="white", fontweight="bold")
    ax.set_xticks(range(len(WATERFALL_STEPS) + 1), [label for _, label, _ in WATERFALL_STEPS] + ["Effectif\n2026"])
    ax.set_title(building)
    ax.axhline(0, color="grey", lw=0.8)
axes[0, 0].set_ylabel("points de pourcentage")
axes[1, 0].set_ylabel("points de pourcentage")
fig.suptitle("Ce qui fait la hausse 2026 de chaque immeuble", fontsize=13)
plt.tight_layout()
plt.show()

En mots, immeuble par immeuble. Le texte ci-dessous est généré à partir des chiffres de la décomposition et du fichier de contexte public, pour qu'il reste toujours cohérent avec eux.

from IPython.display import Markdown

FLOOD_ZONE_NEAR_M = 250          # en deçà, la proximité d'une zone inondable mérite d'être signalée
building_context = pd.read_csv("building_context.csv").set_index("building")


def fr_number(value, decimals=1):
    return f"{value:,.{decimals}f}".replace(",", " ").replace(".", ",")


def building_story(building, row, context):
    lines = [f"**{building} : {fr_number(row.effective_pct)} % effectif** ({fr_number(row.face_pct)} % contractuel).",
             f"Sur papier, les loyers montent de {fr_number(row.face_pct)} % : environ {fr_number(PERCENT * row.renewal_share, 0)} % des baux attendus "
             f"sont des renouvellements (prévus à +{fr_number(row.renewal_face_pct)} %) et le reste des relocations (+{fr_number(row.turnover_face_pct)} %). "
             f"Les concessions en reprennent {fr_number(-row.concessions_pp)} {'point' if -row.concessions_pp < 2 else 'points'}.",
             f"Repères : en {LAST_OBSERVED_YEAR}, ses loyers effectifs à unité identique ont augmenté de {fr_number(row.realised_2025_effective_pct)} %; "
             f"sa tendance de long terme est de {fr_number(row.trend_ANCHOR_pct)} % par an."]
    notes = [f"à {fr_number(context.rapid_transit_distance_m, 0)} m de {context.nearest_rapid_transit.replace('(metro)', '(métro)')}",
             f"score d'emplacement {context.location_score_0_100}/100"]
    if pd.notna(context.units_permitted_2km_2023_2025):
        notes.append(f"{fr_number(context.units_permitted_2km_2023_2025, 0)} logements autorisés à moins de 2 km en 2023–2025 "
                     "(offre nouvelle à surveiller : plus d'offre tend à augmenter les concessions)")
    else:
        notes.append("permis de construire non publiés par la ville")
    if context.flood_zone_distance_m < FLOOD_ZONE_NEAR_M:
        notes.append(f"à {context.flood_zone_distance_m} m d'une zone inondable cartographiée")
    if context.heat_island is True or str(context.heat_island) == "True":
        notes.append("îlot de chaleur urbain")
    lines.append("Contexte public (non utilisé dans le calcul) : " + "; ".join(notes) + ".")
    return "\n\n".join(lines)


display(Markdown("\n\n---\n\n".join(building_story(building, row, building_context.loc[building]) for building, row in why.iterrows())))

Westpark : 5,1 % effectif (7,3 % contractuel).

Sur papier, les loyers montent de 7,3 % : environ 62 % des baux attendus sont des renouvellements (prévus à +6,1 %) et le reste des relocations (+9,3 %). L'ajustement de concession (écart entre la prévision effective et la prévision contractuelle) en reprend 2,2 points.

Repères : en 2025, ses loyers effectifs à unité identique ont augmenté de 3,8 %; sa tendance de long terme est de 3,8 % par an.

Contexte public (non utilisé dans le calcul) : à 1 143 m de Fairview-Pointe-Claire (REM); score d'emplacement 43/100; permis de construire non publiés par la ville.


Saint-Elzear : 5,0 % effectif (6,0 % contractuel).

Sur papier, les loyers montent de 6,0 % : environ 60 % des baux attendus sont des renouvellements (prévus à +5,5 %) et le reste des relocations (+6,8 %). L'ajustement de concession (écart entre la prévision effective et la prévision contractuelle) en reprend 1,0 point.

Repères : en 2025, ses loyers effectifs à unité identique ont augmenté de 4,6 %; sa tendance de long terme est de 3,2 % par an.

Contexte public (non utilisé dans le calcul) : à 4 566 m de Montmorency (métro); score d'emplacement 19/100; 2 173 logements autorisés à moins de 2 km en 2023–2025 (offre nouvelle à surveiller : plus d'offre tend à augmenter les concessions).


Daniel-Johnson : 4,9 % effectif (6,1 % contractuel).

Sur papier, les loyers montent de 6,1 % : environ 60 % des baux attendus sont des renouvellements (prévus à +5,2 %) et le reste des relocations (+7,3 %). L'ajustement de concession (écart entre la prévision effective et la prévision contractuelle) en reprend 1,1 point.

Repères : en 2025, ses loyers effectifs à unité identique ont augmenté de 4,4 %; sa tendance de long terme est de 3,0 % par an.

Contexte public (non utilisé dans le calcul) : à 2 127 m de Montmorency (métro); score d'emplacement 58/100; 3 944 logements autorisés à moins de 2 km en 2023–2025 (offre nouvelle à surveiller : plus d'offre tend à augmenter les concessions); îlot de chaleur urbain.


Levesque : 4,7 % effectif (5,7 % contractuel).

Sur papier, les loyers montent de 5,7 % : environ 55 % des baux attendus sont des renouvellements (prévus à +5,4 %) et le reste des relocations (+6,2 %). L'ajustement de concession (écart entre la prévision effective et la prévision contractuelle) en reprend 1,0 point.

Repères : en 2025, ses loyers effectifs à unité identique ont augmenté de 3,2 %; sa tendance de long terme est de 2,9 % par an.

Contexte public (non utilisé dans le calcul) : à 2 047 m de Bois-Franc (REM); score d'emplacement 32/100; 3 692 logements autorisés à moins de 2 km en 2023–2025 (offre nouvelle à surveiller : plus d'offre tend à augmenter les concessions); à 54 m d'une zone inondable cartographiée.


Le Carlyle : 4,5 % effectif (6,4 % contractuel).

Sur papier, les loyers montent de 6,4 % : environ 63 % des baux attendus sont des renouvellements (prévus à +5,8 %) et le reste des relocations (+7,5 %). L'ajustement de concession (écart entre la prévision effective et la prévision contractuelle) en reprend 1,9 point.

Repères : en 2025, ses loyers effectifs à unité identique ont augmenté de 3,5 %; sa tendance de long terme est de 2,5 % par an.

Contexte public (non utilisé dans le calcul) : à 526 m de De La Savane (métro); score d'emplacement 59/100; 1 077 logements autorisés à moins de 2 km en 2023–2025 (offre nouvelle à surveiller : plus d'offre tend à augmenter les concessions).


The Met : 4,3 % effectif (6,6 % contractuel).

Sur papier, les loyers montent de 6,6 % : environ 58 % des baux attendus sont des renouvellements (prévus à +5,4 %) et le reste des relocations (+8,1 %). L'ajustement de concession (écart entre la prévision effective et la prévision contractuelle) en reprend 2,2 points.

Repères : en 2025, ses loyers effectifs à unité identique ont augmenté de 3,1 %; sa tendance de long terme est de 2,6 % par an.

Contexte public (non utilisé dans le calcul) : à 479 m de Parliament (O-Train); score d'emplacement 86/100; 526 logements autorisés à moins de 2 km en 2023–2025 (offre nouvelle à surveiller : plus d'offre tend à augmenter les concessions).

9. Un modèle de régression fait-il mieux? XGBoost et Ridge, testés et refusésLa consigne permet un modèle de régression s'il est justifié. Nous l'avons testé honnêtement, avec une règle écrite avant la première exécution :

9. Un modèle de régression fait-il mieux? XGBoost et Ridge, testés et refusés

La consigne permet un modèle de régression s'il est justifié. Nous l'avons testé honnêtement, avec une règle écrite avant la première exécution :

Détails
  • XGBoost (profondeur 3, 300 arbres, taux 0,05, sous-échantillonnage 0,8) et Ridge (alpha 10 sur variables standardisées), entraînés au niveau de la paire, avec uniquement des informations connues à l'origine : type d'événement, province, chambres, intervalle, écart de concession précédent, mois de début, ancienneté de l'immeuble, position du loyer dans l'immeuble, et moyennes de l'année précédente (cellule et portefeuille).
  • Mêmes origines, même roster, même agrégation et même cible que notre méthode.
  • Règle de remplacement : un modèle ne remplace notre méthode que si son erreur moyenne est plus faible que la nôtre et que celle de la moyenne historique, sur 2020–2025 et sur 2023–2025, sans pire année plus mauvaise que la nôtre. Aucun hyperparamètre n'a été ajusté sur ces années.

Les contributions SHAP expliquent la prévision 2026 de XGBoost, variable par variable. Le modèle n'étant pas retenu, aucun fichier de modèle entraîné n'est livré (les paramètres de notre méthode sont dans ForecastSpec, section 4b).

from sklearn.linear_model import Ridge
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler

RANDOM_SEED = 20_260_101
XGB_PARAMS = dict(max_depth=3, n_estimators=300, learning_rate=0.05, subsample=0.8, colsample_bytree=0.8,
                  min_child_weight=10, reg_lambda=1.0, random_state=RANDOM_SEED, n_jobs=1)
RIDGE_ALPHA = 10.0
LAG_FEATURES = ["lag_cell_face", "lag_cell_eff", "lag_cell_gap", "lag_port_face", "lag_port_gap"]
MODEL_FEATURES = ["is_renewal", "is_ontario", "bedrooms", "interval_years", "prior_gap", "start_month",
                  "property_age", "rent_position"] + LAG_FEATURES


def lagged_means(known_pairs):
    # Moyennes de l'année précédente, par province × événement et pour le portefeuille.
    cell = known_pairs.groupby(["event_year", "province", "event_type"]).agg(
        lag_cell_face=("face_growth", "mean"), lag_cell_eff=("effective_growth", "mean"), lag_cell_gap=("new_gap", "mean")).reset_index()
    portfolio = known_pairs.groupby("event_year").agg(lag_port_face=("face_growth", "mean"), lag_port_gap=("new_gap", "mean")).reset_index()
    cell["event_year"] += 1
    portfolio["event_year"] += 1
    return cell, portfolio


def model_features(rows, known_pairs):
    cell, portfolio = lagged_means(known_pairs)
    first_year = known_pairs.groupby("property_code")["event_year"].min()
    mean_prior_rent = known_pairs.groupby("property_code")["prior_face_rent"].mean()
    features = rows.merge(cell, on=["event_year", "province", "event_type"], how="left").merge(portfolio, on="event_year", how="left")
    features["is_renewal"] = features["event_type"].eq("renewal").astype(float)
    features["is_ontario"] = features["province"].eq("Ontario").astype(float)
    features["property_age"] = features["event_year"] - features["property_code"].map(first_year).fillna(features["event_year"])
    features["rent_position"] = features["prior_face_rent"] / features["property_code"].map(mean_prior_rent).fillna(features["prior_face_rent"])
    features["bedrooms"] = features["bedrooms"].astype(float)
    return features


def roster_rows(bundle):
    # Une ligne par unité du roster et par type d'événement, pondérée par la part attendue.
    roster = bundle.roster[bundle.roster["comparable"]].reset_index(drop=True)
    shares = event_mix(bundle).set_index(["property_code", "event_type"])["event_share"]
    return pd.concat([pd.DataFrame({"unit": roster.index, "event_year": bundle.target_year, "province": roster["province"],
                                    "event_type": event_type, "property_code": roster["property_code"],
                                    "bedrooms": roster["bedrooms"], "interval_years": roster["expected_years"],
                                    "prior_gap": roster["prior_gap"],
                                    "start_month": (roster["lease_end"] + pd.Timedelta(days=1)).dt.month,
                                    "prior_face_rent": roster["face_rent"],
                                    "share": [shares[(code, event_type)] for code in roster["property_code"]]})
                      for event_type in ("renewal", "turnover")], ignore_index=True)


def fit_predict(kind, train, test):
    target = clip_for_training(train["effective_growth"], "effective_growth").to_numpy()
    if kind == "xgboost":
        regressor = xgboost.XGBRegressor(**XGB_PARAMS)
        regressor.fit(train[MODEL_FEATURES].to_numpy(float), target)
        return regressor.predict(test[MODEL_FEATURES].to_numpy(float)), regressor
    has_lags = train[LAG_FEATURES].notna().all(axis=1)
    usable = [feature for feature in MODEL_FEATURES if train.loc[has_lags, feature].notna().any()]
    training_means = train.loc[has_lags, usable].mean()
    regressor = make_pipeline(StandardScaler(), Ridge(alpha=RIDGE_ALPHA))
    regressor.fit(train.loc[has_lags, usable].fillna(training_means).to_numpy(float), target[has_lags.to_numpy()])
    return regressor.predict(test[usable].fillna(training_means).to_numpy(float)), regressor


def forecast_challenger(bundle, kind):
    known_pairs = bundle.pairs
    train = model_features(known_pairs.assign(start_month=known_pairs["lease_start"].dt.month), known_pairs)
    roster = model_features(roster_rows(bundle), known_pairs)
    predictions, regressor = fit_predict(kind, train, roster)
    roster = roster.assign(prediction=predictions)
    per_unit = roster.groupby("unit").apply(lambda unit: float(np.average(unit["prediction"], weights=unit["share"])),
                                            include_groups=False)
    return {"portfolio": float(per_unit.mean()), "roster": roster, "model": regressor}


challenger_rows = []
for year in evaluation_years:
    bundle = make_bundle(leases, year)
    challenger_rows.append({"target_year": year,
                            "actual_pct": PERCENT * float(realized(leases, year)["effective_growth"].mean()),
                            "notre_methode_pct": all_folds.set_index("target_year").loc[year, "predicted_pct"],
                            "xgboost_pct": PERCENT * forecast_challenger(bundle, "xgboost")["portfolio"],
                            "ridge_pct": PERCENT * forecast_challenger(bundle, "ridge")["portfolio"],
                            "moyenne_historique_pct": baselines.query("model == 'All history pooled' and target_year == @year").predicted_pct.iloc[0]})
challenger_backtest = pd.DataFrame(challenger_rows)
display(challenger_backtest.round(2))

scoreboard_rows = []
for method in ("notre_methode", "xgboost", "ridge", "moyenne_historique"):
    error = challenger_backtest[f"{method}_pct"] - challenger_backtest["actual_pct"]
    in_required_window = challenger_backtest["target_year"].isin(REQUIRED_BACKTEST_YEARS)
    scoreboard_rows.append({"méthode": method, "MAE_2020_2025_pp": error.abs().mean(),
                            "MAE_2023_2025_pp": error[in_required_window].abs().mean(), "pire_année_pp": error.abs().max()})
challenger_scoreboard = pd.DataFrame(scoreboard_rows).set_index("méthode")
display(challenger_scoreboard.round(3))
for candidate in ("xgboost", "ridge"):
    board = challenger_scoreboard
    replaces = bool(board.loc[candidate, "MAE_2020_2025_pp"] < board.loc[["notre_methode", "moyenne_historique"], "MAE_2020_2025_pp"].min()
                    and board.loc[candidate, "MAE_2023_2025_pp"] < board.loc[["notre_methode", "moyenne_historique"], "MAE_2023_2025_pp"].min()
                    and board.loc[candidate, "pire_année_pp"] <= board.loc["notre_methode", "pire_année_pp"])
    print(f"{candidate} remplace notre méthode : {replaces}")
target_year actual_pct notre_methode_pct xgboost_pct ridge_pct moyenne_historique_pct
0 2020 2.77 1.76 2.06 2.22 1.75
1 2021 3.75 2.74 2.94 1.63 2.33
2 2022 4.72 3.75 4.54 5.87 2.87
3 2023 2.18 4.47 4.91 6.52 3.38
4 2024 1.95 2.62 3.11 -0.15 3.09
5 2025 3.83 2.93 4.61 5.53 2.76
MAE_2020_2025_pp MAE_2023_2025_pp pire_année_pp
méthode
notre_methode 1.142 1.289 2.294
xgboost 1.063 1.559 2.733
ridge 1.994 2.714 4.343
moyenne_historique 1.282 1.138 1.841
xgboost remplace notre méthode : False
ridge remplace notre méthode : False
# Pourquoi XGBoost prévoit ce chiffre pour 2026 : contributions SHAP (TreeSHAP) pondérées comme la prévision.
TOP_FEATURES_SHOWN = 10
xgb_2026 = forecast_challenger(make_bundle(leases, TARGET_YEAR), "xgboost")
roster_2026 = xgb_2026["roster"]
contributions = xgb_2026["model"].get_booster().predict(
    xgboost.DMatrix(roster_2026[MODEL_FEATURES].to_numpy(float), feature_names=MODEL_FEATURES), pred_contribs=True)
row_weights = roster_2026["share"].to_numpy() / roster_2026["unit"].nunique()
shap_2026 = (pd.DataFrame({"feature": MODEL_FEATURES,
                           "contribution_to_2026_pp": PERCENT * (contributions[:, :-1] * row_weights[:, None]).sum(axis=0),
                           "mean_abs_pp": PERCENT * np.abs(contributions[:, :-1]).mean(axis=0)})
             .sort_values("mean_abs_pp", ascending=False).reset_index(drop=True))
display(shap_2026.round(3))
top_features = shap_2026.head(TOP_FEATURES_SHOWN).iloc[::-1]
plt.figure(figsize=(8, 4))
plt.barh(top_features.feature, top_features.contribution_to_2026_pp)
plt.title("XGBoost 2026 : contribution SHAP moyenne par variable (points)")
plt.xlabel("points de pourcentage")
plt.tight_layout()
plt.show()
print(f"2026 — XGBoost : {PERCENT * xgb_2026['portfolio']:.2f} % | notre méthode : {estimate_pct:.2f} %")
feature contribution_to_2026_pp mean_abs_pp
0 prior_gap 2.405 3.427
1 lag_port_gap -0.464 0.463
2 lag_cell_face 0.454 0.454
3 lag_port_face -0.354 0.362
4 property_age 0.036 0.312
5 lag_cell_gap -0.275 0.280
6 rent_position 0.079 0.267
7 start_month -0.012 0.247
8 interval_years 0.145 0.226
9 is_renewal 0.014 0.164
10 lag_cell_eff 0.121 0.120
11 is_ontario -0.026 0.059
12 bedrooms -0.002 0.036
2026 — XGBoost : 5.15 % | notre méthode : 4.80 %

Lecture. Les tableaux appliquent mécaniquement la règle déclarée. Aucun des deux modèles ne remplace notre méthode. XGBoost fait un peu mieux sur 2020–2025, mais moins bien sur la fenêtre exigée 2023–2025 et sur sa pire année; Ridge fait moins bien partout. Avec environ 3 000 paires et six années de régimes différents, un modèle plus flexible apprend surtout le bruit d'années déjà vues. Le SHAP montre que XGBoost s'appuie d'abord sur l'écart de concession du bail précédent : une unité louée avec une grosse concession « récupère » une partie de cette concession au bail suivant. C'est le mécanisme que notre composante BRIDGE modélise explicitement, unité par unité. Le résultat refusé reste affiché, car il dit ce que les données ne soutiennent pas.

Détails

D'autres pistes ont été évaluées de la même façon et refusées : variables publiques (inoccupation, offre, population, chômage, taux) ajoutées à ces modèles, signal du loyer affiché, propension au renouvellement, tendance amortie des concessions, simulation Monte Carlo du parc et passage du TAL. Leur code est dans les modules Python facultatifs joints (challenger.py, behavior.py, simulation.py, drivers.py), qui ne sont pas nécessaires pour exécuter ce notebook.

10. Au-delà de la consigne : ce que nous avons construit en plusLa consigne rend l'application facultative. Nous livrons un prototype facultatif (04Prototypefacultatifcockpitsimulation.zip : cockpit et simulation)

10. Au-delà de la consigne : ce que nous avons construit en plus

La consigne rend l'application facultative. Nous livrons un prototype facultatif (04_Prototype_facultatif_cockpit_simulation.zip : cockpit et simulation) et un film d'une minute avec la présentation (03_Film_presentation_1min.mp4). Aucun n'est nécessaire pour évaluer ce notebook, et aucun ne contient de donnée du CRM : ils n'utilisent que les résultats agrégés par immeuble présentés ici. Cliquez sur chaque ligne à triangle pour voir une capture.

1. Cockpit de tarification (cockpit/index.html dans le prototype, s'ouvre d'un double-clic) : la carte du portefeuille avec la prévision de chaque immeuble, un simulateur de renouvellement (loyer, durée, mois gratuits, régime TAL ou Ontario) et le calendrier des baux qui expirent en 2026.

Voir la capture : carte du portefeuille et prévision par immeuble

Cockpit de tarification

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

Simulation de résidents

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

Image du film

11. Hypothèses, limites, et comment améliorer sans ajouter de bruit1. Le prochain bail d'une unité du roster commence le lendemain de la fin du bail actuel (vacance et départs anticipés sont des risques non modélisés).

11. Hypothèses, limites, et comment améliorer sans ajouter de bruit

Hypothèses énoncées.

  1. 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).
  2. La part de renouvellements de 2026 ressemble à celle des trois dernières années de chaque immeuble, rétrécie vers sa province.
  3. L'écart de concession des nouveaux baux de 2026 ressemble à celui de 2025 (BRIDGE); les scénarios ±2 points en montrent la sensibilité.
  4. Les paramètres (ForecastSpec) sont hérités d'une révision antérieure et n'ont pas été optimisés sur 2023–2025.
  5. sRentEffective est 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.

Rapport généré à partir du notebook exécuté : mêmes chiffres, mêmes graphiques. Aucune ligne du CRM n'y figure.
2026 rent increase · same apartment, after concessions
4.80%

effective increase forecast for 2026 · 6.38% in contractual rent · from 4.3% (The Met) to 5.1% (Westpark) by building

+10.8% → +2.2%The 2023 median measures three premium buildings arriving; the same apartment rose only 2.2%.
7.6% → 3.8%In 2025, what the lease says versus what is collected: free months take back half of the increase.
6 years testedThe method is re-run as of December 31 of each year since 2020 and compared with what happened.
What drives each building's 2026 increase (section 8b)

The full notebook, step by step. Click a step to open it.

SummaryAnswer: 4.80% effective same-unit increase for 2026, that is, what Équinoxe will collect on top of today's rent, after free months,

Équinoxe Collection — 2026 rent increase

One-page summary

Answer: 4.80% effective same-unit increase for 2026, that is, what Équinoxe will collect on top of today's rent, after free months, on the same unit. In contractual (face) rent (the rent written in the lease): 6.38%. By building: from 4.3% (The Met, Ottawa) to 5.1% (Westpark, Pointe-Claire).

What we found, in five points

  1. The annual median misleads. It shows +10.8% in 2023, but that is mostly three upscale buildings arriving in the portfolio. The same unit compared with itself rose by only about 2.2% that year (sections 3 and 4).
  2. Concessions eat into the increase. In 2025, the contractual (face) rents of the same units rise by 7.6%, but collected rent by only 3.8%: the concession gap on new leases goes from 3.2% of rent in 2023 to 9.2% in 2025, driven mostly by PromoPay (section 5).
  3. Renewals and turnovers do not move the same way, and Ontario does not follow Quebec's rules: each building is forecast by event type and by province, never applying one province's rules to another (sections 6 and 7.3).
  4. The forecast is validated on the past: replayed as at 31 December of each year from 2020 to 2025, its average error (about 1.1 points) is lower than that of three simple reference methods. Over 2023–2025, a plain historical average does slightly better; we show this frankly (section 7.5).
  5. Every figure is explained: for each building, we show what pushes the rent up or down, with the public context around it (construction, transit, risks) (section 8b). XGBoost and Ridge were tested and do not do better in a stable way (section 9).

Read it in five minutes: this summary → the chart in section 4b (median vs same unit) → section 5c (concessions) → section 7.4 (the figure) → section 8b (why, building by building) → section 7.5 (validation).

The exact value is computed by estimate_2026() in section 7.4, and the validation on 2023, 2024 and 2025 is in section 7.5.

This notebook starts from the supplied starter.ipynb and keeps its structure and code cells: loading, reading the columns, the obvious approach, comparing a unit with itself, contractual (face) vs collected rent, renewals vs turnovers, then "Your turn" with estimate_2026() and backtest() completed. All of the method's code is in this notebook: no imports from the team's own files.

To run it: place the four original CSV files next to this notebook (as for the starter), together with the three supplied public files (external_context.csv, external_postorigin_2026.csv, building_context.csv), install requirements.txt, then Restart Kernel / Run All. Run time: under a minute.

Grading criterion Section
Definition of the increase (10) 0 and 7.1
Data analysis and mix effect (15) 1b, 1c, 2, 3
Same-unit matching (15) 4
Concessions (10) 5
Renewals vs turnovers, Ontario (10) 6, 7.6
External data (15) 7.2, 7.6
Forecast and backtest (15) 7.3 to 7.7, 9
Bonus: building × bedrooms, scenarios, dashboard 7.7, 8, 8b
Beyond the brief (optional prototype) 10

Confidentiality: no CRM row is displayed. The outputs are aggregates (counts, averages, charts).

0. The question we are answeringDefinition adopted: effective same-unit increase. For each unit (key sPropCode + sUnitCode), we compare the new lease with the

0. The question we are answering

Definition adopted: effective same-unit increase. For each unit (key sPropCode + sUnitCode), we compare the new lease with the immediately preceding lease of the same unit, using the effective rent (sRentEffective, after concessions), annualise the change, and take the average of these changes over all pairs whose new lease starts in 2026. Renewals and turnovers are both included, each with its expected weight.

Throughout this notebook, "collected" means the supplied sRentEffective field: the rent after the concessions recorded in the data (this is how the starter describes it). It is not a ledger of actual cash receipts: the extract contains no payments and no arrears.

Details

Why this choice rather than the other definitions offered in the starter:

Possible definition Why we do not use it as the headline figure
Annual median of the whole portfolio Mostly measures mix: the arrival of three upscale buildings creates a 10.8% "increase" in 2023 that did not happen (section 3).
Same unit, contractual (face) rent (sRent) Ignores free months. Since 2024 the gap between contractual (face) and collected rent has been widening (section 5): contractual (face) rent overstates what Équinoxe collects. Shown alongside (6.38%).
Renewals only / turnovers only Useful for setting rents, but a revenue budget needs both. The two are separated in sections 6 and 7.3 and recombined by their expected share.
Total revenue or NOI Impossible with this extract: no occupancy or vacancy data (section 2).

Average rather than median: the average over pairs recomposes exactly from the segments (building × event type), which the median does not allow. The median is still shown as a robustness diagnostic.

1. Load the dataThe four source files stay untouched: every transformation is done in memory, on copies. Table names and library versions are printed for reproducibility.

1. Load the data

The four source files stay untouched: every transformation is done in memory, on copies. Table names and library versions are printed for reproducibility.

import os
import platform
from dataclasses import dataclass
from pathlib import Path

import matplotlib
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import sklearn
import xgboost
from IPython.display import display

pd.set_option("display.width", 160)
pd.set_option("display.max_columns", 40)

TARGET_YEAR = 2026                          # the year to forecast
LAST_OBSERVED_YEAR = TARGET_YEAR - 1        # the extract stops at 2025-12-31
REQUIRED_BACKTEST_YEARS = (2023, 2024, 2025)  # required by the brief
PERCENT = 100.0                             # fraction -> percentage
DAYS_PER_YEAR = 365.25                      # annualisation of intervals between leases

# The original CSV files go next to the notebook, as for the starter.
# Optional environment variable if you prefer to keep them elsewhere.
DATA_DIR = Path(os.environ.get("EQUINOXE_DATA_DIR", "."))
SOURCE_FILES = {"listings": "equinoxe_listings.csv", "leases": "equinoxe_lease_history.csv",
                "concessions": "equinoxe_concessions.csv", "asking": "equinoxe_asking_history.csv"}
missing_files = [file_name for file_name in SOURCE_FILES.values() if not (DATA_DIR / file_name).is_file()]
if missing_files:
    raise FileNotFoundError(f"Place the original CSV files next to the notebook (or set EQUINOXE_DATA_DIR). Missing: {missing_files}")

source_tables = {table: pd.read_csv(DATA_DIR / file_name) for table, file_name in SOURCE_FILES.items()}
listings, leases = source_tables["listings"], source_tables["leases"]
concessions, asking = source_tables["concessions"], source_tables["asking"]

for name, table in source_tables.items():
    print(f"{name:<12} {len(table):>6,} rows   {table.shape[1]:>2} columns")
print({"python": platform.python_version(), "pandas": pd.__version__, "numpy": np.__version__,
       "matplotlib": matplotlib.__version__, "scikit-learn": sklearn.__version__, "xgboost": xgboost.__version__})
listings      1,061 rows   29 columns
leases        4,302 rows   35 columns
concessions   3,560 rows   18 columns
asking        3,857 rows   14 columns
{'python': '3.12.7', 'pandas': '2.2.3', 'numpy': '2.3.5', 'matplotlib': '3.11.2', 'scikit-learn': '1.9.1', 'xgboost': '2.1.4'}

As in the starter, the dates are converted once, at the start, and the lease start year is added. Instead of displaying CRM rows (leases.head()), we display the column profile: type, missing values and number of distinct values.

LEASE_DATE_COLUMNS = ["sLeaseFrom", "sLeaseTo", "sSignDate", "sAvailable"]
for column in LEASE_DATE_COLUMNS:
    if column in leases.columns:
        leases[column] = pd.to_datetime(leases[column], errors="coerce")

leases["year"] = leases.sLeaseFrom.dt.year
asking["date"] = pd.to_datetime(asking.sMonth + "-01")

lease_column_profile = pd.DataFrame({"type": leases.dtypes.astype(str),
                                     "missing_values": leases.isna().sum(),
                                     "distinct_values": leases.nunique()})
lease_column_profile
type missing_values distinct_values
hProperty int64 0 9
hUnit int64 0 1061
hBuilding int64 0 6
sPropCode object 0 9
sUnitCode object 0 519
sBuilding object 0 6
sCity object 0 4
sState object 0 2
sAddr1 object 0 6
sLatitude float64 0 6
sLongitude float64 0 6
sLeaseTerm object 0 4
sPropType object 0 1
sRent int64 0 666
sBeds int64 0 4
sBaths float64 0 3
sSqft int64 0 199
sLink object 0 1061
sFurnishing object 0 1
sAvailable datetime64[ns] 0 1302
sSmoking object 0 1
sCats bool 0 1
sDogs bool 0 1
sRentEffective float64 0 3178
sConcession int64 0 2
sSite object 0 6
sUnitSubtype object 0 4
sUnitType object 0 11
sSignDate datetime64[ns] 0 2067
sLeaseFrom datetime64[ns] 0 1302
sLeaseTo datetime64[ns] 0 1341
sTermMonths float64 0 4
sTermSeq int64 0 8
sRenewal int64 0 2
sFloor int64 0 27
year int32 0 9
1b. Reading the columns: `h` fields and `s` fieldsYardi convention recalled by the starter: the h (handle) columns are numeric identifiers used for joins;

1b. Reading the columns: h fields and s fields

Yardi convention recalled by the starter: the h (handle) columns are numeric identifiers used for joins; the s (stored value) columns are the readable values used for display and grouping. Join on h, label with s. The s prefix does not mean "text": sRent and sSqft are numeric.

Details

For the unit, we use the recommended key sPropCode + sUnitCode (never sSite + sUnitCode, see section 4). Property codes can designate phases of the same building; the results by building are grouped by sBuilding.

Charge codes (sChargeCode) are truncated to eight characters by Yardi (Freeothe, Referenc). We classify them as follows:

sChargeCode What it is Classification
PromoPay, freerent Discount on the rent Rent
freepark, freelock, freeappl Free parking, locker, appliances Service (not rent)
Freeothe, Indem, Referenc Other, indemnity, referral Other, kept separate
1c. What the columns really represent, and how we rename themThe brief warns that the descriptions do not always match the data. Our checks:

1c. What the columns really represent, and how we rename them

The brief warns that the descriptions do not always match the data. Our checks:

Details
  • PromoPay is described as a monthly amount. The next cell shows that in nearly 9 cases out of 10 it is a single entry per lease (sMonths = 1), with a median amount of about 70% of one month's rent. It is therefore a one-off credit to be spread over the lease term, not a discount applied every month. Taken literally, it would suggest that residents live almost for free.
  • sAmount is negative for a benefit. The total credit of an entry is −sAmount × sMonths.
  • sRentEffective is already the rent after concessions, amortised over the lease. We use it as is and do not subtract concessions a second time. Section 5 checks that it is always less than or equal to sRent, and rebuilds the rent from the concession ledger.
  • sTermSeq numbers the successive leases of the same unit: it is what defines the "previous lease".
  • sRenewal equals 1 for a sitting tenant who renews and 0 for a turnover.
  • Asking rent (asking): an advertised price, not a signed one.

The columns used by the model are renamed once, with the COLUMN_RENAMES dictionary below. The source files are never modified.

UNIT_KEY = ["sPropCode", "sUnitCode"]       # safe unit key (never sSite + sUnitCode)
PROMO_CODE = "PromoPay"

COLUMN_RENAMES = {"sPropCode": "property_code", "sUnitCode": "unit_code", "sState": "province", "sBeds": "bedrooms",
                  "sSignDate": "sign_date", "sLeaseFrom": "lease_start", "sLeaseTo": "lease_end", "sTermSeq": "term_seq",
                  "sRent": "face_rent", "sRentEffective": "effective_rent"}


def normalise_unit_key(table):
    # Copy with a cleaned unit key: whitespace removed, property code in lower case.
    keyed = table.copy()
    keyed["sPropCode"] = keyed["sPropCode"].astype(str).str.strip().str.lower()
    keyed["sUnitCode"] = keyed["sUnitCode"].astype(str).str.strip()
    return keyed


promo_entries = normalise_unit_key(concessions[concessions.sChargeCode.eq(PROMO_CODE)])
unit_listed_rent = normalise_unit_key(listings)[UNIT_KEY + ["sRent"]]
promo_entries = promo_entries.merge(unit_listed_rent, on=UNIT_KEY, how="left", validate="many_to_one")
promo_check = pd.Series({
    "PromoPay entries": len(promo_entries),
    "share with sMonths = 1": (promo_entries.sMonths == 1).mean(),
    "share with negative sAmount": (promo_entries.sAmount < 0).mean(),
    "median credit / unit rent": (-promo_entries.sAmount / promo_entries.sRent).median(),
})
display(promo_check.round(3))

COLUMN_MEANINGS = {
    "sPropCode": "property code (one phase of a building); half of the unit key",
    "sUnitCode": "unit number within the property; the other half of the unit key",
    "sState": "province: Quebec (TAL) or Ontario (guideline)",
    "sBeds": "number of bedrooms",
    "sSignDate": "signature date: used to know what was known at a given date",
    "sLeaseFrom": "lease start: date of the measured event",
    "sLeaseTo": "contractual lease end: used to anticipate the leases expiring in 2026",
    "sTermSeq": "rank of the lease in the unit's history: defines the previous lease",
    "sRent": "monthly contractual (face) rent, the rent written in the lease",
    "sRentEffective": "monthly effective rent, after concessions amortised over the lease: what is collected",
}
column_dictionary = pd.DataFrame({"new name": COLUMN_RENAMES, "what the column represents": COLUMN_MEANINGS})
column_dictionary.index.name = "original column"
column_dictionary
PromoPay entries               1494.000
share with sMonths = 1            0.882
share with negative sAmount       1.000
median credit / unit rent         0.711
dtype: float64
new name what the column represents
original column
sPropCode property_code property code (one phase of a building); half ...
sUnitCode unit_code unit number within the property; the other hal...
sState province province: Quebec (TAL) or Ontario (guideline)
sBeds bedrooms number of bedrooms
sSignDate sign_date signature date: used to know what was known at...
sLeaseFrom lease_start lease start: date of the measured event
sLeaseTo lease_end contractual lease end: used to anticipate the ...
sTermSeq term_seq rank of the lease in the unit's history: defin...
sRent face_rent monthly contractual (face) rent, the rent writ...
sRentEffective effective_rent monthly effective rent, after concessions amor...

Data contract. Before any measurement, the model checks that the leases meet the assumptions it depends on, and stops otherwise: no empty key, no duplicated or non-integer sequence, consistent dates (signature ≤ start ≤ end), positive rents, effective rent ≤ contractual (face) rent, known province, stable per unit. Nothing is imputed. The validation works on a copy, with the cleaned key.

REQUIRED_LEASE_COLUMNS = {"sPropCode", "sUnitCode", "sState", "sBeds", "sRent", "sRentEffective",
                          "sSignDate", "sLeaseFrom", "sLeaseTo", "sTermSeq", "sRenewal"}
KNOWN_PROVINCES = ["Quebec", "Ontario"]
LEASE_NUMERIC_COLUMNS = ["sRent", "sRentEffective", "sTermSeq", "sRenewal", "sBeds"]


class DataContractError(ValueError):
    # An input does not meet an assumption the model depends on.
    pass


def validate_leases(lease_table):
    # Validated copy of the leases: dates and numbers converted, key cleaned, rules checked.
    if not isinstance(lease_table, pd.DataFrame) or lease_table.empty:
        raise DataContractError("leases must be a non-empty DataFrame of lease history")
    missing_columns = REQUIRED_LEASE_COLUMNS - set(lease_table.columns)
    if missing_columns:
        raise DataContractError(f"missing columns: {sorted(missing_columns)}")
    checked = lease_table.copy()
    for column in ["sSignDate", "sLeaseFrom", "sLeaseTo"]:
        checked[column] = pd.to_datetime(checked[column], errors="coerce")
    for column in LEASE_NUMERIC_COLUMNS:
        checked[column] = pd.to_numeric(checked[column], errors="coerce")
    if checked[list(REQUIRED_LEASE_COLUMNS)].isna().any().any():
        raise DataContractError("missing or unreadable values in the required columns")
    checked = normalise_unit_key(checked)
    if checked[UNIT_KEY].eq("").any().any() or not checked["sState"].isin(KNOWN_PROVINCES).all():
        raise DataContractError("empty unit key or unknown province")
    if not np.isfinite(checked[LEASE_NUMERIC_COLUMNS].to_numpy()).all():
        raise DataContractError("non-finite numeric value")
    if not checked["sRenewal"].isin([0, 1]).all():
        raise DataContractError("sRenewal must be 0 or 1")
    if ((checked["sTermSeq"] < 1) | (checked["sTermSeq"] % 1 != 0)).any():
        raise DataContractError("sTermSeq must be a positive integer")
    if checked.duplicated(UNIT_KEY + ["sTermSeq"]).any():
        raise DataContractError("duplicated lease sequence for the same unit")
    if (checked[["sRent", "sRentEffective"]] <= 0).any().any():
        raise DataContractError("rents must be positive")
    if (checked["sRentEffective"] > checked["sRent"]).any():
        raise DataContractError("effective rent greater than contractual (face) rent")
    if ((checked["sLeaseTo"] < checked["sLeaseFrom"]) | (checked["sSignDate"] > checked["sLeaseFrom"])).any():
        raise DataContractError("inconsistent lease or signature dates")
    if checked.groupby(UNIT_KEY)["sState"].nunique().gt(1).any():
        raise DataContractError("a unit cannot change province")
    return checked


validated_leases = validate_leases(leases)
print(f"Data contract met: {len(validated_leases):,} leases, {validated_leases.groupby(UNIT_KEY).ngroups:,} units.")
Data contract met: 4,302 leases, 1,061 units.
2. What we are looking atSix buildings: Daniel-Johnson, Lévesque and Saint-Elzéar (Laval), Le Carlyle (Mont-Royal), Westpark (Pointe-Claire) and The Met (Ottawa).

2. What we are looking at

Six buildings: Daniel-Johnson, Lévesque and Saint-Elzéar (Laval), Le Carlyle (Mont-Royal), Westpark (Pointe-Claire) and The Met (Ottawa). Everything stops at 31 December 2025; 2026 is what we forecast. Five buildings fall under Quebec (TAL) rules and The Met under Ontario's: the two regimes are treated separately throughout.

Details

This is an extract: a fixed share of the units of each building and each unit type. The distribution is representative, the volumes are not. We therefore compute no occupancy, no vacancy, no absorption and no total rent roll: that would be an artefact of the extract. These data measure the change in rents, and that is what we are asked for.

print(listings.groupby(["sState", "sCity", "sBuilding"]).size().to_string())
print()
print(listings.groupby(["sBeds", "sBaths"])
      .agg(units=("sRent", "size"),
           median_rent=("sRent", "median"),
           median_sqft=("sSqft", "median"))
      .to_string())
sState   sCity          sBuilding     
Ontario  Ottawa         The Met           124
Quebec   Laval          Daniel-Johnson    134
                        Levesque           84
                        Saint-Elzear      361
         Mont-Royal     Le Carlyle        192
         Pointe-Claire  Westpark          166

              units  median_rent  median_sqft
sBeds sBaths                                 
0     1.0        49       2565.0        550.0
1     1.0       350       1380.0        600.0
2     1.0       358       2440.0       1150.0
      2.0       281       2605.0       1265.0
3     1.0         2       2687.5       1597.5
      2.0        20       3237.5       1545.0
      3.0         1       4115.0       1905.0
3. The obvious approach, and why it misleadsFirst reflex: the median rent of all leases, year by year.

3. The obvious approach, and why it misleads

First reflex: the median rent of all leases, year by year.

FIRST_YEAR_SHOWN = 2019

naive = (leases[leases.year.between(FIRST_YEAR_SHOWN, LAST_OBSERVED_YEAR)]
         .groupby("year")
         .agg(leases=("sRent", "size"), median_rent=("sRent", "median")))
naive["yoy_pct"] = (naive.median_rent.pct_change() * PERCENT).round(2)
naive
leases median_rent yoy_pct
year
2019 328 1730.0 NaN
2020 373 1775.0 2.60
2021 361 1850.0 4.23
2022 422 1912.5 3.38
2023 693 2120.0 10.85
2024 902 2215.0 4.48
2025 958 2305.0 4.06

According to this table, rents rose by 10.8% in 2023. They did not. An annual median compares different sets of units: it moves as soon as the composition of the portfolio moves. The next two cells look beneath the median: size, unit type and buildings leased each year.

TWO_BEDROOMS = 2

window = leases[leases.year.between(FIRST_YEAR_SHOWN, LAST_OBSERVED_YEAR)]
mix = window.groupby("year").agg(
    leases=("sRent", "size"),
    median_sqft=("sSqft", "median"),
    pct_2bed_plus=("sBeds", lambda beds: (beds >= TWO_BEDROOMS).mean() * PERCENT),
    buildings=("sBuilding", "nunique"))
mix["rent_per_sqft"] = (window.assign(psf=window.sRent / window.sSqft)
                        .groupby("year").psf.median())
mix.round(2)
leases median_sqft pct_2bed_plus buildings rent_per_sqft
year
2019 328 1177.5 78.66 3 1.48
2020 373 1180.0 77.75 3 1.53
2021 361 1175.0 78.39 3 1.60
2022 422 1160.0 72.51 3 1.71
2023 693 1095.0 62.63 5 2.04
2024 902 1045.0 62.20 6 2.20
2025 958 1025.0 60.23 6 2.33
SNAPSHOT_YEAR = 2024   # first year in which all three new buildings lease

# When did each building start generating leases?
print(window.groupby("sBuilding").year.min().sort_values().to_string())
print()
# And at what rent level? (leases of SNAPSHOT_YEAR)
print(window[window.year == SNAPSHOT_YEAR].groupby("sBuilding")
      .agg(leases=("sRent", "size"), median_rent=("sRent", "median"))
      .sort_values("median_rent").to_string())
sBuilding
Daniel-Johnson    2019
Levesque          2019
Saint-Elzear      2019
Le Carlyle        2023
The Met           2023
Westpark          2024

                leases  median_rent
sBuilding                          
Levesque            84       1672.5
Daniel-Johnson     136       1870.0
Saint-Elzear       215       2145.0
Westpark           144       2202.5
Le Carlyle         195       2350.0
The Met            128       2752.5

What happened: Le Carlyle, The Met and Westpark came into service in 2023 and 2024. They are upscale buildings, with smaller units that cost more per square foot. The units leased became smaller and the share of 2-bedroom and larger units fell, yet the median rose: the opposite of what size would predict. The rise in the median comes mostly from which buildings are leasing, hardly at all from the same tenant paying more.

The chart below shows this change in composition: the share of leases by building, year by year. The comparison with same-unit growth follows in section 4, once the pairs are built.

building_mix = pd.crosstab(validated_leases.sLeaseFrom.dt.year, validated_leases.sBuilding, normalize="index")
ax = building_mix.plot.area(stacked=True, figsize=(9, 4))
ax.set(title="Lease composition by building", ylabel="Share of the year's leases", xlabel="Lease start year")
ax.legend(loc="center left", bbox_to_anchor=(1, 0.5))
plt.tight_layout()
plt.show()
4. The method that works: comparing a unit with itselfWe pair the consecutive leases of each unit and annualise the change: same apartment, same building, so what remains is the price.

4. The method that works: comparing a unit with itself

We pair the consecutive leases of each unit and annualise the change: same apartment, same building, so what remains is the price.

The key: sPropCode + sUnitCode. Not sSite + sUnitCode: several sites group several property codes whose unit numbers overlap. The next cell demonstrates it, without displaying any CRM row.

COLLISION_EXAMPLE_SITE, COLLISION_EXAMPLE_UNIT = "saint-elzear", "0203"   # example given by the starter

site_key_collisions = listings[listings.duplicated(["sSite", "sUnitCode"], keep=False)]
print(f"{len(site_key_collisions):,} units out of {len(listings):,} collide on sSite + sUnitCode")
example = listings[(listings.sSite == COLLISION_EXAMPLE_SITE) & (listings.sUnitCode == COLLISION_EXAMPLE_UNIT)]
print(f"Example {COLLISION_EXAMPLE_SITE} / {COLLISION_EXAMPLE_UNIT}: {example.hUnit.nunique()} distinct apartments (hUnit), "
      f"{example.sPropCode.nunique()} different property codes")
print(f"Duplicates on sPropCode + sUnitCode in the listings: {listings.duplicated(UNIT_KEY).sum()}")
248 units out of 1,061 collide on sSite + sUnitCode
Example saint-elzear / 0203: 3 distinct apartments (hUnit), 3 different property codes
Duplicates on sPropCode + sUnitCode in the listings: 0

The starter proposes a first version of the matching (same_unit_growth, kept below): previous lease of the same unit, gap between starts of 0.5 to 2.5 years, annualised growth, median by year. It already confirms that the naive median mostly measures composition.

GAP_MIN_YEARS, GAP_MAX_YEARS = 0.5, 2.5   # shorter: often the same lease recorded twice; longer: too old
FIRST_PAIR_YEAR_SHOWN = 2021


def same_unit_growth(df, price_col="sRent", date_col="sLeaseFrom", min_gap=GAP_MIN_YEARS, max_gap=GAP_MAX_YEARS):
    # Starter version: annualised rent change, each unit compared with its own previous lease.
    d = df.sort_values(UNIT_KEY + [date_col]).copy()
    d["prev_price"] = d.groupby(UNIT_KEY)[price_col].shift(1)
    d["prev_date"] = d.groupby(UNIT_KEY)[date_col].shift(1)
    d = d.dropna(subset=["prev_price", "prev_date"])
    d["gap_years"] = (d[date_col] - d.prev_date).dt.days / DAYS_PER_YEAR
    d = d[d.gap_years.between(min_gap, max_gap, inclusive="neither")]
    d = d[(d.prev_price > 0) & (d[price_col] > 0)]
    d["growth_pct"] = ((d[price_col] / d.prev_price) ** (1 / d.gap_years) - 1) * PERCENT
    return d


pairs = same_unit_growth(leases)
print(f"{len(pairs):,} usable pairs across {pairs.groupby(UNIT_KEY).ngroups:,} units")

honest = (pairs[pairs.year.between(FIRST_PAIR_YEAR_SHOWN, LAST_OBSERVED_YEAR)]
          .groupby("year")
          .growth_pct.agg(pairs="size", median="median").round(2))
honest
3,179 usable pairs across 890 units
pairs median
year
2021 355 4.46
2022 352 5.40
2023 416 2.29
2024 698 3.39
2025 780 7.04
fig, ax = plt.subplots(figsize=(9, 4.5))
years_shown = range(FIRST_PAIR_YEAR_SHOWN, TARGET_YEAR)
ax.plot(years_shown, naive.loc[years_shown, "yoy_pct"], "o--", label="naive: median of all leases", lw=2)
ax.plot(years_shown, honest.loc[years_shown, "median"], "o-", label="same unit compared with itself", lw=2)
ax.axhline(0, color="grey", lw=0.8)
ax.set_ylabel("annual rent growth (%)")
ax.set_title("The naive method mostly measures composition")
ax.set_xticks(list(years_shown))
ax.legend()
ax.grid(alpha=0.3)
plt.tight_layout()
plt.show()

4b. Our matching: rules fixed once, exclusions visible

In brief: each unit is compared with its previous lease, with rules written once and exclusions counted. Result: 3,179 comparable pairs.

Details

We tighten the starter's version on three points:

  1. Immediately preceding lease: a pair is kept only if the sequences are consecutive (sTermSeq increases by 1). A missing lease is not "skipped".
  2. Contractual (face) and effective measured together on the same pairs, with the concession gap gap = 1 − sRentEffective / sRent of the previous lease and of the new one.
  3. All rules and all parameters of the model are fixed here, once, in ForecastSpec. None was optimised on the backtest years.

Annualisation: t = days between the two starts / 365.25, growth = (new_rent / previous_rent) ** (1/t) − 1, pair kept if 0.5 < t < 2.5.

Exact identity at unit level: (1 + effective growth) = (1 + contractual growth) × ((1 − new_gap) / (1 − previous_gap)) ** (1/t). Effective growth is contractual (face) growth corrected for the change in the concession gap. The cell checks it on all pairs.

@dataclass(frozen=True)
class ForecastSpec:
    # Parameters frozen before the backtests of this revision.
    origin_month: int = 12                         # origin of each forecast: 31 December of the previous year
    origin_day: int = 31
    gap_min_years: float = GAP_MIN_YEARS           # admissible interval between two lease starts
    gap_max_years: float = GAP_MAX_YEARS
    growth_clip: tuple = (-0.30, 0.60)             # robustness bounds, training means only
    gap_clip: tuple = (0.0, 0.75)                  # bounds of the forecast concession gap
    shrink_k_cell: float = 10.0                    # shrinkage of building × event toward province × event
    shrink_k_mix: float = 20.0                     # shrinkage of the renewal share toward the province
    trailing_years_mix: int = 3                    # window of the renewal share
    components: tuple = ("DIRECT", "BRIDGE", "ANCHOR")
    component_weights: tuple = (1 / 3, 1 / 3, 1 / 3)
    face_persistence_weight: float = 0.5           # contractual: half last year, half history
    evaluation_years: tuple = (2020, 2021, 2022, 2023, 2024, 2025)


SPEC = ForecastSpec()
PAIR_COLUMNS = ["property_code", "unit_code", "province", "bedrooms", "term_seq", "prior_seq", "sign_date", "lease_start",
                "lease_end", "prior_start", "prior_sign", "event_year", "event_type", "prior_face_rent", "prior_effective_rent",
                "face_rent", "effective_rent", "interval_years", "face_growth", "effective_growth", "prior_gap", "new_gap", "eligible"]


def build_pairs(lease_table):
    # Pairs of consecutive leases of the same unit, annualised growth and concession gaps.
    ordered = validate_leases(lease_table).sort_values(UNIT_KEY + ["sTermSeq", "sLeaseFrom"])
    by_unit = ordered.groupby(UNIT_KEY, sort=False)
    paired = ordered.assign(prior_start=by_unit["sLeaseFrom"].shift(1), prior_sign=by_unit["sSignDate"].shift(1),
                            prior_face_rent=by_unit["sRent"].shift(1), prior_effective_rent=by_unit["sRentEffective"].shift(1),
                            prior_seq=by_unit["sTermSeq"].shift(1))
    paired = paired[paired["prior_start"].notna()].copy()
    paired["interval_years"] = (paired["sLeaseFrom"] - paired["prior_start"]).dt.days / DAYS_PER_YEAR
    paired["face_growth"] = (paired["sRent"] / paired["prior_face_rent"]) ** (1.0 / paired["interval_years"]) - 1.0
    paired["effective_growth"] = (paired["sRentEffective"] / paired["prior_effective_rent"]) ** (1.0 / paired["interval_years"]) - 1.0
    paired["prior_gap"] = 1.0 - paired["prior_effective_rent"] / paired["prior_face_rent"]
    paired["new_gap"] = 1.0 - paired["sRentEffective"] / paired["sRent"]
    rents_positive = (paired[["sRent", "prior_face_rent", "sRentEffective", "prior_effective_rent"]] > 0).all(axis=1)
    consecutive = paired["sTermSeq"].sub(paired["prior_seq"]).eq(1)
    paired["eligible"] = (paired["interval_years"].gt(SPEC.gap_min_years) & paired["interval_years"].lt(SPEC.gap_max_years)
                          & rents_positive & consecutive)
    paired["event_year"] = paired["sLeaseFrom"].dt.year
    paired["event_type"] = np.where(paired["sRenewal"].eq(1), "renewal", "turnover")
    return paired.rename(columns=COLUMN_RENAMES)[PAIR_COLUMNS].reset_index(drop=True)


def exact_bridge(face_growth, prior_gap, new_gap, interval_years=1.0):
    # Exact identity: (1 + g_contractual) × ((1 − new_gap) / (1 − previous_gap)) ** (1/t) − 1.
    inputs = (interval_years, prior_gap, new_gap, face_growth)
    if (any(not np.isfinite(np.asarray(value)).all() for value in inputs)
            or np.any(np.asarray(interval_years) <= 0) or np.any(np.asarray(prior_gap) >= 1)
            or np.any(np.asarray(prior_gap) < 0) or np.any(np.asarray(new_gap) < 0)
            or np.any(np.asarray(new_gap) >= 1) or np.any(np.asarray(face_growth) <= -1)):
        raise DataContractError("the bridge requires positive durations and valid rent ratios")
    return (1.0 + face_growth) * ((1.0 - new_gap) / (1.0 - prior_gap)) ** (1.0 / interval_years) - 1.0


unit_pairs = build_pairs(leases)
eligible_pairs = unit_pairs[unit_pairs["eligible"]]
exclusion_reasons = pd.Series({
    "interval ≤ 0.5 year": int((unit_pairs.interval_years <= SPEC.gap_min_years).sum()),
    "interval ≥ 2.5 years": int((unit_pairs.interval_years >= SPEC.gap_max_years).sum()),
    "non-consecutive sequences": int(unit_pairs.term_seq.sub(unit_pairs.prior_seq).ne(1).sum()),
}, name="pairs (one case can have two reasons)")
display(pd.DataFrame([{"candidate_pairs": len(unit_pairs), "eligible": len(eligible_pairs),
                       "excluded": len(unit_pairs) - len(eligible_pairs)}]))
display(exclusion_reasons)

assert eligible_pairs["term_seq"].sub(eligible_pairs["prior_seq"]).eq(1).all()
np.testing.assert_allclose(exact_bridge(eligible_pairs.face_growth, eligible_pairs.prior_gap,
                                        eligible_pairs.new_gap, eligible_pairs.interval_years),
                           eligible_pairs.effective_growth, atol=1e-12)
print("Contractual (face) / effective identity verified on all eligible pairs.")
candidate_pairs eligible excluded
0 3241 3179 62
interval ≤ 0.5 year          62
interval ≥ 2.5 years          0
non-consecutive sequences     0
Name: pairs (one case can have two reasons), dtype: int64
Contractual (face) / effective identity verified on all eligible pairs.

Naive median vs same unit, by year. For each start year of the new lease, the table gives: the number of pairs, the average contractual (face) and effective growth, the effective median (robustness), the renewal share, and the change in the naive median. The gap between the naive median and the matched average illustrates the mix effect. It is not an exact causal decomposition: the statistics and the samples differ.

annual_growth = eligible_pairs.groupby("event_year").agg(
    pairs=("effective_growth", "size"),
    face_pct=("face_growth", lambda growth: PERCENT * growth.mean()),
    effective_pct=("effective_growth", lambda growth: PERCENT * growth.mean()),
    median_effective_pct=("effective_growth", lambda growth: PERCENT * growth.median()),
    renewal_share=("event_type", lambda event: event.eq("renewal").mean())).reset_index()
naive_median_change = validated_leases.groupby(validated_leases.sLeaseFrom.dt.year).sRent.median().pct_change() * PERCENT
annual_growth["naive_median_face_change_pct"] = annual_growth.event_year.map(naive_median_change)
display(annual_growth.round(3))

ax = annual_growth.set_index("event_year")[["naive_median_face_change_pct", "face_pct", "effective_pct"]].rename(columns={
    "naive_median_face_change_pct": "Naive median (all leases)", "face_pct": "Same unit, contractual",
    "effective_pct": "Same unit, effective"}).plot(marker="o", figsize=(9, 4))
ax.set(title="Naive median vs same-unit growth", ylabel="% annualised / annual change", xlabel="Start year of the new lease")
ax.axhline(0, color="grey", lw=0.8)
plt.tight_layout()
plt.show()
event_year pairs face_pct effective_pct median_effective_pct renewal_share naive_median_face_change_pct
0 2018 88 2.126 2.041 1.976 0.534 -11.295
1 2019 159 2.000 1.593 1.629 0.579 7.453
2 2020 331 3.034 2.769 3.019 0.604 2.601
3 2021 355 4.384 3.753 3.543 0.620 4.225
4 2022 352 5.273 4.716 4.586 0.642 3.378
5 2023 416 2.089 2.178 2.019 0.575 10.850
6 2024 698 3.728 1.951 1.772 0.605 4.481
7 2025 780 7.559 3.833 3.575 0.606 4.063
5. Contractual (face) rent is not collected rentsRent is what the lease states; sRentEffective is what Équinoxe actually collects after free months and other benefits.

5. Contractual (face) rent is not collected rent

sRent is what the lease states; sRentEffective is the rent after free months and other benefits, as supplied in the data (the starter describes it as what Équinoxe actually collects). Concessions have become the norm. The first table (starter) compares levels; the second compares growth on the starter's pairs.

conc = (leases[leases.year.between(FIRST_PAIR_YEAR_SHOWN, LAST_OBSERVED_YEAR)]
        .groupby("year")
        .agg(leases=("sRent", "size"),
             pct_with_concession=("sConcession", lambda flag: flag.mean() * PERCENT),
             median_contracted=("sRent", "median"),
             median_effective=("sRentEffective", "median")))
conc["gap_pct"] = ((conc.median_effective / conc.median_contracted - 1) * PERCENT)
conc.round(2)
leases pct_with_concession median_contracted median_effective gap_pct
year
2021 361 54.02 1850.0 1785.00 -3.51
2022 422 57.35 1912.5 1835.00 -4.05
2023 693 54.26 2120.0 2025.40 -4.46
2024 902 73.06 2215.0 2084.46 -5.89
2025 958 85.39 2305.0 2105.59 -8.65
# Does the concession change the trend, or only the level?
eff = same_unit_growth(leases, price_col="sRentEffective")
compare = pd.DataFrame({
    "contracted": honest["median"],
    "effective": eff[eff.year.between(FIRST_PAIR_YEAR_SHOWN, LAST_OBSERVED_YEAR)].groupby("year").growth_pct.median(),
}).round(2)
compare
contracted effective
year
2021 4.46 3.54
2022 5.40 4.59
2023 2.29 2.02
2024 3.39 1.77
2025 7.04 3.58

5b. Checking sRentEffective against the concession ledger

In brief: the concession ledger confirms the direction of the gap without rebuilding it exactly; we therefore use the supplied effective rent as it is.

Details

The supplied effective rent remains our measure. To check it, each concession entry is attached to the lease of the same unit whose period contains its start date. An entry that would match several leases stops the computation. The credit of an entry is −sAmount × sMonths; only the rent codes (PromoPay, freerent) are subtracted from the rent, spread over the lease term (sTermMonths). Services (parking, locker, appliances) are not rent.

The first table shows what the ledger explains and what it does not: entries with no enclosing lease, and leases flagged sConcession = 1 without an entry. The second compares, on a common sample where both leases of the pair have a positive reconstruction and a consistent flag, three definitions of rent: contractual (face), contractual less only the ledger's rent credits, and supplied effective. These entries have no reliable recording date: this reconstruction is a diagnostic and does not enter the backtests.

RENT_CREDIT_CODES = ("promopay", "freerent")
AMENITY_CODES = ("freepark", "freelock", "freeappl")


def match_concessions_to_leases(lease_table, concession_table):
    # Attach each entry to the lease of the same unit that contains its start date.
    lease_rows = validate_leases(lease_table).reset_index(drop=True).reset_index(names="lease_id")
    ledger = normalise_unit_key(concession_table).reset_index(names="concession_id")
    for column in ("sDateFrom", "sDateTo"):
        ledger[column] = pd.to_datetime(ledger[column])
    matched = ledger.merge(lease_rows[["lease_id", *UNIT_KEY, "sLeaseFrom", "sLeaseTo"]], on=UNIT_KEY, how="left")
    matched = matched[matched.sDateFrom.between(matched.sLeaseFrom, matched.sLeaseTo)].copy()
    if matched.groupby("concession_id").lease_id.nunique().gt(1).any():
        raise DataContractError("a concession entry matches several leases")
    charge_code = matched.sChargeCode.str.lower()
    matched["code"] = charge_code
    matched["category"] = np.select([charge_code.isin(RENT_CREDIT_CODES), charge_code.isin(AMENITY_CODES)],
                                    ["rent", "amenity"], default="other")
    matched["total_credit"] = -matched.sAmount * matched.sMonths
    if (matched.total_credit < 0).any():
        raise DataContractError("a concession entry is a positive charge")
    return lease_rows, matched


lease_rows, matched_concessions = match_concessions_to_leases(leases, concessions)
rent_credit_by_lease = matched_concessions[matched_concessions.category.eq("rent")].groupby("lease_id").total_credit.sum()
entries_per_lease = matched_concessions.groupby("lease_id").size()
lease_rows["matched_entries"] = lease_rows.lease_id.map(entries_per_lease).fillna(0)
lease_rows["rent_only"] = lease_rows.sRent - lease_rows.lease_id.map(rent_credit_by_lease).fillna(0) / lease_rows.sTermMonths
lease_rows["flag_consistent"] = ~(lease_rows.sConcession.eq(1) & lease_rows.matched_entries.eq(0))
by_unit_sequence = lease_rows.sort_values(UNIT_KEY + ["sTermSeq"]).groupby(UNIT_KEY)
lease_rows["prior_rent_only"] = by_unit_sequence.rent_only.shift()
lease_rows["prior_flag_consistent"] = by_unit_sequence.flag_consistent.shift(fill_value=False)

concession_reconciliation = pd.DataFrame([{
    "entries": len(concessions),
    "matched to a lease": matched_concessions.concession_id.nunique(),
    "unmatched": len(concessions) - matched_concessions.concession_id.nunique(),
    "leases flagged without an entry": int((lease_rows.sConcession.eq(1) & lease_rows.matched_entries.eq(0)).sum()),
    "leases not flagged with an entry": int((lease_rows.sConcession.eq(0) & lease_rows.matched_entries.gt(0)).sum())}])
display(concession_reconciliation)

pairs_with_reconstruction = eligible_pairs.merge(
    lease_rows[[*UNIT_KEY, "sTermSeq", "rent_only", "prior_rent_only", "flag_consistent", "prior_flag_consistent"]],
    left_on=["property_code", "unit_code", "term_seq"], right_on=[*UNIT_KEY, "sTermSeq"], validate="one_to_one")
common_sample = pairs_with_reconstruction[pairs_with_reconstruction.flag_consistent & pairs_with_reconstruction.prior_flag_consistent
                                          & pairs_with_reconstruction.rent_only.gt(0)
                                          & pairs_with_reconstruction.prior_rent_only.gt(0)].copy()
common_sample["rent_only_pct"] = PERCENT * ((common_sample.rent_only / common_sample.prior_rent_only) ** (1 / common_sample.interval_years) - 1)
common_sample_by_year = common_sample.groupby("event_year").agg(
    n=("effective_growth", "size"), face_pct=("face_growth", lambda growth: PERCENT * growth.mean()),
    rent_only_pct=("rent_only_pct", "mean"),
    supplied_effective_pct=("effective_growth", lambda growth: PERCENT * growth.mean())).reset_index()
display(common_sample_by_year.round(3))
ax = common_sample_by_year.set_index("event_year")[["face_pct", "rent_only_pct", "supplied_effective_pct"]].rename(columns={
    "face_pct": "Contractual", "rent_only_pct": "Contractual − ledger rent credits",
    "supplied_effective_pct": "Supplied effective"}).plot(marker="o", figsize=(9, 4))
ax.set(title="Same sample: effect of the rent definition", ylabel="Annualised growth (%)", xlabel="Start year of the new lease")
plt.tight_layout()
plt.show()
entries matched to a lease unmatched leases flagged without an entry leases not flagged with an entry
0 3560 3356 204 497 264
event_year n face_pct rent_only_pct supplied_effective_pct
0 2018 84 2.038 1.893 2.003
1 2019 142 2.034 1.361 1.740
2 2020 280 2.998 3.116 2.843
3 2021 283 4.503 3.717 3.813
4 2022 273 5.252 5.689 4.641
5 2023 336 2.078 1.693 2.003
6 2024 534 3.761 2.551 2.232
7 2025 502 7.542 5.942 3.775

5c. Why contractual (face) and effective growth diverge from 2024

By the identity of section 4b, effective growth is contractual (face) growth minus the change in the concession gap between the previous lease and the new one. The average gap breaks down into incidence (share of leases with a concession) × depth (average gap when there is one). The change in the gap therefore splits exactly, at the midpoint, into an "incidence" part and a "depth" part.

Details

Reading: if the two growth rates diverge, it is not that the contractual (face) price stops rising; it is that the gap widens on the new lease. The ledger shows which codes carry the gap, but does not reconcile it exactly: it also contains services that are not rent, and it is missing entries (unmatched entries, leases flagged without an entry). The reliable figure remains the sRent / sRentEffective gap; the breakdown by code is indicative.

def concession_divergence(pairs_table):
    # By year: contractual vs effective growth, and change in the gap = incidence + depth.
    rows = []
    for year, year_pairs in pairs_table[pairs_table["eligible"]].groupby("event_year"):
        incidence_prior, incidence_new = float((year_pairs["prior_gap"] > 0).mean()), float((year_pairs["new_gap"] > 0).mean())
        depth_prior = float(year_pairs.loc[year_pairs["prior_gap"] > 0, "prior_gap"].mean()) if incidence_prior else 0.0
        depth_new = float(year_pairs.loc[year_pairs["new_gap"] > 0, "new_gap"].mean()) if incidence_new else 0.0
        gap_prior, gap_new = float(year_pairs["prior_gap"].mean()), float(year_pairs["new_gap"].mean())
        incidence_part = (incidence_new - incidence_prior) * (depth_prior + depth_new) / 2.0
        depth_part = (depth_new - depth_prior) * (incidence_prior + incidence_new) / 2.0
        rows.append({
            "event_year": int(year), "pairs": len(year_pairs),
            "face_pct": PERCENT * year_pairs["face_growth"].mean(), "effective_pct": PERCENT * year_pairs["effective_growth"].mean(),
            "face_minus_effective_pp": PERCENT * (year_pairs["face_growth"].mean() - year_pairs["effective_growth"].mean()),
            "prior_gap_pct": PERCENT * gap_prior, "new_gap_pct": PERCENT * gap_new, "gap_change_pp": PERCENT * (gap_new - gap_prior),
            "incidence_prior": incidence_prior, "incidence_new": incidence_new,
            "depth_prior_pct": PERCENT * depth_prior, "depth_new_pct": PERCENT * depth_new,
            "gap_change_from_incidence_pp": PERCENT * incidence_part, "gap_change_from_depth_pp": PERCENT * depth_part})
    return pd.DataFrame(rows)


def concession_code_contribution(lease_rows_table, matched_table):
    # Average amortised concession as % of contractual rent, over all leases, by year and by code.
    credit_by_code = matched_table.groupby(["lease_id", "code"])["total_credit"].sum().unstack(fill_value=0.0)
    codes = list(credit_by_code.columns)
    per_lease = lease_rows_table[["lease_id", "sLeaseFrom", "sTermMonths", "sRent", "sRentEffective"]].join(credit_by_code, on="lease_id")
    per_lease[codes] = per_lease[codes].fillna(0.0).div(per_lease["sTermMonths"], axis=0).div(per_lease["sRent"], axis=0)
    start_year = per_lease["sLeaseFrom"].dt.year
    contribution = per_lease.groupby(start_year)[codes].mean().mul(PERCENT)
    contribution["total_matched_pct"] = contribution[codes].sum(axis=1)
    contribution["supplied_gap_pct"] = PERCENT * (1 - per_lease["sRentEffective"] / per_lease["sRent"]).groupby(start_year).mean()
    contribution["unexplained_by_ledger_pct"] = contribution["supplied_gap_pct"] - contribution["total_matched_pct"]
    contribution.index.name = "lease_start_year"
    return contribution.reset_index()


divergence = concession_divergence(unit_pairs)
display(divergence.round(2))
fig, axes = plt.subplots(1, 2, figsize=(13, 4))
divergence.set_index("event_year")[["face_pct", "effective_pct"]].rename(
    columns={"face_pct": "Contractual", "effective_pct": "Effective"}).plot(ax=axes[0], marker="o", title="Contractual vs effective, same unit")
axes[0].set(ylabel="% annualised", xlabel="Start year of the new lease")
divergence.set_index("event_year")[["gap_change_from_incidence_pp", "gap_change_from_depth_pp"]].rename(
    columns={"gap_change_from_incidence_pp": "Incidence", "gap_change_from_depth_pp": "Depth"}).plot.bar(
    ax=axes[1], stacked=True, title="Change in the concession gap: incidence + depth")
axes[1].set(ylabel="percentage points", xlabel="Start year of the new lease")
plt.tight_layout()
plt.show()
display(concession_code_contribution(lease_rows, matched_concessions).round(2))
event_year pairs face_pct effective_pct face_minus_effective_pp prior_gap_pct new_gap_pct gap_change_pp incidence_prior incidence_new depth_prior_pct depth_new_pct gap_change_from_incidence_pp gap_change_from_depth_pp
0 2018 88 2.13 2.04 0.08 1.21 1.36 0.15 0.40 0.38 3.04 3.62 -0.08 0.22
1 2019 159 2.00 1.59 0.41 1.36 1.78 0.42 0.38 0.47 3.54 3.77 0.32 0.10
2 2020 331 3.03 2.77 0.27 1.77 2.07 0.30 0.46 0.48 3.84 4.28 0.10 0.20
3 2021 355 4.38 3.75 0.63 2.02 2.68 0.66 0.47 0.54 4.29 4.99 0.31 0.35
4 2022 352 5.27 4.72 0.56 2.74 3.35 0.61 0.56 0.58 4.92 5.78 0.12 0.49
5 2023 416 2.09 2.18 -0.09 3.21 3.24 0.03 0.55 0.49 5.81 6.64 -0.40 0.43
6 2024 698 3.73 1.95 1.78 3.66 5.46 1.79 0.55 0.70 6.63 7.79 1.06 0.73
7 2025 780 7.56 3.83 3.73 5.79 9.24 3.46 0.74 0.86 7.79 10.70 1.13 2.33
lease_start_year freeappl freelock freeothe freepark freerent indem promopay referenc total_matched_pct supplied_gap_pct unexplained_by_ledger_pct
0 2017 0.00 0.12 0.19 0.49 0.03 0.00 0.64 0.00 1.47 1.18 -0.29
1 2018 0.07 0.17 0.22 0.44 0.07 0.03 0.73 0.06 1.79 1.36 -0.43
2 2019 0.00 0.12 0.37 0.65 0.09 0.05 1.21 0.00 2.50 1.70 -0.80
3 2020 0.03 0.11 0.41 0.47 0.09 0.04 1.08 0.03 2.25 2.09 -0.16
4 2021 0.06 0.09 0.43 0.80 0.11 0.04 1.82 0.00 3.35 2.69 -0.66
5 2022 0.02 0.13 0.55 0.96 0.12 0.05 1.58 0.06 3.47 3.33 -0.15
6 2023 0.04 0.27 0.82 1.72 0.30 0.11 1.77 0.05 5.09 3.58 -1.51
7 2024 0.09 0.34 1.25 1.67 0.33 0.10 2.56 0.06 6.39 5.71 -0.69
8 2025 0.07 0.27 0.77 1.62 0.19 0.10 3.77 0.06 6.83 9.13 2.30

Reading. Up to 2023, contractual (face) and effective growth stay within one point of each other. After that the concession gap of the new lease widens, for two successive reasons:

  • in 2024, mostly incidence: the share of new leases with a concession goes from about 55% to 70%;
  • in 2025, mostly depth: the average gap on a lease with a concession goes from about 7.8% to 10.7% of the rent, while incidence rises further.

The ledger shows that PromoPay carries most of this increase. Contractual (face) rent keeps rising, but a growing part of it is handed back as free months. This is why we quote the effective figure: it is what a landlord collects. Contractual (face) growth is still shown, because it is what matters for the regulation of renewals.

6. Renewals do not behave like turnoverssRenewal = 1: a sitting tenant renews. sRenewal = 0: the unit becomes vacant and is re-let at market price. In Quebec, the two are governed by different

6. Renewals do not behave like turnovers

sRenewal = 1: a sitting tenant renews. sRenewal = 0: the unit becomes vacant and is re-let at market price. In Quebec, the two are governed by different rules (the TAL regulates the increase for a sitting tenant); in Ontario, the guideline applies only to rent-controlled units. The starter's table shows that the gap changes sides: renewals lead in 2022, turnovers in 2025.

FIRST_SPLIT_YEAR = 2022

split = (pairs[pairs.year.between(FIRST_SPLIT_YEAR, LAST_OBSERVED_YEAR)]
         .groupby(["year", "sRenewal"])
         .growth_pct.agg(pairs="size", median="median").round(2)
         .unstack("sRenewal"))
split.columns = [f"{stat}_{'renewal' if is_renewal else 'turnover'}" for stat, is_renewal in split.columns]
split
pairs_turnover pairs_renewal median_turnover median_renewal
year
2022 126 226 4.22 5.69
2023 177 239 1.85 2.58
2024 276 422 4.85 2.94
2025 307 473 8.98 6.41

Split by province and event type, on our pairs. Contractual (face) and effective averages by year × province × event. The Met (Ontario) is always treated separately: one province's cap is never applied to the other. The model (section 7.3) forecasts each building × event type separately, then recombines them according to the expected renewal share.

Asking rent. This is an advertised price, not a signed one. The last known asking rent at 31 December 2025 is very close to the listings rent: in this extract, it brings no independent information. Without a time-stamped offer before signature and linked to the realised lease, we do not use it as a variable.

segments = eligible_pairs.groupby(["event_year", "province", "event_type"]).agg(
    n=("effective_growth", "size"),
    face_pct=("face_growth", lambda growth: PERCENT * growth.mean()),
    effective_pct=("effective_growth", lambda growth: PERCENT * growth.mean())).reset_index()
display(segments[segments.event_year.ge(REQUIRED_BACKTEST_YEARS[0])].round(3))

asking_known = asking.copy()
asking_known["sMonth"] = pd.to_datetime(asking_known.sMonth)
asking_known = asking_known[asking_known.sMonth.le(pd.Timestamp(LAST_OBSERVED_YEAR, 12, 31))]
latest_asking = asking_known.sort_values("sMonth").drop_duplicates(UNIT_KEY, keep="last")
asking_vs_listing = latest_asking.merge(listings[UNIT_KEY + ["sRent"]], on=UNIT_KEY, validate="one_to_one")
display(pd.DataFrame([{"origin": f"{LAST_OBSERVED_YEAR}-12-31", "units with asking rent": len(latest_asking),
                       "matched to listings": len(asking_vs_listing),
                       "median asking / listing ratio": float((asking_vs_listing.sAskingRent / asking_vs_listing.sRent).median())}]).round(4))
event_year province event_type n face_pct effective_pct
10 2023 Quebec renewal 239 2.550 2.747
11 2023 Quebec turnover 177 1.467 1.409
12 2024 Ontario renewal 67 2.899 0.328
13 2024 Ontario turnover 41 5.080 4.756
14 2024 Quebec renewal 355 2.915 1.368
15 2024 Quebec turnover 235 4.956 2.803
16 2025 Ontario renewal 63 6.333 1.893
17 2025 Ontario turnover 54 8.925 4.547
18 2025 Quebec renewal 410 6.529 2.603
19 2025 Quebec turnover 253 9.243 6.157
origin units with asking rent matched to listings median asking / listing ratio
0 2025-12-31 1061 1061 0.9824
7. Your turn: our forecastWe forecast the average of the annualised effective-rent growth rates, same unit, for the pairs whose new lease starts in 2026

7. Your turn: our forecast

7.1 The question (recap)

We forecast the average of the annualised effective-rent growth rates, same unit, for the pairs whose new lease starts in 2026 (section 0). Contractual (face) growth is forecast alongside. This figure describes comparable leases; it is neither the growth of total revenue nor that of NOI.

7.2 Public data and the date rule

external_context.csv gathers the requested public sources, each with its period, its release date and its link: CMHC (fixed-sample rents and vacancy, Montréal and Ottawa CMAs), Statistics Canada (CPI, rent component, Quebec and Ontario), the TAL (base adjustment for renewals in Quebec) and the Ontario guideline.

Details

Rule: a forecast made on 31 December may only use data published before that date. The external_asof function rejects any later publication, any without a link, or any non-numeric value. This is why the 2026 TAL figure, published in January 2026, is kept apart in external_postorigin_2026.csv and is used only for the sensitivities of section 7.6.

external = pd.read_csv("external_context.csv")
external_post_origin = pd.read_csv("external_postorigin_2026.csv")
EXTERNAL_REQUIRED_COLUMNS = {"target_year", "release_date", "source_url", "metric", "value"}


def origin_date(target_year):
    # Forecast origin: 31 December of the year before the target.
    return pd.Timestamp(target_year - 1, SPEC.origin_month, SPEC.origin_day)


def external_asof(external_table, target_year):
    # Public sources available at the origin; rejects later publications or those without provenance.
    if external_table is None:
        return pd.DataFrame()
    if not isinstance(external_table, pd.DataFrame):
        raise DataContractError("external must be a DataFrame of public sources with provenance")
    if EXTERNAL_REQUIRED_COLUMNS - set(external_table):
        raise DataContractError(f"external: missing columns {sorted(EXTERNAL_REQUIRED_COLUMNS - set(external_table))}")
    available = external_table[external_table.target_year.eq(target_year)].copy()
    release = pd.to_datetime(available.release_date, errors="coerce")
    if release.isna().any() or (release > origin_date(target_year)).any():
        raise DataContractError("publication missing or later than the forecast origin")
    if available.source_url.isna().any() or not available.source_url.astype(str).str.match(r"https?://\S+").all():
        raise DataContractError("missing provenance")
    if not np.isfinite(pd.to_numeric(available.value, errors="coerce")).all():
        raise DataContractError("non-finite public values")
    return available


display(external_asof(external, TARGET_YEAR)[["geography", "metric", "reference_period", "release_date", "value", "unit"]])
geography metric reference_period release_date value unit
3 Quebec TAL estimated average basic adjustment (lagged) TAL 2025 2025-01-21 5.9000 percent
7 Ontario Ontario rent increase guideline 2026 guideline 2025-06-30 2.1000 percent
11 Montreal CMA CMHC fixed-sample average rent change October 2025 2025-12-11 7.4000 percent
15 Ottawa-Gatineau CMA (Ontario part) CMHC fixed-sample average rent change October 2025 2025-12-11 3.5000 percent
19 Montreal CMA CMHC apartment vacancy rate October 2025 2025-12-11 2.9000 percent
23 Ottawa-Gatineau CMA (Ontario part) CMHC apartment vacancy rate October 2025 2025-12-11 3.0000 percent
27 Montreal CMA Statistics Canada rented accommodation CPI yea... November 2025 2025-12-15 7.3678 percent YoY
31 Ontario Statistics Canada rent CPI year-over-year November 2025 2025-12-15 4.0687 percent YoY

7.3 Method: three simple components, forecast by building × event type

In brief: for each building and each lease type (renewal or turnover), we average three simple readings: last year's momentum, the same momentum converted to effective rent unit by unit, and the long-term trend. We then weight by the number of leases expected in 2026.

Details

What the forecast is allowed to see. For a target Y, the origin is 31 December of Y − 1. We keep the leases signed and started before the origin, then we build the pairs. The roster is the set of units whose last known lease at the origin ends in Y: these are the leases that will be renewed or re-let. The roster anticipates the composition of events; it is not a full rent roll.

Three equally weighted components (one third each, fixed in advance), computed for each building × event type:

  • DIRECT: last year's average effective growth (persistence).
  • BRIDGE: last year's contractual (face) growth, converted to effective by the exact identity of section 4b, applied unit by unit to the roster, with each unit's current concession gap and the gap of last year's new leases.
  • ANCHOR: the effective average over the whole eligible history (long-term anchor).

Small samples. Each building × event average is shrunk toward the province × event average: (n × mean + k × parent) / (n + k), with k = 10. A missing cell uses province × event, then event, then the portfolio. Training means are bounded at −30% / +60% so that an outlier lease does not dominate a small building. Realised results are never bounded.

Renewal share. For each building, the share over the last three years is shrunk toward the province (k = 20). Quebec and Ontario renewals are never mixed.

Contractual (face). Half last year, half history, by cell.

@dataclass(frozen=True)
class OriginBundle:
    # Everything the forecast is allowed to see for a target year.
    target_year: int
    origin: pd.Timestamp
    pairs: pd.DataFrame     # eligible pairs signed and started before the origin
    roster: pd.DataFrame    # units whose last known lease ends during the target year

    @property
    def summary(self):
        return {"target_year": self.target_year, "origin": self.origin.date().isoformat(),
                "n_training_pairs": int(len(self.pairs)), "n_roster_units": int(len(self.roster)),
                "training_years": sorted(int(year) for year in self.pairs["event_year"].unique())}


ROSTER_COLUMNS = ["property_code", "unit_code", "province", "bedrooms", "term_seq", "lease_end", "face_rent", "effective_rent",
                  "prior_gap", "expected_years", "comparable"]


def build_roster(lease_table, target_year):
    # Units whose last known lease at the origin ends during the target year.
    # The origin filter comes BEFORE the choice of the last lease: a more recent extract does not change the roster.
    checked = validate_leases(lease_table)
    origin = origin_date(target_year)
    known_at_origin = checked[(checked["sSignDate"] <= origin) & (checked["sLeaseFrom"] <= origin)]
    latest_lease = known_at_origin.sort_values(UNIT_KEY + ["sTermSeq", "sLeaseFrom"]).drop_duplicates(UNIT_KEY, keep="last")
    roster = latest_lease[latest_lease["sLeaseTo"].dt.year.eq(target_year)].rename(columns=COLUMN_RENAMES)
    roster["prior_gap"] = 1.0 - roster["effective_rent"] / roster["face_rent"]
    # Assumption: the next lease starts the day after the current lease ends.
    roster["expected_years"] = ((roster["lease_end"] + pd.Timedelta(days=1)) - roster["lease_start"]).dt.days / DAYS_PER_YEAR
    roster["comparable"] = roster["expected_years"].between(SPEC.gap_min_years, SPEC.gap_max_years, inclusive="neither")
    return roster[ROSTER_COLUMNS].reset_index(drop=True)


def make_bundle(lease_table, target_year):
    # Filter on signature AND start before pairing: a missing sequence cannot be bridged.
    checked = validate_leases(lease_table)
    origin = origin_date(target_year)
    observed = checked[(checked["sSignDate"] <= origin) & (checked["sLeaseFrom"] <= origin)]
    observed_pairs = build_pairs(observed)
    known_pairs = observed_pairs[observed_pairs["eligible"]].copy()
    roster = build_roster(checked, target_year)
    if known_pairs.empty:
        raise DataContractError(f"no eligible pair known before {origin.date()}")
    if roster.empty or not roster["comparable"].any():
        raise DataContractError(f"no unit expiring in {target_year} is known at {origin.date()}")
    return OriginBundle(target_year, origin, known_pairs, roster)


def clip_for_training(values, column):
    # Robustness bounds: forecast concession gap, or growth.
    lower, upper = SPEC.gap_clip if column == "new_gap" else SPEC.growth_clip
    return values.clip(lower, upper)


def shrunk_cell_means(pairs_table, column, shrink_k):
    # Building × event means shrunk toward province × event (then event, then portfolio).
    if pairs_table.empty:
        return {}
    bounded = pairs_table.assign(_value=clip_for_training(pairs_table[column], column))
    portfolio_mean = float(bounded["_value"].mean())
    event_means = bounded.groupby("event_type")["_value"].mean().to_dict()
    province_event_means = bounded.groupby(["province", "event_type"])["_value"].mean()
    province_of = bounded.groupby("property_code")["province"].first().to_dict()
    cell_means = {}
    for (property_code, event_type), cell in bounded.groupby(["property_code", "event_type"]):
        parent = province_event_means.get((province_of[property_code], event_type), event_means.get(event_type, portfolio_mean))
        n_pairs = len(cell)
        cell_means[(property_code, event_type)] = (n_pairs * float(cell["_value"].mean()) + shrink_k * parent) / (n_pairs + shrink_k)
    return cell_means


def cell_value(cell_means, pairs_table, column, property_code, event_type, province):
    # Cell value, with hierarchical fallback for a missing cell.
    if (property_code, event_type) in cell_means:
        return cell_means[(property_code, event_type)]
    bounded = pairs_table.assign(_value=clip_for_training(pairs_table[column], column))
    province_event = bounded[bounded["province"].eq(province) & bounded["event_type"].eq(event_type)]["_value"]
    if len(province_event):
        return float(province_event.mean())
    same_event = bounded[bounded["event_type"].eq(event_type)]["_value"]
    return float(same_event.mean()) if len(same_event) else float(bounded["_value"].mean())


def event_mix(bundle):
    # Expected share of renewals / turnovers by building, shrunk toward the province.
    roster = bundle.roster[bundle.roster["comparable"]]
    recent = bundle.pairs[bundle.pairs["event_year"].between(bundle.target_year - SPEC.trailing_years_mix, bundle.target_year - 1)]
    overall_rate = (float(recent["event_type"].eq("renewal").mean()) if len(recent)
                    else float(bundle.pairs["event_type"].eq("renewal").mean()))
    rows = []
    for property_code, units in roster.groupby("property_code"):
        province = units["province"].iloc[0]
        property_recent = recent[recent["property_code"].eq(property_code)]
        province_recent = recent[recent["province"].eq(province)]
        prior_rate = float(province_recent["event_type"].eq("renewal").mean()) if len(province_recent) else overall_rate
        renewal_rate = ((property_recent["event_type"].eq("renewal").sum() + SPEC.shrink_k_mix * prior_rate)
                        / (len(property_recent) + SPEC.shrink_k_mix))
        for event_type, share in (("renewal", renewal_rate), ("turnover", 1.0 - renewal_rate)):
            rows.append({"property_code": property_code, "province": province, "event_type": event_type,
                         "roster_units": len(units), "event_share": float(share), "expected_units": len(units) * float(share),
                         "roster_prior_gap": float(units["prior_gap"].mean()), "n_recent_pairs": int(len(property_recent))})
    mix_table = pd.DataFrame(rows)
    mix_table["weight"] = mix_table["expected_units"] / mix_table["expected_units"].sum()
    return mix_table


def roster_bridge(bundle, property_code, face_growth, new_gap):
    # Average of the unit-by-unit identities over a building's roster (next start = current end + 1 day).
    units = bundle.roster[bundle.roster["property_code"].eq(property_code) & bundle.roster["comparable"]]
    return float(np.mean(exact_bridge(face_growth, units["prior_gap"], new_gap, units["expected_years"])))

The components are computed for each building × event cell, combined with equal weights, then aggregated with each cell's expected weight (roster units × event share). Computing the identity unit by unit and then averaging avoids replacing an average of ratios by a ratio of averages.

def component_cells(bundle):
    # DIRECT, BRIDGE, ANCHOR and contractual for each building × event.
    known_pairs, target_year = bundle.pairs, bundle.target_year
    last_year = known_pairs[known_pairs["event_year"].eq(target_year - 1)]
    if last_year.empty:   # no lease started last year: most recent year available
        last_year = known_pairs[known_pairs["event_year"].eq(known_pairs["event_year"].max())]
    weights = event_mix(bundle)
    direct = shrunk_cell_means(last_year, "effective_growth", SPEC.shrink_k_cell)
    face_last = shrunk_cell_means(last_year, "face_growth", SPEC.shrink_k_cell)
    gap_last = shrunk_cell_means(last_year, "new_gap", SPEC.shrink_k_cell)
    anchor = shrunk_cell_means(known_pairs, "effective_growth", SPEC.shrink_k_cell)
    face_anchor = shrunk_cell_means(known_pairs, "face_growth", SPEC.shrink_k_cell)
    rows = []
    for _, cell in weights.iterrows():
        key = (cell["property_code"], cell["event_type"], cell["province"])
        face_persistence = cell_value(face_last, last_year, "face_growth", *key)
        new_gap_persistence = cell_value(gap_last, last_year, "new_gap", *key)
        rows.append({**cell.to_dict(),
                     "DIRECT": cell_value(direct, last_year, "effective_growth", *key),
                     "BRIDGE": roster_bridge(bundle, cell["property_code"], face_persistence, new_gap_persistence),
                     "ANCHOR": cell_value(anchor, known_pairs, "effective_growth", *key),
                     "face_persistence": face_persistence, "face_anchor": cell_value(face_anchor, known_pairs, "face_growth", *key),
                     "new_gap_persistence": new_gap_persistence,
                     "n_last_year_pairs": int((last_year["property_code"].eq(cell["property_code"])
                                               & last_year["event_type"].eq(cell["event_type"])).sum())})
    return pd.DataFrame(rows)


def combine(cells, components=None, weights=None):
    # Combination with fixed weights: effective = average of the components; contractual = last year / history blend.
    components = SPEC.components if components is None else components
    weights = SPEC.component_weights if weights is None else weights
    if (not components or len(components) != len(weights) or not np.isfinite(weights).all() or min(weights) < 0
            or abs(sum(weights) - 1.0) > 1e-9 or len(set(components)) != len(components)
            or not set(components).issubset(SPEC.components)):
        raise DataContractError("the weights must match the components and sum to 1")
    combined = cells.copy()
    combined["effective"] = sum(weight * combined[name] for name, weight in zip(components, weights))
    combined["face"] = (SPEC.face_persistence_weight * combined["face_persistence"]
                        + (1 - SPEC.face_persistence_weight) * combined["face_anchor"])
    return combined


def aggregate(cells, column="effective"):
    # Average weighted by each cell's expected weight.
    return float(np.average(cells[column], weights=cells["weight"]))


def forecast(bundle, components=None, weights=None):
    cells = combine(component_cells(bundle), components, weights)
    return {"target_year": bundle.target_year, "origin": bundle.origin.date().isoformat(),
            "effective": aggregate(cells, "effective"), "face": aggregate(cells, "face"),
            "components": {name: aggregate(cells, name) for name in SPEC.components},
            "renewal_share": float(cells.loc[cells["event_type"].eq("renewal"), "weight"].sum()),
            "roster_units": int(bundle.roster["comparable"].sum()),
            "roster_excluded": int((~bundle.roster["comparable"]).sum()),
            "cells": cells, "bundle": bundle.summary}

7.4 estimate_2026() and backtest() completed

The two starter functions, with the same signature. estimate_2026 returns a percentage (4.8 means 4.8%). backtest(leases, target_year) runs the same method as if we were on 31 December of target_year − 1, then compares it with what actually happened during target_year. The realised result contains all eligible pairs of the year, with no bounds.

def realized(lease_table, target_year):
    # What actually happened: all eligible pairs whose new lease starts during the target year.
    all_pairs = build_pairs(lease_table)
    return all_pairs[all_pairs["eligible"] & all_pairs["event_year"].eq(target_year)].copy()


def backtest_detail(lease_table, target_year, components=None, weights=None):
    bundle = make_bundle(lease_table, target_year)
    prediction = forecast(bundle, components, weights)
    actual_pairs = realized(lease_table, target_year)
    if actual_pairs.empty:
        raise DataContractError(f"no realised pair in {target_year}")
    actual_effective, actual_face = float(actual_pairs["effective_growth"].mean()), float(actual_pairs["face_growth"].mean())
    actual_by_cell = actual_pairs.groupby(["property_code", "event_type"]).agg(
        actual_effective=("effective_growth", "mean"), actual_face=("face_growth", "mean"),
        realized_n=("effective_growth", "size")).reset_index()
    cells = prediction["cells"].merge(actual_by_cell, on=["property_code", "event_type"], how="left")
    cells["abs_error"] = (cells["effective"] - cells["actual_effective"]).abs()
    scored = cells.dropna(subset=["actual_effective"])
    segment_mae = float(np.average(scored["abs_error"], weights=scored["realized_n"])) if len(scored) else np.nan
    # Diagnostics only: the roster is never reweighted with the realised mix.
    comparable_roster = bundle.roster[bundle.roster.comparable]
    in_roster = actual_pairs.merge(comparable_roster[["property_code", "unit_code", "term_seq"]],
                                   left_on=["property_code", "unit_code", "prior_seq"],
                                   right_on=["property_code", "unit_code", "term_seq"], how="inner", validate="many_to_one")
    roster_units_seen = in_roster[["property_code", "unit_code"]].drop_duplicates().shape[0]
    return {"target_year": target_year, "origin": prediction["origin"], "predicted": prediction["effective"],
            "actual": actual_effective, "error": prediction["effective"] - actual_effective,
            "predicted_face": prediction["face"], "actual_face": actual_face, "face_error": prediction["face"] - actual_face,
            "components": prediction["components"],
            "component_errors": {name: value - actual_effective for name, value in prediction["components"].items()},
            "segment_mae": segment_mae, "realized_pairs": int(len(actual_pairs)), "roster_units": prediction["roster_units"],
            "realized_in_roster": int(len(in_roster)), "target_roster_coverage": len(in_roster) / len(actual_pairs),
            "roster_observed_fraction": roster_units_seen / len(comparable_roster),
            "segment_target_coverage": float(scored.realized_n.sum() / len(actual_pairs)),
            "by_segment": cells.to_dict("records"), "cells": cells, "bundle": prediction["bundle"]}


def estimate_2026(leases, asking, external=None):
    # Returns our estimate of the 2026 rent increase, as a percentage.
    # Definition: average of the annualised effective-rent (sRentEffective) growth rates, same unit (sPropCode + sUnitCode),
    # consecutive leases whose new lease starts in 2026. `asking` is accepted to respect the starter's signature:
    # the asking rent remains context (section 6). `external` is checked by the date rule of section 7.2.
    external_asof(external, TARGET_YEAR)
    return PERCENT * forecast(make_bundle(leases, TARGET_YEAR))["effective"]


def backtest(leases, target_year):
    # Runs the method as if we were at the end of target_year - 1, and compares with what happened in target_year.
    detail = backtest_detail(leases, target_year)
    return {"target_year": target_year, "predicted_pct": PERCENT * detail["predicted"],
            "actual_pct": PERCENT * detail["actual"], "error_pp": PERCENT * detail["error"],
            "by_segment": detail["by_segment"], "segment_rate_units": "fraction"}


estimate_pct = estimate_2026(leases, asking, external)
forecast_2026 = forecast(make_bundle(leases, TARGET_YEAR))
display(pd.Series({"2026 effective increase (%)": estimate_pct,
                   "2026 contractual (face) increase (%)": PERCENT * forecast_2026["face"],
                   **{f"Component {name} (%)": PERCENT * value for name, value in forecast_2026["components"].items()},
                   "Expected renewal share": forecast_2026["renewal_share"],
                   "Comparable roster units": forecast_2026["roster_units"],
                   "Excluded units (term outside 0.5–2.5 years)": forecast_2026["roster_excluded"]}).round(4))
2026 effective increase (%)                      4.8047
2026 contractual (face) increase (%)             6.3780
Component DIRECT (%)                             3.8592
Component BRIDGE (%)                             7.5240
Component ANCHOR (%)                             3.0309
Expected renewal share                           0.6031
Comparable roster units                        928.0000
Excluded units (term outside 0.5–2.5 years)      3.0000
dtype: float64

7.5 Backtest on 2023, 2024 and 2025, and comparison with simple methods

The starter suggests for yr in (2023, 2024, 2025): backtest(leases, yr). We display the result as a table. To see more regimes, we also replay 2020 to 2022, without presenting them as independent samples.

Comparators, with exactly the same origins and the same target: persistence (last year's average), the average of the last three years, the average of the whole history. No winner is chosen on these scores: the years 2023–2025 were already known when the method was designed, and three points are not enough to separate methods.

def rolling_evaluation(lease_table, target_years, components=None, weights=None):
    rows = []
    for year in target_years:
        detail = backtest_detail(lease_table, year, components, weights)
        rows.append({"target_year": year, "origin": detail["origin"],
                     "predicted_pct": PERCENT * detail["predicted"], "actual_pct": PERCENT * detail["actual"],
                     "error_pp": PERCENT * detail["error"], "abs_error_pp": PERCENT * abs(detail["error"]),
                     "predicted_face_pct": PERCENT * detail["predicted_face"], "actual_face_pct": PERCENT * detail["actual_face"],
                     "face_error_pp": PERCENT * detail["face_error"], "segment_mae_pp": PERCENT * detail["segment_mae"],
                     **{f"{name}_pct": PERCENT * value for name, value in detail["components"].items()},
                     **{f"{name}_error_pp": PERCENT * value for name, value in detail["component_errors"].items()},
                     "realized_pairs": detail["realized_pairs"], "roster_units": detail["roster_units"],
                     **{key: detail[key] for key in ["realized_in_roster", "target_roster_coverage",
                                                     "roster_observed_fraction", "segment_target_coverage"]}})
    return pd.DataFrame(rows)


def baseline_evaluation(lease_table, target_years):
    # Three simple comparators, same origin and same target; no winner is chosen.
    rows = []
    for year in target_years:
        bundle = make_bundle(lease_table, year)
        actual = float(realized(lease_table, year)["effective_growth"].mean())
        for name, training in (("Last year pooled", bundle.pairs[bundle.pairs.event_year.eq(year - 1)]),
                               ("Trailing 3 years pooled", bundle.pairs[bundle.pairs.event_year.ge(year - 3)]),
                               ("All history pooled", bundle.pairs)):
            if training.empty:
                training = bundle.pairs
            prediction = float(training.effective_growth.mean())
            rows.append({"target_year": year, "model": name, "predicted_pct": PERCENT * prediction,
                         "actual_pct": PERCENT * actual, "error_pp": PERCENT * (prediction - actual)})
    return pd.DataFrame(rows)


required_backtests = pd.DataFrame([{key: value for key, value in backtest(leases, year).items()
                                    if key not in ("by_segment", "segment_rate_units")} for year in REQUIRED_BACKTEST_YEARS])
display(required_backtests.round(3))

evaluation_years = tuple(year for year in SPEC.evaluation_years if year < TARGET_YEAR)
all_folds = rolling_evaluation(leases, evaluation_years)
display(all_folds[["target_year", "predicted_pct", "actual_pct", "error_pp", "predicted_face_pct", "actual_face_pct",
                   "face_error_pp", "segment_mae_pp", "roster_units", "realized_pairs"]].round(3))

baselines = baseline_evaluation(leases, evaluation_years)
scores = baselines.groupby("model").error_pp.agg(MAE_pp=lambda error: error.abs().mean(), bias_pp="mean",
                                                 worst_pp=lambda error: error.abs().max())
scores.loc["Our method (fixed)"] = [all_folds.abs_error_pp.mean(), all_folds.error_pp.mean(), all_folds.abs_error_pp.max()]
scores["MAE_2023_2025_pp"] = baselines[baselines.target_year.isin(REQUIRED_BACKTEST_YEARS)].groupby("model").error_pp.apply(
    lambda error: error.abs().mean())
scores.loc["Our method (fixed)", "MAE_2023_2025_pp"] = all_folds[all_folds.target_year.isin(REQUIRED_BACKTEST_YEARS)].abs_error_pp.mean()
display(scores.round(3))

ax = all_folds.set_index("target_year")[["predicted_pct", "actual_pct"]].rename(
    columns={"predicted_pct": "Forecast at the previous 31 December", "actual_pct": "Realised"}).plot(marker="o", figsize=(9, 4))
ax.set(title="Backtest: simulated forecast vs realised (effective, same unit)", ylabel="% annualised", xlabel="Target year",
       xticks=list(evaluation_years))
plt.tight_layout()
plt.show()
target_year predicted_pct actual_pct error_pp
0 2023 4.472 2.178 2.294
1 2024 2.620 1.951 0.669
2 2025 2.928 3.833 -0.905
target_year predicted_pct actual_pct error_pp predicted_face_pct actual_face_pct face_error_pp segment_mae_pp roster_units realized_pairs
0 2020 1.764 2.769 -1.005 2.005 3.034 -1.029 0.957 306 331
1 2021 2.743 3.753 -1.010 2.875 4.384 -1.509 1.149 353 355
2 2022 3.747 4.716 -0.968 3.881 5.273 -1.392 0.955 347 352
3 2023 4.472 2.178 2.294 4.581 2.089 2.492 2.248 402 416
4 2024 2.620 1.951 0.669 2.755 3.728 -0.973 1.743 640 698
5 2025 2.928 3.833 -0.905 3.589 7.559 -3.970 1.314 845 780
MAE_pp bias_pp worst_pp MAE_2023_2025_pp
model
All history pooled 1.282 -0.503 1.841 1.138
Last year pooled 1.295 -0.373 2.538 1.549
Trailing 3 years pooled 1.409 -0.372 1.754 1.421
Our method (fixed) 1.142 -0.154 2.294 1.289

Honest reading. Over the six years 2020–2025, our method has a lower average error than the three simple comparators (about 1.1 points, against 1.3 to 1.4) and the lowest bias. But its worst year (2023, where it extends the strong 2022 increase while the market slows) is worse than that of the historical average, and on the required 2023–2025 window the whole-history average does slightly better in this extract. We display this without replacing the method with the winner of years already consulted: that would be choosing after the fact. The contribution of our method is operational: it breaks down by building, by event type, into contractual (face) rent and concessions, which makes it possible to budget and to explain. Its predictive superiority is not demonstrated, and we do not claim it.

Roster coverage (realised pairs whose previous lease was indeed in the roster) is a diagnostic; it never changes the weights after the fact.

display(all_folds[["target_year", "realized_in_roster", "target_roster_coverage", "roster_observed_fraction",
                   "segment_target_coverage"]].round(3))
target_year realized_in_roster target_roster_coverage roster_observed_fraction segment_target_coverage
0 2020 303 0.915 0.990 1.000
1 2021 347 0.977 0.983 1.000
2 2022 342 0.972 0.986 1.000
3 2023 398 0.957 0.990 0.998
4 2024 629 0.901 0.983 0.990
5 2025 763 0.978 0.903 1.000

7.6 Reconciliation with public data

In brief: our figures are set side by side with CMHC, the CPI, the TAL and Ontario's guideline, province by province, to check their plausibility.

Details

For each public source available at 31 December 2025, we place next to it the internal forecast for the same province, contractual (face) and effective. For the TAL and the Ontario guideline, which regulate renewals, the comparison is made on the renewal cells only. These gaps help defend the assumptions, without proving causality: our recent portfolio, its concessions and its mix differ from a CMA's market. Vacancy is not subtracted from a growth rate (that would make no sense). These few series are not added as model variables: with six years, that would be overfitting (section 9 confirms this with public variables).

def external_comparison(lease_table, external_table, target_year=TARGET_YEAR):
    cells = forecast(make_bundle(lease_table, target_year))["cells"]
    rows = []
    for source in external_asof(external_table, target_year).itertuples():
        geography = source.geography.lower()
        province = "Ontario" if ("ontario" in geography or "ottawa" in geography) else "Quebec"   # regimes never mixed
        province_cells = cells[cells.province.eq(province)]
        regulatory = "TAL" in source.metric or "guideline" in source.metric.lower()
        if regulatory:
            province_cells = province_cells[province_cells.event_type.eq("renewal")]
        face = PERCENT * np.average(province_cells.face, weights=province_cells.weight)
        effective = PERCENT * np.average(province_cells.effective, weights=province_cells.weight)
        is_vacancy = "vacancy" in source.metric.lower()
        rows.append({"geography": source.geography, "metric": source.metric, "reference_period": source.reference_period,
                     "release_date": source.release_date, "public_value": source.value,
                     "internal_scope": "renewal" if regulatory else "all events",
                     "internal_face_pct": face, "internal_effective_pct": effective,
                     "face_minus_benchmark_pp": np.nan if is_vacancy else face - source.value})
    return pd.DataFrame(rows)


display(external_comparison(leases, external).round(3))
geography metric reference_period release_date public_value internal_scope internal_face_pct internal_effective_pct face_minus_benchmark_pp
0 Quebec TAL estimated average basic adjustment (lagged) TAL 2025 2025-01-21 5.900 renewal 5.633 3.831 -0.267
1 Ontario Ontario rent increase guideline 2026 guideline 2025-06-30 2.100 renewal 5.448 3.011 3.348
2 Montreal CMA CMHC fixed-sample average rent change October 2025 2025-12-11 7.400 all events 6.350 4.876 -1.050
3 Ottawa-Gatineau CMA (Ontario part) CMHC fixed-sample average rent change October 2025 2025-12-11 3.500 all events 6.566 4.317 3.066
4 Montreal CMA CMHC apartment vacancy rate October 2025 2025-12-11 2.900 all events 6.350 4.876 NaN
5 Ottawa-Gatineau CMA (Ontario part) CMHC apartment vacancy rate October 2025 2025-12-11 3.000 all events 6.566 4.317 NaN
6 Montreal CMA Statistics Canada rented accommodation CPI yea... November 2025 2025-12-15 7.368 all events 6.350 4.876 -1.017
7 Ontario Statistics Canada rent CPI year-over-year November 2025 2025-12-15 4.069 all events 6.566 4.317 2.497

Reading.

  • Quebec, renewals: our forecast contractual (face) increase for Quebec renewals (about 5.6%) is close to the last TAL figure known at the origin (5.9%). The two are consistent.
  • Montréal, market: CMHC (7.4%) and the CPI rent component (7.4%) are above our contractual (face) figure (6.35%). Our effective figure (4.9%) is lower still, because of the concessions that market indices do not measure.
  • Ontario, The Met: The Met's renewals rose by about 6.3% in contractual (face) terms in 2025 (section 6), well above the guideline (2.5% for 2025, 2.1% for 2026). This is consistent with an exempt building, first occupied after 15 November 2018: its first lease in the extract dates from 2023 (section 3). This is not proof; the date of first occupancy remains to be confirmed. We therefore do not cap The Met in the central forecast, and the Ontario scenario below remains conditional.
Details

Our trend against the market, year by year. The chart places, for each province, our realised same-unit growth rates (contractual (face) and effective) next to the public series of the same year: the CMHC fixed-sample average rent (CMA), Statistics Canada's CPI rent component, and the regulation of renewals (TAL in Quebec, guideline in Ontario). The 2026 diamonds are our forecast.

FIRST_MARKET_YEAR = 2021
REFERENCE_YEAR_PATTERN = r"(\d{4})"
MARKET_PANELS = (("Quebec", "Montreal CMA", "Montreal CMA", "TAL", "Quebec (5 buildings)"),
                 ("Ontario", "Ottawa-Gatineau CMA (Ontario part)", "Ontario", "guideline", "Ontario (The Met)"))

internal_by_province = eligible_pairs.groupby(["province", "event_year"]).agg(
    face_pct=("face_growth", lambda growth: PERCENT * growth.mean()),
    effective_pct=("effective_growth", lambda growth: PERCENT * growth.mean()))
forecast_cells = forecast_2026["cells"]
public_series = external.assign(reference_year=external.reference_period.str.extract(REFERENCE_YEAR_PATTERN, expand=False).astype(int))

fig, axes = plt.subplots(1, 2, figsize=(14, 4.5), sharey=True)
for ax, (province, cma, cpi_geography, regulator, title) in zip(axes, MARKET_PANELS):
    internal = internal_by_province.loc[province].loc[FIRST_MARKET_YEAR:LAST_OBSERVED_YEAR]
    province_cells = forecast_cells[forecast_cells.province.eq(province)]
    ax.plot(internal.index, internal.face_pct, "o-", color="tab:blue", label="Équinoxe, contractual (same unit)")
    ax.plot(internal.index, internal.effective_pct, "o-", color="tab:orange", label="Équinoxe, effective (same unit)")
    ax.plot([TARGET_YEAR], [PERCENT * np.average(province_cells.face, weights=province_cells.weight)], "D", color="tab:blue", ms=9)
    ax.plot([TARGET_YEAR], [PERCENT * np.average(province_cells.effective, weights=province_cells.weight)], "D", color="tab:orange",
            ms=9, label="Our 2026 forecast")
    cmhc = public_series[public_series.geography.eq(cma) & public_series.metric.str.contains("fixed-sample")]
    cpi = public_series[public_series.geography.eq(cpi_geography) & public_series.metric.str.contains("CPI")]
    rules = public_series[public_series.metric.str.contains(regulator)]
    ax.plot(cmhc.reference_year, cmhc.value, "s--", color="tab:green", label="CMHC, fixed-sample average rent (CMA)")
    ax.plot(cpi.reference_year, cpi.value, "^--", color="tab:purple", label="CPI rent component (Statistics Canada)")
    ax.plot(rules.reference_year, rules.value, "x:", color="black", ms=8, label="Renewal regulation (TAL / guideline)")
    ax.set(title=title, xlabel="Year", xticks=list(range(FIRST_MARKET_YEAR, TARGET_YEAR + 1)))
    ax.grid(alpha=0.3)
axes[0].set_ylabel("% per year")
axes[1].legend(loc="upper left", bbox_to_anchor=(1.02, 1), fontsize=9)
plt.tight_layout()
plt.show()

Reading. In Quebec, our same-unit contractual (face) growth is at the market level (CMHC) in 2021–2022, then falls clearly behind in 2023–2024: it then tracks the TAL almost exactly (2.1% against 2.3% in 2023, 3.7% against 4.0% in 2024), while CMHC and the CPI stay above 6%. In 2025 it catches up with the market (7.6% against 7.4% for CMHC). Our effective growth has stayed below the indices since 2023: the public indices do not measure free months, whereas we do. In Ontario, the Ottawa market slows in 2025 (CMHC 3.5%) while The Met's contractual (face) rents accelerate; effective growth, for its part, stays moderate. These public series serve to check the plausibility of our forecast, not to compute it: section 9 shows that adding them to the model does not improve the backtests.

Does the TAL help forecast Quebec renewals? Test fixed before running it. Over 2023–2025, the rule "last year + (this year's TAL − last year's TAL)" for the contractual (face) growth of Quebec renewals must beat both the average of earlier years and persistence, on average error and on worst year. No fitted slope: there are only three TAL values before the last year. We even assume the year's TAL is known (published in January), which favours the hypothesis. If the test fails, the TAL remains context and the regulatory scenarios are sensitivities, not forecasts.

def tal_passthrough_test(lease_table, external_table, test_years=REQUIRED_BACKTEST_YEARS):
    is_tal = external_table["metric"].str.contains("TAL", na=False)
    tal_by_year = {int(str(period).split()[-1]): float(value)
                   for period, value in zip(external_table.loc[is_tal, "reference_period"], external_table.loc[is_tal, "value"])}
    all_pairs = build_pairs(lease_table)
    quebec_renewals = all_pairs[all_pairs["eligible"] & all_pairs["province"].eq("Quebec") & all_pairs["event_type"].eq("renewal")]
    actual_by_year = PERCENT * quebec_renewals.groupby("event_year")["face_growth"].mean()
    rows = []
    for year in test_years:
        if year not in tal_by_year or year - 1 not in tal_by_year:
            raise DataContractError(f"TAL values for {year - 1} and {year} required")
        earlier = actual_by_year[actual_by_year.index < year]
        rows.append({"year": year, "actual_pct": float(actual_by_year[year]), "all_prior_mean_pct": float(earlier.mean()),
                     "persistence_pct": float(earlier.iloc[-1]),
                     "tal_passthrough_pct": float(earlier.iloc[-1]) + tal_by_year[year] - tal_by_year[year - 1],
                     "tal_pct": tal_by_year[year]})
    table = pd.DataFrame(rows)
    errors = {method: (table[f"{method}_pct"] - table["actual_pct"]).abs()
              for method in ("all_prior_mean", "persistence", "tal_passthrough")}
    best_baseline = min(("all_prior_mean", "persistence"), key=lambda method: errors[method].mean())
    verdict = {"mae_pp": {method: float(error.mean()) for method, error in errors.items()},
               "worst_pp": {method: float(error.max()) for method, error in errors.items()},
               "criterion": "the TAL must beat both comparators on average error and on worst year"}
    verdict["retained"] = bool(errors["tal_passthrough"].mean() < min(errors["all_prior_mean"].mean(), errors["persistence"].mean())
                               and errors["tal_passthrough"].max() <= errors[best_baseline].max())
    return table, verdict


tal_table, tal_verdict = tal_passthrough_test(leases, external)
display(tal_table.round(2))
print(tal_verdict)
year actual_pct all_prior_mean_pct persistence_pct tal_passthrough_pct tal_pct
0 2023 2.55 3.92 5.83 6.85 2.3
1 2024 2.91 3.69 2.55 4.25 4.0
2 2025 6.53 3.58 2.91 4.81 5.9
{'mae_pp': {'all_prior_mean': 1.700063443886726, 'persistence': 2.419597978689527, 'tal_passthrough': 2.449407693602618}, 'worst_pp': {'all_prior_mean': 2.9456759355395805, 'persistence': 3.6143143314996435, 'tal_passthrough': 4.299194176938574}, 'criterion': 'the TAL must beat both comparators on average error and on worst year', 'retained': False}

2026 regulatory sensitivities (published after the origin). The 2026 TAL figure (new method, three-year average of the CPI) and the 2026 Ontario guideline are read from external_post_origin. The renewal cells are brought back to the anchor level when they exceed it, keeping the modelled change in the concession gap. On the Ontario side, the guideline covers only rent-controlled units; buildings first occupied after 15 November 2018 are exempt, and The Met's date of first occupancy is not in the extract: this scenario is conditional. No cap is applied from one province to the other.

ANCHOR_PLAUSIBLE_RANGE_PCT = (-5.0, 25.0)   # safeguard against an erroneous anchor entry
TAL_2026_PCT = float(external_post_origin.loc[external_post_origin.metric.str.startswith("TAL 2026"), "value"].iloc[0])
ONTARIO_2026_PCT = float(external_post_origin.loc[external_post_origin.metric.eq("Ontario rent increase guideline"), "value"].iloc[0])


def regulatory_scenarios(cells, quebec_renewal_face_pct, ontario_renewal_face_pct):
    for name, anchor_pct in (("quebec", quebec_renewal_face_pct), ("ontario", ontario_renewal_face_pct)):
        if not np.isfinite(anchor_pct) or not ANCHOR_PLAUSIBLE_RANGE_PCT[0] < anchor_pct < ANCHOR_PLAUSIBLE_RANGE_PCT[1]:
            raise DataContractError(f"implausible {name} anchor: {anchor_pct}")

    def reprice(label, anchors):
        repriced = cells.copy()
        for province, anchor_pct in anchors.items():
            renewal_cells = repriced["province"].eq(province) & repriced["event_type"].eq("renewal")
            excess = (repriced.loc[renewal_cells, "face"] - anchor_pct / PERCENT).clip(lower=0.0)
            repriced.loc[renewal_cells, "effective"] -= excess
            repriced.loc[renewal_cells, "face"] -= excess
        return {"scenario": label, "effective_pct": PERCENT * aggregate(repriced), "face_pct": PERCENT * aggregate(repriced, "face")}

    return pd.DataFrame([
        reprice("Central forecast", {}),
        reprice(f"Quebec renewals at the 2026 TAL ({quebec_renewal_face_pct:g}%)", {"Quebec": quebec_renewal_face_pct}),
        reprice(f"Ontario renewals at the guideline ({ontario_renewal_face_pct:g}%), if The Met is rent-controlled",
                {"Ontario": ontario_renewal_face_pct}),
        reprice("Both anchors", {"Quebec": quebec_renewal_face_pct, "Ontario": ontario_renewal_face_pct})])


display(external_post_origin[["geography", "metric", "value", "release_date", "limitation"]])
display(regulatory_scenarios(forecast_2026["cells"], TAL_2026_PCT, ONTARIO_2026_PCT).round(3))
geography metric value release_date limitation
0 Quebec TAL 2026 base rent-adjustment percentage 3.10 2026-01 Published AFTER the 2025-12-31 forecast origin...
1 Ontario Ontario rent increase guideline 2.10 2025-06-30 Applies only to rent-controlled units; buildin...
2 Quebec Statistics Canada rent CPI year-over-year, Aug... 3.02 2026-09-14 Single monthly reading; monthly rent CPI is no...
3 Ontario Statistics Canada rent CPI year-over-year, Aug... 2.37 2026-09-14 Single monthly reading; not used in any estimate.
scenario effective_pct face_pct
0 Central forecast 4.805 6.378
1 Quebec renewals at the 2026 TAL (3.1%) 3.465 5.038
2 Ontario renewals at the guideline (2.1%), if T... 4.557 6.130
3 Both anchors 3.217 4.790

7.7 Scenarios and historical envelope

Concessions can separate contractual (face) from effective growth sharply: we shift the gap of new leases by ±2 points, and the turnover share by ±10 points. The gap scenarios are compared with the structural reference (BRIDGE alone with current concessions); the turnover ones, with the central forecast. These are transparent assumptions, not probabilities.

The envelope = central forecast ± the largest annual error observed in the backtest. With six years, a regime change and years already consulted, this is not a confidence interval: it is the order of magnitude of past error.

CONCESSION_GAP_SHOCK = 0.02     # ±2 points of concession gap on new leases
TURNOVER_SHARE_SHOCK = 0.10     # ±10 points of turnover share


def scenario_table(bundle):
    central = combine(component_cells(bundle))
    rows = []

    def add(label, cells):
        rows.append({"scenario": label, "effective_pct": PERCENT * aggregate(cells), "face_pct": PERCENT * aggregate(cells, "face")})

    add("Central forecast", central)
    for label, gap_change in (("Structural reference: current concessions", 0.0),
                              (f"Structural: concession gap +{PERCENT * CONCESSION_GAP_SHOCK:g} points", CONCESSION_GAP_SHOCK),
                              (f"Structural: concession gap −{PERCENT * CONCESSION_GAP_SHOCK:g} points", -CONCESSION_GAP_SHOCK)):
        shocked = central.copy()
        shocked["effective"] = [roster_bridge(bundle, cell.property_code, cell.face,
                                              float(np.clip(cell.new_gap_persistence + gap_change, *SPEC.gap_clip)))
                                for cell in shocked.itertuples()]
        add(label, shocked)
    for share_change in (TURNOVER_SHARE_SHOCK, -TURNOVER_SHARE_SHOCK):
        shocked = central.copy()
        for property_code in shocked.property_code.unique():
            turnover = shocked.property_code.eq(property_code) & shocked.event_type.eq("turnover")
            renewal = shocked.property_code.eq(property_code) & shocked.event_type.eq("renewal")
            turnover_share = float(np.clip(shocked.loc[turnover, "event_share"].iloc[0] + share_change, 0, 1))
            shocked.loc[turnover, "event_share"] = turnover_share
            shocked.loc[renewal, "event_share"] = 1 - turnover_share
        shocked["expected_units"] = shocked.roster_units * shocked.event_share
        shocked["weight"] = shocked.expected_units / shocked.expected_units.sum()
        add(f"Turnover share {PERCENT * share_change:+.0f} points", shocked)
    return pd.DataFrame(rows)


scenarios = scenario_table(make_bundle(leases, TARGET_YEAR))
display(scenarios.round(3))
largest_backtest_error_pp = float(all_folds.abs_error_pp.max())
historical_envelope_pct = [estimate_pct - largest_backtest_error_pp, estimate_pct + largest_backtest_error_pp]
print(f"Descriptive historical envelope: {historical_envelope_pct[0]:.2f}% to {historical_envelope_pct[1]:.2f}% "
      f"(central ± largest annual backtest error, {largest_backtest_error_pp:.2f} pts; not a confidence interval)")
scenario effective_pct face_pct
0 Central forecast 4.805 6.378
1 Structural reference: current concessions 6.325 6.378
2 Structural: concession gap +2 points 4.012 6.378
3 Structural: concession gap −2 points 8.638 6.378
4 Turnover share +10 points 5.077 6.575
5 Turnover share -10 points 4.532 6.181
Descriptive historical envelope: 2.51% to 7.10% (central ± largest annual backtest error, 2.29 pts; not a confidence interval)
8. Bonus: forecast by building and by number of bedrooms, small dashboardBy building: the model's cells, aggregated by sBuilding.

8. Bonus: forecast by building and by number of bedrooms, small dashboard

By building: the model's cells, aggregated by sBuilding.

Details

By number of bedrooms: an offset per unit type, computed over the last three years known at the origin and shrunk toward zero (k = 20), is added and then re-centred on each building's roster, so that each building reconciles exactly with its forecast. This offset is only useful if it predicts better than "no bedroom effect": the second table checks this on the same origins as the backtest. If it does not, the bedroom table remains descriptive.

BEDROOM_SHRINK_K = 20.0          # same shrinkage strength as the renewal share
BEDROOM_TRAILING_YEARS = 3       # same window as the renewal share
building_names = validated_leases.groupby("sPropCode")["sBuilding"].first().rename("building")


def building_forecast(details_cells):
    cells = details_cells.merge(building_names, left_on="property_code", right_index=True, validate="many_to_one")
    return pd.DataFrame([{"building": name, "expected_events": building_cells.expected_units.sum(),
                          "face_pct": PERCENT * np.average(building_cells.face, weights=building_cells.weight),
                          "effective_pct": PERCENT * np.average(building_cells.effective, weights=building_cells.weight)}
                         for name, building_cells in cells.groupby("building")])


def bedroom_offsets(pairs_table, target_year):
    recent = pairs_table[pairs_table["event_year"].between(target_year - BEDROOM_TRAILING_YEARS, target_year - 1)]
    if recent.empty:
        raise DataContractError("no recent pair to estimate the bedroom offsets")
    effective = clip_for_training(recent["effective_growth"], "effective_growth")
    face = clip_for_training(recent["face_growth"], "face_growth")
    rows = []
    for bedrooms, index in recent.groupby("bedrooms").groups.items():
        n_pairs = len(index)
        shrink = n_pairs / (n_pairs + BEDROOM_SHRINK_K)
        rows.append({"bedrooms": bedrooms, "n_pairs": n_pairs,
                     "effective_offset": shrink * float(effective.loc[index].mean() - effective.mean()),
                     "face_offset": shrink * float(face.loc[index].mean() - face.mean())})
    return pd.DataFrame(rows)


def bedroom_forecast(lease_table, details_cells, target_year=TARGET_YEAR):
    bundle = make_bundle(lease_table, target_year)
    offsets = bedroom_offsets(bundle.pairs, target_year).set_index("bedrooms")
    roster = bundle.roster[bundle.roster["comparable"]].merge(building_names, left_on="property_code", right_index=True,
                                                              validate="many_to_one")
    cells = details_cells.merge(building_names, left_on="property_code", right_index=True, validate="many_to_one")
    rows = []
    for building, units in roster.groupby("building"):
        building_cells = cells[cells["building"].eq(building)]
        base = {column: float(np.average(building_cells[column], weights=building_cells["weight"])) for column in ("effective", "face")}
        units_by_bedrooms = units.groupby("bedrooms").size()
        for column in ("effective", "face"):
            building_offsets = offsets[f"{column}_offset"].reindex(units_by_bedrooms.index).fillna(0.0)
            base[f"{column}_recentre"] = float(np.average(building_offsets, weights=units_by_bedrooms))
        for bedrooms, n_units in units_by_bedrooms.items():
            offset = offsets.reindex([bedrooms]).fillna(0.0).iloc[0]
            rows.append({"building": building, "bedrooms": bedrooms, "roster_units": int(n_units),
                         "effective_pct": PERCENT * (base["effective"] + offset["effective_offset"] - base["effective_recentre"]),
                         "face_pct": PERCENT * (base["face"] + offset["face_offset"] - base["face_recentre"])})
    return pd.DataFrame(rows)


def bedroom_offset_backtest(lease_table, target_years):
    # Do the bedroom offsets predict the realised spread of the following year better than "no effect"?
    rows = []
    for year in target_years:
        bundle = make_bundle(lease_table, year)
        offsets = bedroom_offsets(bundle.pairs, year).set_index("bedrooms")["effective_offset"]
        actual_pairs = realized(lease_table, year)
        realized_spread = actual_pairs.groupby("bedrooms")["effective_growth"].mean() - actual_pairs["effective_growth"].mean()
        pairs_per_group = actual_pairs.groupby("bedrooms").size()
        common = realized_spread.index.intersection(offsets.index)
        weights = pairs_per_group.loc[common]
        rows.append({"target_year": year, "bedroom_groups": len(common),
                     "mae_with_offsets_pp": PERCENT * float(np.average((offsets.loc[common] - realized_spread.loc[common]).abs(), weights=weights)),
                     "mae_no_bedroom_effect_pp": PERCENT * float(np.average(realized_spread.loc[common].abs(), weights=weights))})
    check = pd.DataFrame(rows)
    check["offsets_better"] = check["mae_with_offsets_pp"] < check["mae_no_bedroom_effect_pp"]
    return check


by_building = building_forecast(forecast_2026["cells"])
display(by_building.round(3))
by_bedroom = bedroom_forecast(leases, forecast_2026["cells"])
display(by_bedroom.round(2))
bedroom_check = bedroom_offset_backtest(leases, SPEC.evaluation_years)
display(bedroom_check.round(3))
print("Years in which the bedroom offsets beat \"no effect\":", int(bedroom_check.offsets_better.sum()), "out of", len(bedroom_check))
building expected_events face_pct effective_pct
0 Daniel-Johnson 128.0 6.055 4.937
1 Le Carlyle 180.0 6.428 4.522
2 Levesque 75.0 5.745 4.749
3 Saint-Elzear 267.0 6.017 4.967
4 The Met 119.0 6.566 4.317
5 Westpark 159.0 7.347 5.137
building bedrooms roster_units effective_pct face_pct
0 Daniel-Johnson 0 12 4.98 6.34
1 Daniel-Johnson 1 54 4.94 6.24
2 Daniel-Johnson 2 58 4.94 5.82
3 Daniel-Johnson 3 4 4.71 6.09
4 Le Carlyle 1 68 4.53 6.69
5 Le Carlyle 2 107 4.53 6.26
6 Le Carlyle 3 5 4.30 6.53
7 Levesque 1 9 4.76 6.11
8 Levesque 2 64 4.75 5.69
9 Levesque 3 2 4.53 5.96
10 Saint-Elzear 1 77 4.98 6.31
11 Saint-Elzear 2 182 4.97 5.89
12 Saint-Elzear 3 8 4.75 6.16
13 The Met 0 37 4.34 6.76
14 The Met 1 46 4.31 6.67
15 The Met 2 36 4.30 6.24
16 Westpark 1 61 5.15 7.61
17 Westpark 2 95 5.14 7.18
18 Westpark 3 3 4.91 7.45
target_year bedroom_groups mae_with_offsets_pp mae_no_bedroom_effect_pp offsets_better
0 2020 3 0.183 0.108 False
1 2021 3 0.071 0.073 True
2 2022 3 0.212 0.256 True
3 2023 3 0.162 0.087 False
4 2024 4 0.349 0.194 False
5 2025 4 0.124 0.122 False
Years in which the bedroom offsets beat "no effect": 2 out of 6
# Dashboard: 2026 forecast by building, contractual (face) and effective, with the descriptive historical envelope.
dashboard = by_building.set_index("building")
envelope_half_width_pp = (historical_envelope_pct[1] - historical_envelope_pct[0]) / 2
rows_position = np.arange(len(dashboard))
BAR_HEIGHT = 0.4
fig, ax = plt.subplots(figsize=(9, 4))
ax.barh(rows_position + BAR_HEIGHT / 2, dashboard.face_pct, BAR_HEIGHT, label="Contractual")
ax.barh(rows_position - BAR_HEIGHT / 2, dashboard.effective_pct, BAR_HEIGHT, xerr=envelope_half_width_pp, capsize=3,
        label="Effective (± historical envelope)")
ax.set_yticks(rows_position, [f"{name} ({events:.0f} events)" for name, events in zip(dashboard.index, dashboard.expected_events)])
ax.axvline(estimate_pct, color="black", ls="--", lw=1, label=f"Effective portfolio: {estimate_pct:.2f}%")
ax.set(title="2026 forecast by building", xlabel="% annualised, same unit")
ax.legend(loc="upper center", bbox_to_anchor=(0.5, -0.18), ncol=3)
plt.tight_layout()
plt.show()
8b. Why this figure? A building-by-building readingA manager does not only want "+4.9%": they want to know why. For each building, the forecast reconciles exactly into three pieces:

8b. Why this figure? A building-by-building reading

A manager does not only want "+4.9%": they want to know why. For each building, the forecast reconciles exactly into three pieces:

Details
  • Renewals: expected share of 2026 leases that are renewals × the contractual (face) increase forecast for them;
  • Turnovers: expected share of turnovers × the contractual (face) increase forecast for them;
  • Concession adjustment: the difference between the building's effective forecast and its contractual (face) forecast, in percentage points.

Renewals + turnovers = the building's contractual (face) increase; plus the concession adjustment = effective increase. The cell checks that the sum lands exactly on the section 8 forecast. One honest precision: the contractual forecast combines two readings and the effective forecast combines three (section 7.3), so this difference is a reconciliation between the model's two forecasts, not an isolated measurement of the causal effect of free months. Read it as "what the model expects concessions to take back"; isolating a purely causal effect would need a counterfactual that changes only the concessions. We add two reference points taken from the data: the effective increase realised in 2025 (recent momentum) and the building's long-term average (the trend).

Finally, the public context around each building (public file building_context.csv: residential construction permitted within 2 km, rapid transit, location score, flood zone, heat island). This context helps to discuss the figure; it does not enter the computation, because none of these variables improved the backtests.

def explain_building(cells):
    # Exact reconciliation of one building: renewals + turnovers + concession adjustment (effective − contractual) = effective.
    weights = cells["weight"]
    renewal = cells["event_type"].eq("renewal")
    renewal_share = float(weights[renewal].sum() / weights.sum())
    face_renewal = float(np.average(cells.loc[renewal, "face"], weights=weights[renewal]))
    face_turnover = float(np.average(cells.loc[~renewal, "face"], weights=weights[~renewal]))
    face = float(np.average(cells["face"], weights=weights))
    effective = float(np.average(cells["effective"], weights=weights))
    return {"renewal_share": renewal_share,
            "renewal_face_pct": PERCENT * face_renewal, "turnover_face_pct": PERCENT * face_turnover,
            "renewals_pp": PERCENT * renewal_share * face_renewal,
            "turnovers_pp": PERCENT * (1 - renewal_share) * face_turnover,
            "concessions_pp": -PERCENT * (face - effective),
            "face_pct": PERCENT * face, "effective_pct": PERCENT * effective,
            "momentum_DIRECT_pct": PERCENT * float(np.average(cells["DIRECT"], weights=weights)),
            "trend_ANCHOR_pct": PERCENT * float(np.average(cells["ANCHOR"], weights=weights))}


cells_with_building = forecast_2026["cells"].merge(building_names, left_on="property_code", right_index=True, validate="many_to_one")
why = pd.DataFrame({building: explain_building(cells) for building, cells in cells_with_building.groupby("building")}).T
pairs_with_building = eligible_pairs.merge(building_names, left_on="property_code", right_index=True, validate="many_to_one")
why["realised_2025_effective_pct"] = PERCENT * pairs_with_building[pairs_with_building.event_year.eq(LAST_OBSERVED_YEAR)].groupby(
    "building").effective_growth.mean()
why = why.sort_values("effective_pct", ascending=False)

# The three pieces land exactly on the by-building forecast of section 8 (an arithmetic check, not a causal attribution).
np.testing.assert_allclose(why.renewals_pp + why.turnovers_pp + why.concessions_pp, why.effective_pct, atol=1e-9)
np.testing.assert_allclose(why.effective_pct, by_building.set_index("building").loc[why.index, "effective_pct"], atol=1e-9)
display(why.round(2))
renewal_share renewal_face_pct turnover_face_pct renewals_pp turnovers_pp concessions_pp face_pct effective_pct momentum_DIRECT_pct trend_ANCHOR_pct realised_2025_effective_pct
Westpark 0.62 6.14 9.34 3.83 3.52 -2.21 7.35 5.14 3.80 3.81 3.78
Saint-Elzear 0.60 5.47 6.84 3.29 2.73 -1.05 6.02 4.97 4.39 3.17 4.64
Daniel-Johnson 0.60 5.20 7.33 3.11 2.94 -1.12 6.05 4.94 4.14 2.96 4.41
Levesque 0.55 5.38 6.19 2.96 2.78 -1.00 5.75 4.75 3.59 2.89 3.17
Le Carlyle 0.63 5.80 7.50 3.65 2.78 -1.91 6.43 4.52 3.59 2.55 3.54
The Met 0.58 5.45 8.10 3.15 3.42 -2.25 6.57 4.32 3.01 2.59 3.12
WATERFALL_STEPS = [("renewals_pp", "Renewals", "tab:blue"), ("turnovers_pp", "Turnovers", "tab:cyan"),
                   ("concessions_pp", "Concession adjustment / Ajustement de concession", "tab:red")]
fig, axes = plt.subplots(2, 3, figsize=(14, 7), sharey=True)
for ax, (building, row) in zip(axes.ravel(), why.iterrows()):
    level = 0.0
    for position, (column, label, color) in enumerate(WATERFALL_STEPS):
        ax.bar(position, row[column], bottom=level, color=color)
        ax.text(position, level + row[column] / 2, f"{row[column]:+.1f}", ha="center", va="center", color="white", fontweight="bold")
        level += row[column]
    ax.bar(len(WATERFALL_STEPS), row.effective_pct, color="black")
    ax.text(len(WATERFALL_STEPS), row.effective_pct / 2, f"{row.effective_pct:.1f}%", ha="center", va="center", color="white", fontweight="bold")
    ax.set_xticks(range(len(WATERFALL_STEPS) + 1), [label for _, label, _ in WATERFALL_STEPS] + ["Effective\n2026"])
    ax.set_title(building)
    ax.axhline(0, color="grey", lw=0.8)
axes[0, 0].set_ylabel("percentage points")
axes[1, 0].set_ylabel("percentage points")
fig.suptitle("What makes up each building's 2026 increase", fontsize=13)
plt.tight_layout()
plt.show()

In words, building by building. The text below is generated from the figures of the breakdown and from the public context file, so that it always stays consistent with them.

from IPython.display import Markdown

FLOOD_ZONE_NEAR_M = 250          # below this distance, proximity to a flood zone is worth flagging
building_context = pd.read_csv("building_context.csv").set_index("building")


def en_number(value, decimals=1):
    return f"{value:,.{decimals}f}"


def building_story(building, row, context):
    lines = [f"**{building}: {en_number(row.effective_pct)}% effective** ({en_number(row.face_pct)}% contractual).",
             f"On paper, rents rise by {en_number(row.face_pct)}%: about {en_number(PERCENT * row.renewal_share, 0)}% of the expected leases "
             f"are renewals (forecast at +{en_number(row.renewal_face_pct)}%) and the rest turnovers (+{en_number(row.turnover_face_pct)}%). "
             f"Concessions take back {en_number(-row.concessions_pp)} {'point' if en_number(-row.concessions_pp) == '1.0' else 'points'}.",
             f"Reference points: in {LAST_OBSERVED_YEAR}, its same-unit effective rents rose by {en_number(row.realised_2025_effective_pct)}%; "
             f"its long-term trend is {en_number(row.trend_ANCHOR_pct)}% a year."]
    notes = [f"{en_number(context.rapid_transit_distance_m, 0)} m from {context.nearest_rapid_transit}",
             f"location score {context.location_score_0_100}/100"]
    if pd.notna(context.units_permitted_2km_2023_2025):
        notes.append(f"{en_number(context.units_permitted_2km_2023_2025, 0)} units permitted within 2 km in 2023–2025 "
                     "(new supply to watch: more supply tends to increase concessions)")
    else:
        notes.append("building permits not published by the city")
    if context.flood_zone_distance_m < FLOOD_ZONE_NEAR_M:
        notes.append(f"{context.flood_zone_distance_m} m from a mapped flood zone")
    if context.heat_island is True or str(context.heat_island) == "True":
        notes.append("urban heat island")
    lines.append("Public context (not used in the calculation): " + "; ".join(notes) + ".")
    return "\n\n".join(lines)


display(Markdown("\n\n---\n\n".join(building_story(building, row, building_context.loc[building]) for building, row in why.iterrows())))

Westpark: 5.1% effective (7.3% contractual).

On paper, rents rise by 7.3%: about 62% of the expected leases are renewals (forecast at +6.1%) and the rest turnovers (+9.3%). The concession adjustment (the gap between the effective and contractual forecasts) takes back 2.2 points.

Reference points: in 2025, its same-unit effective rents rose by 3.8%; its long-term trend is 3.8% a year.

Public context (not used in the calculation): 1,143 m from Fairview-Pointe-Claire (REM); location score 43/100; building permits not published by the city.


Saint-Elzear: 5.0% effective (6.0% contractual).

On paper, rents rise by 6.0%: about 60% of the expected leases are renewals (forecast at +5.5%) and the rest turnovers (+6.8%). The concession adjustment (the gap between the effective and contractual forecasts) takes back 1.0 point.

Reference points: in 2025, its same-unit effective rents rose by 4.6%; its long-term trend is 3.2% a year.

Public context (not used in the calculation): 4,566 m from Montmorency (metro); location score 19/100; 2,173 units permitted within 2 km in 2023–2025 (new supply to watch: more supply tends to increase concessions).


Daniel-Johnson: 4.9% effective (6.1% contractual).

On paper, rents rise by 6.1%: about 60% of the expected leases are renewals (forecast at +5.2%) and the rest turnovers (+7.3%). The concession adjustment (the gap between the effective and contractual forecasts) takes back 1.1 points.

Reference points: in 2025, its same-unit effective rents rose by 4.4%; its long-term trend is 3.0% a year.

Public context (not used in the calculation): 2,127 m from Montmorency (metro); location score 58/100; 3,944 units permitted within 2 km in 2023–2025 (new supply to watch: more supply tends to increase concessions); urban heat island.


Levesque: 4.7% effective (5.7% contractual).

On paper, rents rise by 5.7%: about 55% of the expected leases are renewals (forecast at +5.4%) and the rest turnovers (+6.2%). The concession adjustment (the gap between the effective and contractual forecasts) takes back 1.0 point.

Reference points: in 2025, its same-unit effective rents rose by 3.2%; its long-term trend is 2.9% a year.

Public context (not used in the calculation): 2,047 m from Bois-Franc (REM); location score 32/100; 3,692 units permitted within 2 km in 2023–2025 (new supply to watch: more supply tends to increase concessions); 54 m from a mapped flood zone.


Le Carlyle: 4.5% effective (6.4% contractual).

On paper, rents rise by 6.4%: about 63% of the expected leases are renewals (forecast at +5.8%) and the rest turnovers (+7.5%). The concession adjustment (the gap between the effective and contractual forecasts) takes back 1.9 points.

Reference points: in 2025, its same-unit effective rents rose by 3.5%; its long-term trend is 2.5% a year.

Public context (not used in the calculation): 526 m from De La Savane (metro); location score 59/100; 1,077 units permitted within 2 km in 2023–2025 (new supply to watch: more supply tends to increase concessions).


The Met: 4.3% effective (6.6% contractual).

On paper, rents rise by 6.6%: about 58% of the expected leases are renewals (forecast at +5.4%) and the rest turnovers (+8.1%). The concession adjustment (the gap between the effective and contractual forecasts) takes back 2.2 points.

Reference points: in 2025, its same-unit effective rents rose by 3.1%; its long-term trend is 2.6% a year.

Public context (not used in the calculation): 479 m from Parliament (O-Train); location score 86/100; 526 units permitted within 2 km in 2023–2025 (new supply to watch: more supply tends to increase concessions).

9. Does a regression model do better? XGBoost and Ridge, tested and rejectedThe brief allows a regression model if it is justified. We tested it honestly, with a rule written before the first run:

9. Does a regression model do better? XGBoost and Ridge, tested and rejected

The brief allows a regression model if it is justified. We tested it honestly, with a rule written before the first run:

Details
  • XGBoost (depth 3, 300 trees, learning rate 0.05, subsampling 0.8) and Ridge (alpha 10 on standardised variables), trained at the pair level, using only information known at the origin: event type, province, bedrooms, interval, previous concession gap, start month, building age, rent position within the building, and last year's averages (cell and portfolio).
  • Same origins, same roster, same aggregation and same target as our method.
  • Replacement rule: a model replaces our method only if its average error is lower than ours and than that of the historical average, over 2020–2025 and over 2023–2025, with no worst year worse than ours. No hyperparameter was tuned on these years.

The SHAP contributions explain XGBoost's 2026 forecast, variable by variable. Since the model is not retained, no trained model file is delivered (the parameters of our method are in ForecastSpec, section 4b).

from sklearn.linear_model import Ridge
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler

RANDOM_SEED = 20_260_101
XGB_PARAMS = dict(max_depth=3, n_estimators=300, learning_rate=0.05, subsample=0.8, colsample_bytree=0.8,
                  min_child_weight=10, reg_lambda=1.0, random_state=RANDOM_SEED, n_jobs=1)
RIDGE_ALPHA = 10.0
LAG_FEATURES = ["lag_cell_face", "lag_cell_eff", "lag_cell_gap", "lag_port_face", "lag_port_gap"]
MODEL_FEATURES = ["is_renewal", "is_ontario", "bedrooms", "interval_years", "prior_gap", "start_month",
                  "property_age", "rent_position"] + LAG_FEATURES


def lagged_means(known_pairs):
    # Previous year's averages, by province × event and for the portfolio.
    cell = known_pairs.groupby(["event_year", "province", "event_type"]).agg(
        lag_cell_face=("face_growth", "mean"), lag_cell_eff=("effective_growth", "mean"), lag_cell_gap=("new_gap", "mean")).reset_index()
    portfolio = known_pairs.groupby("event_year").agg(lag_port_face=("face_growth", "mean"), lag_port_gap=("new_gap", "mean")).reset_index()
    cell["event_year"] += 1
    portfolio["event_year"] += 1
    return cell, portfolio


def model_features(rows, known_pairs):
    cell, portfolio = lagged_means(known_pairs)
    first_year = known_pairs.groupby("property_code")["event_year"].min()
    mean_prior_rent = known_pairs.groupby("property_code")["prior_face_rent"].mean()
    features = rows.merge(cell, on=["event_year", "province", "event_type"], how="left").merge(portfolio, on="event_year", how="left")
    features["is_renewal"] = features["event_type"].eq("renewal").astype(float)
    features["is_ontario"] = features["province"].eq("Ontario").astype(float)
    features["property_age"] = features["event_year"] - features["property_code"].map(first_year).fillna(features["event_year"])
    features["rent_position"] = features["prior_face_rent"] / features["property_code"].map(mean_prior_rent).fillna(features["prior_face_rent"])
    features["bedrooms"] = features["bedrooms"].astype(float)
    return features


def roster_rows(bundle):
    # One row per roster unit and per event type, weighted by the expected share.
    roster = bundle.roster[bundle.roster["comparable"]].reset_index(drop=True)
    shares = event_mix(bundle).set_index(["property_code", "event_type"])["event_share"]
    return pd.concat([pd.DataFrame({"unit": roster.index, "event_year": bundle.target_year, "province": roster["province"],
                                    "event_type": event_type, "property_code": roster["property_code"],
                                    "bedrooms": roster["bedrooms"], "interval_years": roster["expected_years"],
                                    "prior_gap": roster["prior_gap"],
                                    "start_month": (roster["lease_end"] + pd.Timedelta(days=1)).dt.month,
                                    "prior_face_rent": roster["face_rent"],
                                    "share": [shares[(code, event_type)] for code in roster["property_code"]]})
                      for event_type in ("renewal", "turnover")], ignore_index=True)


def fit_predict(kind, train, test):
    target = clip_for_training(train["effective_growth"], "effective_growth").to_numpy()
    if kind == "xgboost":
        regressor = xgboost.XGBRegressor(**XGB_PARAMS)
        regressor.fit(train[MODEL_FEATURES].to_numpy(float), target)
        return regressor.predict(test[MODEL_FEATURES].to_numpy(float)), regressor
    has_lags = train[LAG_FEATURES].notna().all(axis=1)
    usable = [feature for feature in MODEL_FEATURES if train.loc[has_lags, feature].notna().any()]
    training_means = train.loc[has_lags, usable].mean()
    regressor = make_pipeline(StandardScaler(), Ridge(alpha=RIDGE_ALPHA))
    regressor.fit(train.loc[has_lags, usable].fillna(training_means).to_numpy(float), target[has_lags.to_numpy()])
    return regressor.predict(test[usable].fillna(training_means).to_numpy(float)), regressor


def forecast_challenger(bundle, kind):
    known_pairs = bundle.pairs
    train = model_features(known_pairs.assign(start_month=known_pairs["lease_start"].dt.month), known_pairs)
    roster = model_features(roster_rows(bundle), known_pairs)
    predictions, regressor = fit_predict(kind, train, roster)
    roster = roster.assign(prediction=predictions)
    per_unit = roster.groupby("unit").apply(lambda unit: float(np.average(unit["prediction"], weights=unit["share"])),
                                            include_groups=False)
    return {"portfolio": float(per_unit.mean()), "roster": roster, "model": regressor}


challenger_rows = []
for year in evaluation_years:
    bundle = make_bundle(leases, year)
    challenger_rows.append({"target_year": year,
                            "actual_pct": PERCENT * float(realized(leases, year)["effective_growth"].mean()),
                            "our_method_pct": all_folds.set_index("target_year").loc[year, "predicted_pct"],
                            "xgboost_pct": PERCENT * forecast_challenger(bundle, "xgboost")["portfolio"],
                            "ridge_pct": PERCENT * forecast_challenger(bundle, "ridge")["portfolio"],
                            "historical_mean_pct": baselines.query("model == 'All history pooled' and target_year == @year").predicted_pct.iloc[0]})
challenger_backtest = pd.DataFrame(challenger_rows)
display(challenger_backtest.round(2))

scoreboard_rows = []
for method in ("our_method", "xgboost", "ridge", "historical_mean"):
    error = challenger_backtest[f"{method}_pct"] - challenger_backtest["actual_pct"]
    in_required_window = challenger_backtest["target_year"].isin(REQUIRED_BACKTEST_YEARS)
    scoreboard_rows.append({"method": method, "MAE_2020_2025_pp": error.abs().mean(),
                            "MAE_2023_2025_pp": error[in_required_window].abs().mean(), "worst_year_pp": error.abs().max()})
challenger_scoreboard = pd.DataFrame(scoreboard_rows).set_index("method")
display(challenger_scoreboard.round(3))
for candidate in ("xgboost", "ridge"):
    board = challenger_scoreboard
    replaces = bool(board.loc[candidate, "MAE_2020_2025_pp"] < board.loc[["our_method", "historical_mean"], "MAE_2020_2025_pp"].min()
                    and board.loc[candidate, "MAE_2023_2025_pp"] < board.loc[["our_method", "historical_mean"], "MAE_2023_2025_pp"].min()
                    and board.loc[candidate, "worst_year_pp"] <= board.loc["our_method", "worst_year_pp"])
    print(f"{candidate} replaces our method: {replaces}")
target_year actual_pct our_method_pct xgboost_pct ridge_pct historical_mean_pct
0 2020 2.77 1.76 2.06 2.22 1.75
1 2021 3.75 2.74 2.94 1.63 2.33
2 2022 4.72 3.75 4.54 5.87 2.87
3 2023 2.18 4.47 4.91 6.52 3.38
4 2024 1.95 2.62 3.11 -0.15 3.09
5 2025 3.83 2.93 4.61 5.53 2.76
MAE_2020_2025_pp MAE_2023_2025_pp worst_year_pp
method
our_method 1.142 1.289 2.294
xgboost 1.063 1.559 2.733
ridge 1.994 2.714 4.343
historical_mean 1.282 1.138 1.841
xgboost replaces our method: False
ridge replaces our method: False
# Why XGBoost forecasts this figure for 2026: SHAP contributions (TreeSHAP) weighted like the forecast.
TOP_FEATURES_SHOWN = 10
xgb_2026 = forecast_challenger(make_bundle(leases, TARGET_YEAR), "xgboost")
roster_2026 = xgb_2026["roster"]
contributions = xgb_2026["model"].get_booster().predict(
    xgboost.DMatrix(roster_2026[MODEL_FEATURES].to_numpy(float), feature_names=MODEL_FEATURES), pred_contribs=True)
row_weights = roster_2026["share"].to_numpy() / roster_2026["unit"].nunique()
shap_2026 = (pd.DataFrame({"feature": MODEL_FEATURES,
                           "contribution_to_2026_pp": PERCENT * (contributions[:, :-1] * row_weights[:, None]).sum(axis=0),
                           "mean_abs_pp": PERCENT * np.abs(contributions[:, :-1]).mean(axis=0)})
             .sort_values("mean_abs_pp", ascending=False).reset_index(drop=True))
display(shap_2026.round(3))
top_features = shap_2026.head(TOP_FEATURES_SHOWN).iloc[::-1]
plt.figure(figsize=(8, 4))
plt.barh(top_features.feature, top_features.contribution_to_2026_pp)
plt.title("XGBoost 2026: mean SHAP contribution by variable (points)")
plt.xlabel("percentage points")
plt.tight_layout()
plt.show()
print(f"2026 — XGBoost: {PERCENT * xgb_2026['portfolio']:.2f}% | our method: {estimate_pct:.2f}%")
feature contribution_to_2026_pp mean_abs_pp
0 prior_gap 2.405 3.427
1 lag_port_gap -0.464 0.463
2 lag_cell_face 0.454 0.454
3 lag_port_face -0.354 0.362
4 property_age 0.036 0.312
5 lag_cell_gap -0.275 0.280
6 rent_position 0.079 0.267
7 start_month -0.012 0.247
8 interval_years 0.145 0.226
9 is_renewal 0.014 0.164
10 lag_cell_eff 0.121 0.120
11 is_ontario -0.026 0.059
12 bedrooms -0.002 0.036
2026 — XGBoost: 5.15% | our method: 4.80%

Reading. The tables apply the declared rule mechanically. Neither model replaces our method. XGBoost does slightly better over 2020–2025, but worse on the required 2023–2025 window and on its worst year; Ridge does worse everywhere. With about 3,000 pairs and six years of different regimes, a more flexible model mostly learns the noise of years already seen. SHAP shows that XGBoost relies first on the concession gap of the previous lease: a unit leased with a large concession "recovers" part of that concession on the next lease. This is the mechanism that our BRIDGE component models explicitly, unit by unit. The rejected result stays displayed, because it says what the data do not support.

Details

Other avenues were evaluated in the same way and rejected: public variables (vacancy, supply, population, unemployment, interest rates) added to these models, the asking-rent signal, renewal propensity, a damped trend in concessions, a Monte Carlo simulation of the portfolio, and TAL pass-through. Their code is in the optional Python modules attached (challenger.py, behavior.py, simulation.py, drivers.py), which are not needed to run this notebook.

10. Beyond the brief: what we built in additionThe brief makes the application optional. We deliver an optional prototype (04Prototypefacultatifcockpitsimulation.zip: cockpit and simulation)

10. Beyond the brief: what we built in addition

The brief makes the application optional. We deliver an optional prototype (04_Prototype_facultatif_cockpit_simulation.zip: cockpit and simulation) and a one-minute film with the presentation (03_Film_presentation_1min.mp4). Neither is needed to assess this notebook, and neither contains any CRM data: they use only the building-level aggregate results presented here. Click each line with a triangle to see a screenshot.

1. Pricing cockpit (cockpit/index.html in the prototype, opens with a double-click): the portfolio map with each building's forecast, a renewal simulator (rent, term, free months, TAL or Ontario regime) and the calendar of leases expiring in 2026.

See the screenshot: portfolio map and forecast by building

Pricing cockpit

2. Resident simulation (simulation/index.html in the prototype): 150 fictional residents, generated by AI, react to market shocks (lower immigration, a wave of new towers, a new REM station). This is a demonstration of mechanisms, not a forecast: the tested forecast remains 4.8%, and the page displays it permanently.

See the screenshot: residents reacting to an immigration cut

Resident simulation

3. One-minute presentation film (03_Film_presentation_1min.mp4): the approach, from the median trap to 4.8%.

See a still from the film

Film still

11. Assumptions, limitations, and how to improve without adding noise1. The next lease of a roster unit starts the day after the current lease ends (vacancy and early departures are unmodelled risks).

11. Assumptions, limitations, and how to improve without adding noise

Stated assumptions.

  1. The next lease of a roster unit starts the day after the current lease ends (vacancy and early departures are unmodelled risks).
  2. The 2026 renewal share resembles that of each building's last three years, shrunk toward its province.
  3. The concession gap of 2026 new leases resembles that of 2025 (BRIDGE); the ±2 point scenarios show the sensitivity.
  4. The parameters (ForecastSpec) are inherited from an earlier revision and were not optimised on 2023–2025.
  5. sRentEffective is exact as supplied; the concession ledger is used to check it, not to replace it.
Details

Limitations. Same unit does not guarantee constant quality after a renovation. The current extract does not prove that past values were never corrected: the reconstruction at the origins is a simulation. Six backtest years and a regime change in 2024–2025 make any fine precision illusory.

To improve: keep real dated snapshots; time-stamp offers and concessions; add occupancy, vacancy, renovation status and The Met's first occupancy (to know whether the Ontario guideline applies); test one extension at a time, with a rule fixed in advance, and confirm it on years not yet seen (2026).

References

Public data (exact values, periods, release dates and links in external_context.csv and external_postorigin_2026.csv):

  • CMHC — Rental Market Survey, Montréal and Ottawa CMAs: https://www.cmhc-schl.gc.ca
  • Statistics Canada — CPI, rent component, Quebec and Ontario (table 18-10-0004-01): https://www150.statcan.gc.ca
  • Tribunal administratif du logement — annual rent increase calculation: https://www.tal.gouv.qc.ca
  • Ontario — rent increase guideline: https://www.ontario.ca/page/rent-increase-guideline
Details

Method: scikit-learn, Common pitfalls and recommended practices (data leakage and selection): https://scikit-learn.org/stable/common_pitfalls.html · Hyndman & Athanasopoulos, Forecasting: Principles and Practice, time-series cross-validation: https://otexts.com/fpp3/tscv.html · Lundberg et al., TreeSHAP, via XGBoost's pred_contribs: https://xgboost.readthedocs.io

AI tools used (permitted by the brief, cited here): Codex (OpenAI) helped audit the components, correct the original protocol and prepare the code and tests. Claude Code (Anthropic; Claude Opus 5.5 and Sonnet 5.5 models) helped verify the public sources, write the analyses of sections 5c, 7.6, 8 and 9, and reorganise this notebook around the starter. Each public value was re-verified against its official source, and each computation is covered by a test. The team remains responsible for the choices, their validation and the presentation.