Snapshot April 29, 2026

Probabilistic Rent Check Methodology

Technical description of the probabilistic method WoonBusters uses to estimate the legal maximum rent (WWS/WWSO) for rental listings in the Netherlands - including a 90% confidence interval.

1. Problem

The Dutch housing valuation system (WWS, Beleidsboek 2026) sets the legal maximum rent based on 11 main categories (surface area, heating, sanitary facilities, energy performance, outdoor space, storage, WOZ value, amenities, parking, monument surcharges) plus category 3b Cooling (added 1 July 2024).

In our parsed representation these expand to ~36 sub-features (sanitary alone has 10). For a typical listing on WoonBusters we only know a handful from the ad itself - area, energy label, garden, balcony, sometimes bedroom count and lift. The rest - especially sanitary details, heating type, and storage - are almost never available. The WWS needs all of them for an exact point total.

Our approach: compute a probability distribution over the point total using empirical priors for the unknown features, conditioned on physically similar listings. That gives a 90% confidence interval on the maximum legal rent - not a worst-case/ best-case range that flips classification on a single coin-flip.

2. Data

2.1 Training data: Funda ground truth

Dataset (peildatum 2026-04-26): 12,162 Funda listings (rent + sale) from 30+ cities, scraped via the reverse-engineered mobile API (pyfunda). Stored in ground_truth_funda (68 columns). The parser extracts 22 sampleable features plus a handful of derived features used as hard conditions. Re-run periodically; UPSERT on global_id keeps it idempotent.

2.2 Quality filter

We require living_area, construction_year, the Badkamervoorzieningen field, and the Verwarming field. Rows marked "Studentenkamer" are excluded (they belong in WWSO). After filtering: 1,116 rent-only rows + 8,243 sale rows = 9,359 mixed training rows. We pool sale data because for most WWS-relevant physical features (sanitary, heating, energy) sale and rent listings of the same dwelling type and era are interchangeable; for 9 features that showed persistent selection bias we restrict sampling to rent-only.

Is 9,359 rows enough for Monte Carlo? The sampler doesn't draw joint feature-vectors over all rows. It draws empirical marginal distributions per feature within a physically similar cohort (see §3). For a typical listing that yields hundreds of rows per feature where a value is known - plenty for bootstrap sampling of categorical features. Holdout MAE remains stable around 3.55 points (deterministic-oracle benchmark) even when retraining on 60% of the dataset.

2.3 Kamernet facility data (for WWSO)

Kamernet's mobile API exposes kitchenId, showerId, toiletId, and housematesNumberId. Stored in kamernet_facilities: 2,624 rows with verified facility status (as of 2026-04-26).

3. Probabilistic inference

The WWS score is additive, so we split the calculation into a deterministic part (area, energy label, outdoor, WOZ, monument) and a sampled part (heating type, sanitary features, storage, lift).

For each listing we build a cohort of physically similar Funda rows (area ±25%, year ±25, matching dwelling type) and widen progressively if <25 rows remain. From that cohort we draw empirical per-feature distributions, conditioning on "hard" facts from the listing (garden, balcony, lift, parking, shared kitchen) and relaxing "soft" constraints (energy label) only if the conditional cohort shrinks too much.

We draw each feature independently from its own non-null subset. Joint sampling (picking entire vectors from one Funda row) was empirically rejected: the rows with every field filled skew toward luxury listings (MNAR at row level). The independence assumption costs <1% MAE.

We run N=10,000 Monte Carlo iterations, sum the deterministic and sampled points, apply the monument bonus, and look up max_rent in the WWS table. We report the median, a scaled 90% confidence interval, P(overpaying), and a verdict (fair, uncertain, overpaying, or free_market).

4. Calibration: confidence interval

Raw Monte Carlo intervals are empirically too narrow - bootstrap pool tightness and independent per-feature sampling cause the 5/95 percentile range to under-cover the truth. We calibrate by scaling the half-widths around the median by k=1.15 (range 1.09–1.21 across 5 seeds against a fully deterministic oracle), giving 89.5% empirical coverage on the test set.

5. Accuracy (cross-validation)

Holdout: 10 random seeds × 400 stratified test rows = 4,000 holdout comparisons, bootstrap CI computed across the 10 seed-means.

MetricMean95% bootstrap CI
MAE (points)3.55[3.46, 3.64]
Bias+0.36[+0.18, +0.53]
90% CI coverage77.9%[76.7%, 79.2%]
Regulated MAE (oracle <187 pts)5.48[5.19, 5.85]

6. WWSO (rooms)

Rooms fall under a different point system (no free-sector threshold; shared facilities are divided by the number of tenants; WOZ uses a 3-tier COROP comparison). When Kamernet API data is available (~43% of rooms) we know the facility split directly; otherwise we use empirical priors by property type and area bin, fit on 2,624 Kamernet rows. An unknown energy label gets the same treatment as under WWS: sampled per Monte Carlo iteration from P(label | construction_year, dwelling_type).

7. Routing: WWS vs WWSO

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, Apartment, House, Flat, ...} → WWS
elif wws_is_onzelfstandig flag from pipeline → WWSO
else → WWS (default)

Routing is on property_type, not area. A 14 m² studio is still WWS unless Kamernet explicitly reports shared facilities.

8. Data pipeline summary

1. FUNDA GROUND TRUTH (ad hoc, periodic pyfunda runs)
   pyfunda → ground_truth_funda (12,162 rows total)
                                  ├─ 2,850 rent (offering_type='rent')
                                  └─ 9,217 sale (offering_type='buy')
   parser → parsed_features JSONB
   quality filter → 9,359 mixed training rows
                    (1,116 rent + 8,243 sale; see §2.3 + §2.5)

2. KAMERNET FACILITIES (continuous, grows each scrape cycle)
   kamernet_runner.py → kamernet_facilities (2,624 rows)
   For WWSO: kitchen / shower / toilet / housemate count

3. PRIORS (fit at session start)
   PriorStore.load() from ground_truth_funda
   Per-feature kernel bootstrap: rent-only for 9 features with
   selection bias, rent + sale for the rest
   Continuous Gaussian-kernel weighting on (log-area, year)

4. INFERENCE PER LISTING
   route_calculator → wws or wwso
   calculate_wws_bayesian / calculate_wwso_bayesian
   N=10,000 Monte Carlo iterations, k=1.15 CI scale factor
   → BayesianResult (median, p5, p95, p_overpaying, verdict)

5. BACKFILL STORAGE
   bayesian_huurcheck (listing_id PK)
   Upserted after each calculation, with data_quality_tier

9. Software components

ModulePurpose
api/huurcheck/funda_wws.pyDeterministic WWS rubric scoring
api/huurcheck/priors.pyEmpirical priors with cohort matching
api/huurcheck/calculator_bayesian.pyWWS Monte Carlo calculator
api/huurcheck/calculator_bayesian_wwso.pyWWSO Monte Carlo calculator
api/huurcheck/route_calculator.pyRouting WWS vs WWSO
runners/run_bayesian_backfill.pyBulk backfill of all listings

10. Assumptions and limitations

Tier disclosure: of the ~52,800 listings we scrape, only 22,614 (~43%) have a verified house number that lets us hit Kadaster on the right unit. The remaining ~30,300 - split into lat/lon-only (14,176, building-precise but unit-ambiguous), wijk-only (13,909), and postcode-only (2,503) - receive no verdict on the listings pages and are excluded from the published population statistics. The 57.7% overpaying headline is a Tier 1 number, not a population-wide claim.

11. References

Questions or suggestions? Email [email protected].