1. Probleem
Het Nederlandse woningwaarderingsstelsel (WWS, Beleidsboek 2026) bepaalt het wettelijke maximum huurbedrag op basis van 11 rubrieken (1 t/m 11) plus rubriek 3b Koeling (toegevoegd 1-7-2024):
| Rubriek | Inhoud | Hoofd-input |
|---|---|---|
| 1 | Oppervlakte vertrekken | m² woonkamer + slaapkamers |
| 2 | Oppervlakte overige ruimten | m² hal/keuken/berging |
| 3 | Verwarming | type (cv/stadsverwarming/warmtepomp), aantal verwarmde vertrekken, warmteterugwinning |
| 3b | Koeling (sinds 1-7-2024) | airco per vertrek (max 2 pt totaal) |
| 4 | Sanitair | aantal badkamers, toiletten, bad/douche/wastafels, bidet, urinoir, vloerverwarming |
| 5 | Energieprestatie | energielabel (onbekend → per MC-iteratie gesampled uit P(label | bouwjaar, type), zie §10) |
| 6 | Buitenruimte | tuin / balkon / dakterras + afmetingen |
| 7 | Berging | aanwezig ja/nee, type (privé/gedeeld) |
| 8 | WOZ-waarde | Kadaster-waarde en per-m² vergelijking |
| 9 | Gemeenschappelijke voorzieningen | gedeelde fietsenstalling, gemeenschappelijke tuin, wasruimte, etc. |
| 10 | Parkeergelegenheid (sinds 1-7-2024) | afgesloten garage (9 pt), overdekt/carport (6 pt), open parkeerplaats (4 pt), + exclusieve laadpaal (+2 pt) |
| 11 | Bijzondere voorzieningen | lift, alarm, video deurbel, EV-laadpunt, woonvoorzieningen gehandicapten |
| (geen rubriek) Monument-/nieuwbouw-opslagen | Rent surcharge ná punten→huur conversie. Rijksmonument: +35% op max huur (door ons gemodelleerd via RCE LOD). Niet gemodelleerd: gemeentelijk/provinciaal (+15%), nieuwbouw na 1-7-2024 (+10%), beschermd stadsgezicht pre-1965 (+5%), zorgwoning (+35% op puntensubtotaal van rubrieken 1-11) | |
In onze parsed representatie bestaan deze 11 rubrieken uit ~36 sub-features- rubriek 4 Sanitair alleen al heeft 10: aantal badkamers, extra toiletten, bad, douche, inloopdouche, wastafels, dubbele wastafel, vloerverwarming bad, bidet, urinoir. Een precieze item-voor-item mapping van WWS-rubrieken naar onze features (we dekken ~25–26 van de 36 items) staat op de data-transparantie pagina.
Het probleem: van een typische huurwoning op WoonBusters kennen we maar een handvol van deze inputs uit de advertentie zelf - oppervlakte, energielabel, tuin, balkon, soms aantal slaapkamers en lift. De overige features - vooral sanitair-details, verwarmingstype en berging - zijn vrijwel nooit beschikbaar. Maar het WWS vereist ze allemaal om tot een exact puntentotaal te komen.
Onze aanpak: bereken een waarschijnlijkheidsverdeling over het puntentotaal door gebruik te maken van empirische kansverdelingen voor de onbekende kenmerken, gebaseerd op echte data van fysiek vergelijkbare woningen. Dat geeft een 90% betrouwbaarheidsinterval op de maximale huur - geen alles-of-niets oordeel.
Wat zegt "X% betaalt te veel" eigenlijk?
Per peildatum 2026-04-29: twee aparte cijfers, niet één. We rapporteren WWS en WWSO altijd los van elkaar omdat ze fundamenteel verschillende regimes zijn. Een blended cijfer (57,7%) is mechanisch opwaarts vertekend door WWSO en hoort niet als losse headline gebruikt te worden.
| Segment | n (Tier 1) | % te duur | 95% CI |
|---|---|---|---|
| WWS - zelfstandige woonruimte (appartementen, studio's) op exact-adres Kadaster | 16.496 | 50,2% | 49,4-51,0% |
| WWSO - onzelfstandige woonruimte (kamers) via 3-tier COROP | 6.118 | 77,9% | 76,9-79,0% |
| (afgeleid) Tier 1 blend | 22.614 | 57,7% | 57,0-58,3% |
De zelfstandige-markt-headline is 50,2% te duur op 16.496 appartementen + studio's. Dat is het cijfer dat het reguliere huurmarkt-debat karakteriseert. WWSO (kamers) zit mechanisch hoger omdat onzelfstandige woonruimte geen vrije-sectorgrens heeft - er is altijd een legaal maximum, dus elke kamer boven die grens telt automatisch als "te duur".
Wat doen we met listings zonder geverifieerd adres?
Van de ~52.800 gescraapte listings hebben er ~31.400 geen exact adres in een vorm waar we Kadaster mee kunnen bevragen. We splitsen die in drie categorieën, en voor alle drie tonen we geen verdict:
- Lat/lon-only (~15.000): hebben coördinaten maar geen adres-tekst (typisch HousingAnywhere, Vesteda, een deel van Kamernet en VBT). De coördinaten wijzen het juiste gebouw aan (mediane afwijking <3m), maar in een appartementencomplex met meerdere units kan reverse-geocoding niet betrouwbaar de juiste woning kiezen. Het verschil tussen de WOZ van de geraden unit en de werkelijke unit heeft een mediane fout van 5.6%, een 90e-percentielfout van 59% en incidentele uitschieters tot >200%. We hebben getest of gebouw-mediaan aggregeren materieel beter is - dat blijkt niet zo (mediaan zelfs iets slechter omdat WOZ binnen één gebouw fundamenteel heterogeen is: penthouse vs. studio op de begane grond). Conclusie: zonder unit-niveau identificatie is een eerlijke huurcheck niet mogelijk.
- Tier 2 (~13.900, met CBS wijk-code): getest of CBS-wijk WOZ × hedonische correctie materieel beter is dan stad × m². Verdict: nee, beide hebben mediane fout van ~36%. WOZ varieert te sterk binnen één wijk.
- Tier 3 (~2.500, alleen postcode): te weinig data om eerlijk te scoren.
Plus filteren we op contracttype/property_type voor de drie categorieën die wettelijk buiten WWS/WWSO vallen (totaal 32 listings):
contract_type="short_stay"(1 listing): formeel een verblijfsrecht/hospitality-contract, géén huurovereenkomst.contract_type="anti_squat"ofproperty_type LIKE "%anti-kraak%"(31 listings): bruikleenovereenkomst onder leegstandsbeheer - geen huurregime.contract_term_months ≤ 6(effectief 0 listings extra na bovenstaande filters; veld is voor maar 0,13% van Tier 1 geparseerd).
Expat-listings vallen ook gewoon onder WWS - er is in de Nederlandse huurwet geen uitzondering op basis van nationaliteit; alleen contractsoort telt.
Eerlijke beperking:
contract_term_months is slechts voor 0,13% van Tier 1 geparseerd. Een onbekend klein deel van Tier 1 zou juridisch alsnog short-stay kunnen zijn waar de bron geen expliciete tag heeft meegegeven. Zonder LLM-re-extractie van advertentietekst kunnen we die leak niet dichten.Voor de live /huurcheck tool is dit géén beperking - gebruikers vullen daar hun eigen adres in waardoor we altijd Tier 1 data hebben. De beperking geldt alleen voor de listings-page badges en de gepubliceerde populatiestatistieken.
2. Data
2.1 Trainingsdata: Funda ground truth
Om te weten welke feature-combinaties bij welk type woning voorkomen, hebben we een trainingsdataset nodig. Funda is daarvoor uitermate geschikt omdat het:
- Diepgaande detailpagina's heeft met gestructureerde
characteristics(Badkamervoorzieningen, Verwarming, Isolatie, Tuin, etc.) - Nationale coverage heeft - niet beperkt tot één makelaar
- Via pyfunda (Python library die Funda's mobile API reverse-engineered) stabiel en bulk te scrapen is
Dataset (peildatum 2026-04-26): 2.850 huurlistings + 9.217 koop-listings = 12.162 Funda-rijen in totaal, uit 30+ steden. De set blijft groeien - we draaien periodiek nieuwe pyfunda-runs en UPSERT op global_id, dus elke run voegt nieuwe advertenties toe en ververst bestaande. Voor de relevantie van de koop-data voor het schatten van een huur-prijs zie §2.5.
| Stad | Listings |
|---|---|
| Amsterdam | 828 |
| Den Haag | 371 |
| Rotterdam | 289 |
| Almere | 155 |
| Leiden | 130 |
| Utrecht | 123 |
| Eindhoven | 100 |
| Amstelveen | 76 |
| Haarlem | 73 |
| Overig (20+ steden) | 390 |
Opgeslagen in PostgreSQL-tabel ground_truth_funda (68 kolommen), waaronder global_id, url, title, address, price, living_area, construction_year, energy_label, characteristics (JSONB) en raw_data.
2.2 Feature parsing
Funda's characteristics is een losse dict van vrije-tekst velden. Onze parser (scraper/funda_characteristics_parser.py) extraheert hier 22 sampleable features uit waar de Probabilistische sampler direct uit trekt, plus een aantal afgeleide features (isolatie-score, parkeer-type) die we als hard condition gebruiken.
De volledige lijst van 22 sampleable features (de empirische verdelingen die in priors_v1.json worden opgeslagen):
- Badkamervoorzieningen (10) →
num_bathrooms,num_extra_toilets,has_bath,has_shower,has_walk_in_shower,num_sinks,has_dual_sink,has_underfloor_heating_bath,has_bidet,has_urinal - Verwarming + Warm water (2) →
heating_points_per_room(1.5 of 2.0),has_heat_recovery - Energie (1) →
energy_label(gebruikt als soft condition voor verwarming/isolatie-cohort) - Tuin/balkon (4) →
outdoor_type,has_tuin,has_balkon,has_dakterras - Schuur/berging (2) →
has_storage,storage_type - Voorzieningen (3) →
has_lift,has_private_parking,has_parking_garage
Daarnaast extraheert de parser ook isolatiekenmerken (has_double_glazing, has_hr_glass, is_fully_insulated) en tuin_m2 als afgeleide gegevens; die gebruiken we als deterministische input of laten we vallen waar Funda ze te zelden vult (tuin_m2 komt voor in <5% van de rows).
2.3 Kwaliteitsfilter
Niet alle Funda-rows zijn bruikbaar als training. We vereisen:
living_areaenconstruction_yeargevuld- Badkamervoorzieningen-veld aanwezig (anders zijn alle bathroom-features
Noneen zijn ze niet echt "False" maar "onbekend") - Verwarming-veld aanwezig (zelfde reden)
- Geen Studentenkamer. Funda heeft géén eigen top-level "Studentenkamer" categorie, maar enkele listings dragen dit label in het
Soort appartementveld; die vallen onder WWSO en filteren we uit de WWS-trainingsset (huidige run: 2 rows uitgesloten).
Na filtering: 1.116 huur-trainingsrows (van 2.850 ruwe huurlistings). De ratio (~44%) wordt bepaald door hoe vaak verhuurders het Badkamervoorzieningen- en Verwarming-blok invullen op Funda. Plus daarbij komen nog 8.243 koop-trainingsrows (zie §2.5 voor waarom we koop-data poolen) → eindgebruik 9.359 trainingsrows in totaal, waarvan een deel feature-specifiek wordt gefilterd op offering_type (rent-only voor 9 features met persistente selectie-bias, rent+sale voor de overige).
2.4 Kamernet faciliteit-data (voor WWSO)
Voor kamers hebben we aanvullend nodig: is de keuken gedeeld of privé? De douche? Het toilet? En met hoeveel huisgenoten? Kamernet's mobile-API geeft deze informatie expliciet via kitchenId, showerId, toiletId, housematesNumberId.
Opgeslagen in tabel kamernet_facilities. Na backfill: 2.624 rows met geverifieerde faciliteit-data (peildatum 26-04-2026).
2.5 Rent vs. Sale: zijn de datasets poolbaar?
Naast huurlistings hebben we ook 9.217 koop-listings uit Funda (peildatum 26-04-2026). Een logische vraag: kunnen we die koop-data direct meenemen in de priors voor onze huurcheck? De fysieke kenmerken (sanitair, verwarming, isolatie) hangen niet af van of een woning verhuurd of verkocht wordt - een bad is een bad. Maar er is selectiebias: koop-woningen zijn voornamelijk eengezinswoningen, huur-woningen voornamelijk appartementen.
Om dit te kwantificeren hebben we voor elk van de 22 sampleable features een per-feature statistische vergelijking gedraaid (chi-squared voor booleans en categorieën, Kolmogorov-Smirnov voor numeriek), met als oordelen:
- similar = p > 0.05 (geen statistisch significant verschil)
- moderate = 0.01 < p ≤ 0.05
- different = p ≤ 0.01 (substantieel afwijkend)
Aggregaat: rent vs. sale (alle dwelling types)
Cohort: 20–200 m², bouwjaar 1900–2026. n_rent = 2.470, n_sale = 7.871.
| Verdict | Aantal features |
|---|---|
| similar | 7 |
| moderate | 2 |
| different | 13 |
Op het eerste gezicht ziet het er slecht uit: 13 van de 22 features verschillen significant. Maar de grootste gaten zijn één-op-één een afspiegeling van het dwelling-type-verschil:
| Feature | rent | sale | verklaring |
|---|---|---|---|
has_lift | 42% | 13% | huur is appartement, koop is huis |
has_parking_garage | 32% | 8% | idem |
has_tuin | 72% | 92% | idem (huis = tuin) |
has_private_parking | 12% | 29% | idem |
Sample-compositie: rent is 88% appartement / 12% huis; sale is 44% appartement / 56% huis. Het echte antwoord vereist een like-for-like vergelijking binnen dwelling type.
Within-type: alleen appartementen
Cohort: appartementen, 20–200 m², 1900–2026. n_rent = 2.195, n_sale = 3.508.
| Verdict | Aantal features |
|---|---|
| similar | 9 |
| moderate | 2 |
| different | 11 |
Beter, maar nog steeds 11 features verschillen. Deze gaten zijn echte selectie-bias: rent-appartementen zijn nieuwer / vaker gerenoveerd, met meer luxe-voorzieningen.
| Feature | rent | sale | Δ |
|---|---|---|---|
has_lift | 47% | 31% | +16 pt |
has_parking_garage | 36% | 16% | +19 pt |
has_dual_sink | 29% | 14% | +15 pt |
has_bath | 35% | 24% | +12 pt |
has_walk_in_shower | 44% | 39% | +5 pt |
Within-type: alleen huizen
Cohort: eengezinswoningen, 20–200 m², 1900–2026. n_rent = 275, n_sale = 4.363.
| Verdict | Aantal features |
|---|---|
| similar | 15 |
| moderate | 2 |
| different | 5 |
Veel beter: 15 van de 22 features statistisch gelijk. De vijf afwijkingen zijn praktisch verwaarloosbaar (has_lift 2% vs 0% - huizen hebben gewoon geen lift) of betreffen parkeer-features die alsnog enige selectiebias hebben.
Liggen de verschillen aan oppervlakte of bouwjaar?
Rent en sale verschillen mogelijk niet alleen op dwelling type, maar ook op oppervlakte en bouwjaar - bijvoorbeeld als rental investors systematisch nieuwere appartementen targeten. We hebben gemiddelde en mediane waarden vergeleken:
| Cohort | n | area med | year med |
|---|---|---|---|
| RENT-Apartment | 2.370 | 82 m² | 2008 |
| SALE-Apartment | 3.898 | 81 m² | 1980 |
| RENT-House | 285 | 122 m² | 1995 |
| SALE-House | 4.538 | 124 m² | 1979 |
Oppervlakte is bijna identiek, maar bouwjaar verschilt dramatisch: rent-appartementen zijn gemiddeld 28 jaar nieuwer dan sale-appartementen. Dat verklaart een groot deel van de selectie-bias: rental investors kopen / bouwen nieuwere complexen voor de verhuurmarkt; sale-listings bestrijken de hele woningvoorraad terug tot 1900.
Test: appartementen-only én bouwjaar 2000+. n_rent = 1.375, n_sale = 1.073:
| Verdict | Aantal features |
|---|---|
| similar | 13 (was 9) |
| moderate | 2 |
| different | 7 (was 11) |
Door ook op bouwjaar te matchen convergeren de sanitair-features (bad, inloopdouche, vloerverwarming, warmteterugwinning) van "different" naar "similar". De resterende verschillen zitten bijna allemaal in parkeer- en lift-features:
| Feature | rent (2000+) | sale (2000+) |
|---|---|---|
has_lift | 56% | 48% |
has_parking_garage | 57% | 41% |
has_private_parking | 12% | 25% |
has_balkon | 90% | 80% |
Dit is echte selectiebias die niet weggaat met fijner cohort-matchen: rental investors kiezen specifiek modernere appartementenblokken met garages en liften. Sale-data van "dezelfde periode" is daarom nog steeds geen zuivere vervanging voor rent-data op deze features.
Conclusie en gevolgen voor de priors
Onze aanpak: een per-feature-policy. Elke training-row wordt getagd met zijn offering_type (rent of buy). Bij het sampelen van een feature bepaalt een whitelist of we uit beide pools mogen trekken of alleen uit rent:
| Feature | Sample-pool | Reden |
|---|---|---|
has_lift, has_parking_garage, has_private_parking, has_balkon | rent only | persistente selectiebias na cohort-matchen |
has_dual_sink, outdoor_type, storage_type | rent only | verschillen blijven significant na year-matching |
| Alle 15 overige features (sanitair, verwarming, energie, tuin, dakterras, num_*) | rent + sale | na cohort-matchen statistisch gelijk |
| Featuregroep | Pool | Aantal rows | Vergroot effectieve cohort |
|---|---|---|---|
| 9 rent-only features (lift, parkeren, balkon, dual_sink, walk_in_shower, underfloor_heating_bath, outdoor_type, storage_type) | rent only | 1.116 | +13% |
| 13 mixable features (sanitair-rest, verwarming, energie, num_*, tuin, dakterras, has_storage, has_heat_recovery) | rent + sale | 9.359 | 8.4× t.o.v. rent-only |
De per-feature filter wordt toegepast bij elke Monte Carlo iteratie in sample_feature() in api/huurcheck/priors.py. Voor de rent-only features blijft het effectieve cohort dus klein (1.116 rent rows verdeeld over alle dwelling/area/jaar combinaties); voor de mixable features krijgen we een echte cohort-uitbreiding.
Sale-listings hebben overigens een hogere kwaliteit op de WWS-relevante velden: 89% van de sale-rows passeert het Badkamervoorzieningen + Verwarming kwaliteitsfilter, vs 39% voor rent. Eigenaren vullen op Funda meer detail in dan verhuurders. Deze hoge invul-graad maakt sale-data extra waardevol om sparse cohorten in de rent-set aan te vullen op de features waar het veilig is.
De rauwe vergelijkingsdata staan in docs/comparisons/rent_vs_sale_*.json in onze repo. We draaien de vergelijking opnieuw bij elke materiele uitbreiding van de sale-set zodat we kunnen monitoren of de balans verschuift.
3. Probabilistische inferentie
3.1 Basisformule
De WWS-puntenberekening is additief: elke rubriek draagt los punten bij, het totaal is de som, en de maximale huur is een deterministische functie van het totaal.
totaal_punten = oppervlakte + verwarming + sanitair + energie + buitenruimte
+ berging + WOZ + lift + monument_bonus
max_huur = WWS_tabel(totaal_punten)We kunnen de berekening splitsen in een deterministisch deel (wat we exact weten) en een gesampled deel (wat we uit priors moeten trekken).
3.2 Wat we deterministisch berekenen
| Rubriek | Input | Punten |
|---|---|---|
| 1–2 Oppervlakte | area_m2 uit LLM | area_m2 × 1.0 |
| 5 Energie | energy_label (EP-online voor 91% van Tier 1; voor de overige 9% sampelen we per Monte Carlo iteratie uit de empirische verdeling P(label | bouwjaar, dwelling_type) - zie §10) | WWS-tabel lookup |
| 6 Buitenruimte | has_garden, has_balcony | Functie van bekende status |
| 8 WOZ | Kadaster lookup op exact adres (postcode + huisnummer). Listings zonder huisnummer krijgen géén verdict - er is geen fallback. | WOZ-formule |
| (rent surcharge, geen rubriek) | Rijksmonument via RCE LOD lookup (BAG-id koppeling) | +35% op maximale huur ná punten→huur conversie |
Deze 5 rubrieken zijn identiek in elke Monte Carlo iteratie. Geen sampling.
3.3 Wat we gesampled uit priors
| Rubriek | Onbekend | Sampled features |
|---|---|---|
| 3 Verwarming | CV vs stadsverwarming vs warmtepomp | heating_points_per_room |
| 4 Sanitair | Bad? Aantal wastafels? | 8 features (zie §2.2) |
| 7 Berging | Privé of gedeeld? | has_storage, storage_type |
| 11 Bijzondere voorzieningen | Lift aanwezig? | has_lift |
Bij WWSO komt daar nog bij: keuken gedeeld/privé, douche gedeeld/privé, toilet gedeeld/privé, aantal huisgenoten (voor delingsfactor).
3.4 De empirische priors (kernel-bootstrap, v6)
Voor elke sampleable feature bouwen we een empirische verdeling op uit trainingsdata. De kernvraag: "Van huurwoningen die fysiek lijken op dit aanbod, hoe vaak komt feature X voor?"
Sinds v6: we gebruiken een Gaussian-kernel weighting in plaats van discrete cohort-windows. Elke training-row krijgt een continu gewicht op basis van log-area en bouwjaar afstand tot het target:
weight = exp(-0.5 × ((log(area / target_area) / 0.20)²
+ ((year - target_year) / 15)²))Bandwidth defaults h_log_area = 0.20 (1σ ≈ ±22% area) en h_year = 15. Een row ver weg krijgt verwaarloosbaar gewicht; een row dichtbij krijgt bijna 1. We trekken vervolgens met random.choices gewogen uit de pool.
Effective sample size (Kish 1965) meet hoeveel "effectieve" rows er in de pool zitten: ESS = (Σw)² / Σ(w²). Bij ESS ≥ 25 sampelen we direct. Bij sparser cohort verbreden we de bandwidth gladsmooth: 1.6× → 2.5× → 4×. Pas daarna laten we soft of hard conditions vallen.
Voorbeeld: voor een 55 m² woning uit 1965 krijgt een 50 m² 1960 row gewicht ≈ 0.96, een 80 m² 1970 row gewicht ≈ 0.27, een 200 m² 1990 row gewicht ≈ 0.001.
3.5 Conditionele sampling
Een WoonBusters-listing kan gedeeltelijke info hebben die de prior moet respecteren:
- Hard conditions (feit over de woning, altijd toepassen):
has_garden,has_balcony,has_lift,has_parking_garage,shared_kitchen,shared_shower,shared_toilet. - Soft conditions (proxy, mag wegvallen bij sparse data):
energy_label.
De sampler filtert het cohort eerst met alle hard+soft conditions. Als er < 25 rows overblijven, laat hij één soft condition tegelijk vallen (greedy: de meest restrictieve eerst). Hard conditions blijven altijd staan.
3.6 Per-feature onafhankelijke sampling
We trekken elke feature onafhankelijk uit zijn eigen subset van rows waar die feature bekend is (niet None).
3.7 MNAR-handling
Sommige features zijn "presence features" - Funda vult het veld alleen in als het kenmerk aanwezig is. has_tuin is True als Funda "Tuin" vermeldt, anders None (niet False). Maar in werkelijkheid betekent het ontbreken van het Tuin-veld meestal dat er geen tuin is.
Oplossing: bij fit-tijd zetten we None → False voor deze 7 presence features: has_tuin, has_balkon, has_dakterras, has_storage, has_lift, has_private_parking, has_parking_garage.
3.8 Monte Carlo
Input: area_m2, price, energy_label, build_year, city, has_garden,
has_balcony, num_bedrooms, property_type
Stap 1 - bepaal cohort
find Funda rows met area±25%, year±25, same type
Stap 2 - bereken deterministische punten
area_pts = area_m2 × 1.0
energy_pts = WWS_tabel[energy_label]
outdoor_pts = 3.0 if has_balcony else 0 + 3.25 if has_garden else 0
woz_pts = WOZ_formule(area_m2, city)
deterministic_total = area_pts + energy_pts + outdoor_pts + woz_pts
Stap 3 - Monte Carlo loop (N=10.000 iteraties):
for i in 1..N:
heating_pts_i = sample(heating_points_per_room) × num_rooms
sanitair_pts_i = som van 8 gesamplede sanitair features
storage_pts_i = sample(has_storage) × sample(storage_type)
lift_pts_i = sample(has_lift) × 1.0
total_pts_i = deterministic_total + heating_pts_i + sanitair_pts_i +
storage_pts_i + lift_pts_i
max_rent_i = WWS_tabel(total_pts_i)
Stap 4 - aggregeer:
median_pts = mediaan van total_pts_1..N
p5_raw, p95_raw = 5% en 95% percentielen
# Schaalfactor k=1.15 (zie §4, gecalibreerd tegen deterministisch oracle):
p5 = median - 1.15 × (median - p5_raw)
p95 = median + 1.15 × (p95_raw - median)
P(overpaying) = fractie van iteraties waar price > max_rent_i
verdict =
'free_market' als >50% van iteraties ≥187 punten
'overpaying' als P(overpaying) > 0.7
'uncertain' als 0.3 < P(overpaying) ≤ 0.7
'fair' anders4. Calibratie: betrouwbaarheidsinterval
Monte Carlo-sampling geeft van nature intervallen, maar die zijn empirisch te smal: onze "90% CI" bevat de waarheid maar in ~70% van de gevallen. Dit komt door:
- Pool tightness: bootstrap uit 18 distinct values comprimeert de staarten.
- Independent per-feature sampling: verliest joint correlaties, schuift massa naar het midden.
Calibratieprocedure
We doen een 60/20/20 train/calibrate/test split op de quality-filtered Funda-rows (peildatum 2026-04-26: 9.359 rijen):
- Train priors op 60%.
- Op 20% calibratie-set: bereken voor elk listing de ratio
|true_points − median_points| / gem_half_CI, vind empirisch de scale factorkdie 90% coverage oplevert. - Test op 20% held-out data: pas
ktoe en controleer coverage.
Uitkomst calibratie (5 seeds, deterministisch oracle):
| Metric | Waarde |
|---|---|
| Raw 5/95 CI coverage (unscaled, k=1.0) | 85.2% |
Scale factor k (gecalibreerd) | 1.15 (range 1.09–1.21) |
| Scaled 90% CI coverage (calibratie-test set, 5 seeds × 400 rows) | 89.5% (range 86.5–92.5%) |
| Empirische 90% CI coverage (volledige holdout, 10 seeds × 400 rows) | 78,0% [76,6%, 79,2%] |
5. Accuratesse (cross-validation)
Holdout-test: 80/20 random split op de quality-filtered Funda-rows. Priors gefit op train-set, predict op test-set met alleen WB-listing-level info (oppervlakte, energielabel, tuin, balkon, lift, parkeer-flags). De "oracle" gebruikt dezelfde calculator maar krijgt elk parsed sub-feature als override - zodat alle deterministische bijdragen (oppervlakte, WOZ, monument) wegvallen en de MAE puur de prior-driven onzekerheid meet.
Holdout-resultaten met deterministisch oracle
10 random seeds × 400 stratified test rows = 4.000 holdout-comparisons (data: docs/comparisons/robustness_v6.json). Bootstrap 95% CI berekend over de 10 seed-gemiddelden. Stratificatie: random binnen offering_type (rent vs sale) en within-area-bin om te voorkomen dat één fold disproportioneel uit één segment komt.
| Metric | Mean | 95% bootstrap CI |
|---|---|---|
| MAE (punten, ondergrens-schatting) | 3,55 | [3,46; 3,64] |
| MAE in euro's (≈) | ~€26/maand | [€25; €27] |
| Bias | +0,36 pt | [+0,18; +0,53] |
| 90% CI coverage | 77,9% | [76,7%; 79,2%] |
| Regulated MAE (oracle <187 pt) | 5,48 | [5,19; 5,85] |
| Regulated bias | +1,99 pt | ±0,36 (95%) |
| Regulated 90% CI coverage | 60,9% | [57,1%; 64,8%] |
Richting van de regulated bias: +1,99 pt betekent dat we gereguleerde listings (oracle <187 pt) gemiddeld te hoog scoren - we zien ze als minder gereguleerd dan ze zijn. Dat impliceert dat de gepubliceerde 57,7% te duur op Tier 1 een ondergrens is van het werkelijke percentage; het echte cijfer ligt waarschijnlijk 1-2 pp hoger (rond 60%). Individuele predicties kunnen ±10 pt afwijken; de systematische bias is statistisch significant maar materieel klein.
Root-cause analyse van regulated bias
We hebben drie hypotheses getest om te begrijpen waarom regulated listings überhaupt een bias hebben (n=823, dezelfde holdout):
| Hypothese | Test | Resultaat |
|---|---|---|
| Cohort-imbalance (93% Funda is vrije sector) | Restricteer cohort tot regulated-only training rows | Bias gaat van +1.99 → −2.44 (slaat door naar onderkant). Niet de oorzaak. |
| Eén dominante feature drijft de bias | Bias-decompositie per feature | Geen feature met >0.4 pt bijdrage. Bias accumuleert over kleine effecten. |
| Hoge variantie maskeert kleine systematische component | stdev 5.32 op 823 rows = SE ~0.18 | +1.99 ± 0.36 (95%) - significant maar klein. Variantie is het echte verhaal. |
Conclusie: er is geen makkelijke fix voor de bias zonder structurele verandering aan de calculator. Voor de gepubliceerde headline (57.7% op Tier 1) kan dit betekenen dat het echte cijfer ~1-2 pp hoger ligt, maar we kunnen het niet exact corrigeren. We rapporteren daarom het "onbevooroordeelde" cijfer en vermelden deze nuance expliciet.
Reliability diagram (calibratie per CI-niveau)
Standaard statistische toets: bij elk nominaal CI-niveau, hoeveel van de waarheden valt binnen het voorspelde interval? Idealiter 50% nominaal = 50% empirisch. Resultaat (n=4.000):
| Nominal CI | Empirical | 95% CI |
|---|---|---|
| 50% | 54.4% | [52.9%, 56.0%] |
| 70% | 66.0% | [64.5%, 67.4%] |
| 80% | 71.9% | [70.5%, 73.3%] |
| 90% | 78.0% | [76.6%, 79.2%] |
| 95% | 79.7% | [78.4%, 80.9%] |
Conclusie: bij 50% is de calibratie correct (54%), maar bij hogere niveaus convergeert de empirische dichtheid bij ~80%. De CI's zijn niet breed genoeg in de staarten. Dit suggereert dat het Monte Carlo sampling de echte verdeling niet helemaal vangt voor outliers - wat een logische beperking is van bootstrap-sampling binnen een eindige cohort.
Bias per cohort - waar zit de afwijking?
De gemiddelde bias op de full-WWS holdout is +0.36 pt (zie tabel boven), maar er zit heterogeniteit onder. Onderstaande sub-cohort-uitsplitsing komt uit een eerdere kleinere holdout-run (n=499, deterministisch oracle, vooral bedoeld om te zien waar de bias zich concentreert; voor de productie-MAE-cijfers zie de tabel hierboven). Bron: docs/comparisons/holdout_extras.json.
| Cohort | n | Bias | MAE | CI cov |
|---|---|---|---|---|
| Pre-1945 buildings | 157 | −1.01 | 2.58 | 87.9% |
| 1945-1969 * | 57 | −1.02 | 2.05 | 86.0% |
| 1970-1989 * | 82 | −0.07 | 1.71 | 93.9% |
| 1990-2009 | 106 | −0.85 | 2.07 | 89.6% |
| 2010+ * | 97 | −0.37 | 1.95 | 91.8% |
| Eengezins (huizen) | 171 | −0.97 | 2.52 | 87.7% |
| Meergezins (appt) | 328 | −0.56 | 1.95 | 90.9% |
| Regulated (<187 pt) * | 37 | +0.82 | 1.26 | 94.6% |
| Free sector | 462 | −0.82 | 2.22 | 89.4% |
* Cellen met n < 100: bias/MAE puntschattingen - bootstrap-CI niet gerapporteerd vanwege beperkte sample size. De Regulated-cel met n=37 in het bijzonder is beperkt informatief; de productie-headline blijft de n=4.000 holdout-bias +1,99 (zie tabel boven). Deze sub-cohort tabel is bedoeld om waar de bias zich concentreert te tonen, niet om absolute getallen vast te leggen.
(1) Vooroorlogse en oude woningen (pre-1969) zijn onderschat met ongeveer 1 punt - sparser cohort, meer spreiding in werkelijke kenmerken.
(2) De bias flipt teken aan de gereguleerd/vrije-sector grens: gereguleerde woningen worden lichtjes overschat (+0.82 pt), vrije-sector woningen onderschat (−0.82 pt). Dit is regression-to-the-mean: de cohort-bootstrap trekt extreme woningen naar het midden. Voor het gereguleerde segment - waar de huurder echt iets aan heeft - is de MAE wel uitstekend (1.26 punten, coverage 94.6%).
Binaire classificatie: gereguleerd vs vrije sector
Op de full-WWS holdout (n=4.000) is de binaire classificatie (predicted <187 pt = gereguleerd) ~94% correct tegen het deterministische oracle. False-negatives (true gereguleerd gemarkeerd als vrije sector) zijn schadelijker voor huurders dan false-positives en concentreren zich aan de bovenrand van het regulated segment (180-186 pt) waar de MC-spreiding rond 187 ligt. Bron: docs/comparisons/robustness_v6.json.
6. WWSO (onzelfstandige woonruimte)
Voor kamers geldt het Woningwaarderingsstelsel Onzelfstandige Woonruimte - een compleet ander puntenstelsel:
- Geen vrije-sectorgrens (kamers zijn altijd gereguleerd)
- Andere puntwaarden
- Gedeelde keuken/sanitair telt
÷ aantal_huurders - WOZ via 3-tier COROP-vergelijking (10/12/14 punten)
- Onbekend energielabel: zelfde behandeling als bij WWS - per Monte Carlo iteratie gesampled uit P(label | bouwjaar, type), zie §10
Kern-onbekendheden
| Feature | Impact op punten | Bron |
|---|---|---|
| Gedeelde keuken | 4–10 punten ÷ N | Kamernet API direct |
| Gedeelde douche | 3–6 punten ÷ N | Kamernet API direct |
| Gedeeld toilet | 3 punten ÷ N | Kamernet API direct |
| Aantal huisgenoten | delingsfactor | Kamernet API direct |
Kamernet's mobile-API (~43% van onze kamerlisting-pool) geeft al deze velden expliciet. Voor andere bronnen vallen we terug op empirische priors:
| property_type + area_bin | P(kitchen shared) | P(shower shared) | Mean housemates |
|---|---|---|---|
| Kamer <15 m² | 84% | 90% | 12 |
| Kamer 15–25 m² | 63% | 85% | 8 |
| Kamer 25–35 m² | 65% | 80% | 6 |
| Kamer 35+ m² | 21% | 50% | 3 |
| Studio (any) | 0% | 12% | 2 |
| Appartement (any) | 13% | 13% | 2 |
Deze priors zijn empirisch gefit op 2.624 Kamernet-rows met bekende faciliteit-status.
7. Routing: WWS vs WWSO
Elk listing moet naar de juiste calculator. Beslissingsregel:
if kamernet facility data says "any shared" → WWSO
if kamernet facility data says "all private" → WWS
elif property_type in {Kamer, Room} → WWSO
elif property_type in {Studio, Appartement, Woning, Flat, ...} → WWS
elif wws_is_onzelfstandig flag from pipeline → WWSO
else → WWS (default)Geïmplementeerd in api/huurcheck/route_calculator.py.
8. Datapijplijn samenvatting
1. FUNDA GROUND TRUTH (ad hoc, periodieke pyfunda runs)
pyfunda → ground_truth_funda (12.162 rows totaal)
├─ 2.850 huur (offering_type='rent')
└─ 9.217 koop (offering_type='buy')
parser → parsed_features JSONB
kwaliteitsfilter → 9.359 mixed training rows
(1.116 rent + 8.243 sale; zie §2.3 + §2.5)
2. KAMERNET FACILITIES (continu - groeit elke scrape-cyclus)
kamernet_runner.py → kamernet_facilities (2.624 rows)
Voor WWSO: keuken / douche / toilet / aantal huisgenoten
3. PRIORS (fit bij start van sessie)
PriorStore.load() vanuit ground_truth_funda
Per-feature kernel-bootstrap: rent-only voor 9 features
met selectie-bias, rent + sale voor de overige
Continue Gaussian-kernel weighting op (log-area, year)
4. INFERENCE PER LISTING
route_calculator → wws of wwso
calculate_wws_bayesian / calculate_wwso_bayesian
N=10.000 Monte Carlo iteraties, k=1.15 CI-schaalfactor
→ BayesianResult (median, p5, p95, p_overpaying, verdict)
5. BACKFILL STORAGE
bayesian_huurcheck tabel (listing_id primary key)
Upserted na elke berekening, samen met data_quality_tier9. Softwarecomponenten
| Module | Doel |
|---|---|
api/huurcheck/funda_wws.py | Deterministische features → WWS rubriek-punten |
api/huurcheck/priors.py | Empirische priors met cohort-matching |
api/huurcheck/calculator_bayesian.py | WWS Monte Carlo calculator |
api/huurcheck/calculator_bayesian_wwso.py | WWSO Monte Carlo calculator |
api/huurcheck/route_calculator.py | Routing WWS vs WWSO |
scraper/funda_characteristics_parser.py | Funda characteristics → features |
scraper/adapters/kamernet_runner.py | Kamernet facility extractie |
runners/fetch_funda_ground_truth.py | pyfunda scraper |
runners/parse_funda_characteristics.py | Parse-runner voor ground truth |
runners/backfill_kamernet_facilities.py | Kamernet detail-API backfill |
runners/run_bayesian_backfill.py | Bulk backfill van alle listings |
10. Belangrijke aannames en beperkingen
Wat we expliciet NIET modelleren
- Bouwjaar via BAG-lookup: voor het grootste deel van de Tier 1 listings hebben we het bouwjaar direct (uit listing of via BAG-koppeling); voor de rest fallback op 1975. Voor productie-niveau accuraatheid zou je
postal_code + house_number → BAG → construction_yearvolledig integreren - dat is op de roadmap. - Exacte WOZ-waarde via Kadaster: voor Tier 1 listings (n=22.614, ~40% van onze populatie) halen we de WOZ direct uit Kadaster's WOZ-waardeloket API op het exacte adres. Voor de overige listings zonder geverifieerd huisnummer geven we geen verdict - een schatting via stad × m² zou een mediane afwijking van ~36% hebben t.o.v. de werkelijke WOZ (zie §2.5 voor de tier-uitsplitsing).
- Rijksmonument-status: we koppelen elke listing aan het Rijksmonumentenregister via de RCE linked open data SPARQL endpoint (54.930 monumenten geladen, 547 listings gematcht = 1,0%, iets onder het NL gemiddelde van ~1,7% - verwacht omdat onze populatie urban-geconcentreerd is en rijksmonumenten gelijkmatiger over het land verspreid zijn). Bij match krijgt de listing de +35% rent-surcharge per Beleidsboek 2026 bijlage I.
- Energielabel-fallback (9% van Tier 1 listings, 1.941 stuks): voor 91% van Tier 1 hebben we een echt EP-online label; voor de overige 9% sampelen we per Monte Carlo iteratie een label uit de empirische verdeling
P(label | bouwjaar, dwelling_type)gefit op 10.245 Funda-rows (zieapi/huurcheck/energy_label_dist.json). Het 90% CI verbreedt daarmee automatisch voor listings zonder label - geen vals-tight interval. We hebben een deterministische bouwjaar-tabel-fallback ook getest: 30,8% exact match en −6,87 pt systematische bias. Door per iteratie te sampelen verdwijnt die bias en blijft alleen de echte epistemische onzekerheid over, weerspiegeld in de breedte van het interval. Bron:docs/comparisons/energy_label_validation.json. - Tuin-oppervlakte: Funda populeert dit veld in <5% van de rows. We gebruiken typische waarden per
outdoor_type.
Bias-waarschuwingen
- Funda is mid/high-end biased: 93% van onze Funda-sample scoort in de vrije sector. Voor gereguleerde-segment inferentie gebruiken we Funda daarom alleen als bron voor fysieke structuur (sanitair, verwarming), niet voor prijs.
- Funda filter bias: training rows moeten
Badkamervoorzieningenhebben. Dit filtert ~50% weg. Hierdoor zijn onze priors over sanitair gebaseerd op rows van verhuurders die het veld wél invulden - mogelijk iets hoger geklassificeerde woningen.
Onbekende onbekenden
- We herkennen niet als een listing "zorgwoning" is (+35% rubriek 1-11).
- We herkennen niet als een listing "nieuwbouw na 1-7-2024" is (+10% op totaal volgens Wet betaalbare huur).
- Gemeentelijk/provinciaal monument (+15% rent surcharge) en beschermd stads-/dorpsgezicht (+5%) modelleren we niet - er is geen openbaar landelijk register met BAG-id-koppeling. Alleen rijksmonumenten (+35%) krijgen we via RCE LOD.
Voor aggregate marktrapportage is dit verwaarloosbaar (<2% van de populatie is getroffen). Voor individuele huurcheck-verdicts zou dit een apart signaal moeten zijn.
11. Referenties
- Besluit huurprijzen woonruimte (Bijlage I, onderdeel A - zelfstandige)
- Besluit huurprijzen woonruimte (Bijlage I, onderdeel B - onzelfstandige)
- Beleidsboek Waardering Woonruimte 2026 (Huurcommissie)
- Wet betaalbare huur (Staatsblad 2024, 195) - introductie 187-punten grens