Request access to Immovlan Flanders (research use only)

This repository is publicly accessible, but you have to accept the conditions to access its files and content.

This dataset contains photos and text from real-estate listings on immovlan.be, collected for non-commercial academic research at KU Leuven. Photos remain the property of their owners. Access is granted manually to researchers and students. By requesting access you agree to use the data only for non-commercial research or teaching, not to redistribute or re-upload it (in whole or in part), not to publish photos or listing text, and not to attempt to identify or contact any person in the data.

Log in or Sign Up to review the conditions and access this dataset content.

Private research dataset — do not redistribute. Photos and text are scraped from immovlan.be for non-commercial academic research (KU Leuven). Access is granted on request; do not re-upload, publish or use commercially. See §10.

The listings and photos_224 configs are split with the leakage-free splits_grouped.csv (§6); each row also carries split_original to reproduce older results.

Immovlan Flanders — a multi-modal residential property dataset

6,067 for-sale houses and apartments in Flanders (Belgium), scraped from immovlan.be on 8–10 March 2026. Each listing has an asking price, ~45 structured attributes, a Dutch description, and (for 5,698 listings) a photo gallery — 116,279 photos in total.

Built for the KU Leuven Computer Vision & NLP project Explainable Multi-modal Property Valuation. This README is the datasheet: what is in the data, how to load it, and what will bite you.

Version v1.2 — March 2026 snapshot, validated and re-split 2026-09-25, Parquet 2026-09-27
Unit one listing (listing_id, e.g. RAK16008)
Listings 6,067 · 5,827 after cleaning · 5,698 with photos (the modelling set)
Modalities tabular (64 raw / 59 cleaned cols) · Dutch text (median 181 words) · photos (median 19 per listing)
Primary target asking_price in EUR — model log(asking_price)
Geography 5 Flemish provinces, 444 postcodes, 640 municipalities
Property types huis (house) 4,344 · appartement 1,723
Price range €79,000 – €1,980,000, median €357,500
Recommended split tabular_data/splits_grouped.csv (leakage-free, 70/15/15)
Formats Parquet (Hub configs) · CSV · WebDataset tars · original JPEG tars — same data, see §3
Quality gate python scripts/validate_dataset.py → exit 0 · report in data/quality_report.json

1. What it is ready for

Use case Ready? Notes
Price regression — tabular ✅ listings_clean.csv; postcode explains most of the variance (see §7)
Price regression — images ✅ WebDataset shards at 224 / 384 px, 2×2 mosaics, full-res originals
Price regression — text ✅ Dutch descriptions, cleaned; no French detected
Multi-modal fusion ✅ All modalities keyed by listing_id, one shared split
XAI (SHAP / Grad-CAM / LIME) ✅ Nothing dataset-specific blocks it
External validation vs Statbel ✅ statbel_transactions.csv + postcode→NIS mapping
Overpricing / price-reduction classification ❌ No usable labels — every price_reduced_* is False/NaN (§7.1)
Time-on-market / survival ❌ No observed sale or removal dates in v1
Brussels / Wallonia, French NLP ❌ Flanders only

2. Getting the data

Request access on this page (approved manually), then:

hf auth login                       # a token of the account that was granted access
hf download AnthonyL1996/immovlan-flanders --repo-type dataset --local-dir immovlan-flanders
from datasets import load_dataset
listings = load_dataset("AnthonyL1996/immovlan-flanders", "listings")   # train/validation/test
photos = load_dataset("AnthonyL1996/immovlan-flanders", "photos_224", split="test", streaming=True)

This repository holds the Parquet configs (data/processed/parquet/, 2 GB — what load_dataset uses), tabular_data/, data/processed/listings_clean.csv, data/processed/shards_224_v2/ (2 GB) and data/processed/photos_full_res/ (24.8 GiB) under the paths used throughout this card. The 384 px / mosaic shards are not published here; the tree in §3 lists them for completeness.

Skip the 25 GB of originals unless you need them:

hf download AnthonyL1996/immovlan-flanders --repo-type dataset --local-dir immovlan-flanders \
    --exclude "data/processed/photos_full_res/*"

3. Layout

Which variant should I use?

Several files hold the same data in a different format — pick one per modality, don't combine them (you would double-count). Everything is keyed by listing_id.

You want Use Same data as On HF
load_dataset / the Hub viewer Parquet configs data/processed/parquet/ (§4.8) listings = listings_clean.csv + descriptions + splits_grouped.csv; photos_224 = the shards_224_v2 images ✅
Tabular work in pandas data/processed/listings_clean.csv + tabular_data/splits_grouped.csv listings Parquet config ✅
Raw, uncleaned attributes tabular_data/listings.csv listings_raw Parquet config ✅
Streaming images into PyTorch shards_224_v2 WebDataset tars photos_224 Parquet config (tars use the original split) ✅
Original resolution photos_full_res/<province>_photos.tar source of every image variant ✅
384 px or 2×2 mosaics shards_384, mosaic_shards resized / tiled from the originals DVC only
Nothing (legacy) shards (v1 224 px), photos_224x224.tar.gz same photos as each other; superseded by shards_224_v2 DVC only

Exact copies: tabular_data/*.csv are byte-identical to the CSVs in data/processed/ (the validator checks this). Duplicates inside the data — the same property under several listing_ids, and 9.4% repeated photos — are a separate issue, covered in §6 and §7.6.

tabular_data/                       ← start here (identical copies of the processed CSVs + splits)
├── listings.csv                    6,067 × 64  raw tabular attributes, one row per listing
├── labels.csv                      6,067       price-reduction labels (unusable, §7.1)
├── descriptions.csv                6,067       cleaned Dutch description text
├── splits.csv                      5,698       original split (has leakage, §6)
├── splits_grouped.csv              5,698       leakage-free split  ← use this
├── statbel_transactions.csv        8,550       Statbel sale prices: year × NIS municipality × type
└── statbel_postcode_nis.csv          316       postcode → NIS municipality code

data/processed/
├── listings_clean.csv              5,827 × 59  cleaned, imputed, feature-engineered
├── shards_224_v2/{train,val,test}/ 116,265 photos, 224 px   ← recommended image set
├── shards/{train,val,test}/        116,279 photos, 224 px   (v1: no EXIF fix, plain resize)
├── shards_384/{train,val,test}/    116,279 photos, 384 px
├── mosaic_shards/{train,val,test}/   5,698 2×2 mosaics, 448 px (one per listing)
├── photos_full_res/<province>_photos.tar   original JPEGs, 24.8 GiB
├── photos_224x224.tar.gz           all photos 224 px as plain files, 1.7 GB
├── parquet/                        Hub-ready Parquet: listings, photos_224, statbel (§4.8)
└── (same five CSVs as tabular_data/)

data/raw/immovlan/                  September 2026 re-scrape: photos + metadata only (§7.5)
data/quality_report.json            output of the validator — every number in this README

4. Files and schemas

4.1 listings.csv — one row per listing

Raw values as scraped; Dutch category strings kept as-is. Fill rate in brackets.

Group Columns
Keys listing_id (100%), url, source (always immovlan)
Location postcode (str, 4 digits), municipality, province, latitude, longitude (100%; 13 rows have lat/lon swapped)
Target asking_price (EUR, 100%)
Size surface_m2 (94%), land_m2 (60%), n_bedrooms (98%), n_bathrooms (92%), n_toilets (69%), n_facades (74%)
Building build_year (74%), condition (73%: Normaal, Nieuw pand, Te renoveren, Uitstekend, …), heating_type (72%), glazing_type (44%)
Energy epc_label (65%, B–F only), epc_label_num (B=5 … F=1), epc_kwh_m2 (74%)
Amenities has_garden, has_terrace, has_lift, has_garage, has_attic, has_solar_panels, has_heat_pump, furnished, n_garages, n_outdoor_parking — NaN means not stated, not absent
Orientation garden_orientation (11%), facade_orientation (38%)
Regulatory zoning (58%), flood_sensitivity (98%), flood_g_score / flood_p_score (~30%), availability (52%)
Photos n_photos, photo_paths (comma-separated; paths point into the March raw folder, which no longer exists — load photos from the shards or tars instead)
Time listed_date, scraped_at, days_on_market (days listed at scrape time, not until sale)
Labels price_reduced_{14,28,42}d, price_reduction_pct_*, removed_within_*, final_status — all uninformative (§7.1)
Always empty cadastral_income, floor, n_floors_building, has_balcony, renovation_obligation, price_reduced, price_reduction_pct, removed_date, days_to_first_reduction

4.2 listings_clean.csv — model-ready tabular table

Produced by notebooks/02_data_cleaning.ipynb from listings.csv:

  • drops empty/constant/metadata columns; fixes the 13 swapped coordinates;
  • parse errors → NaN (a 1,962-bedroom house, 1 m² surface, 0 kWh/m² EPC values), n_garages capped at 10;
  • deduplicates on (postcode, surface_m2, n_bedrooms, asking_price) → 5,827 rows;
  • booleans: NaN → False; numerics: median imputation + a <col>_missing indicator;
  • sparse columns become <col>_reported flags;
  • adds building_age, price_per_m2, log_asking_price, is_house, has_epc, price_per_m2_outlier (7 flagged, kept).

⚠️ Imputation medians were computed on all rows, including val/test. The effect is small, but for a strict protocol re-impute from listings.csv with statistics fitted on train only.

4.3 descriptions.csv

listing_id, source, description_clean — HTML stripped, lowercased, whitespace normalised. Stopwords are not removed (intended for transformer models such as RobBERT).

4.4 Splits

listing_id, split ∈ {train, val, test} (+ group_id in splits_grouped.csv). Covers the 5,698 listings that have photos. Details and which one to use: §6.

4.5 Photo shards (WebDataset)

Each shard-XXXX.tar holds sample pairs keyed {listing_id}_{photo_index:03d}:

RAK16008_000.jpg    JPEG, square, size per set
RAK16008_000.json   {"listing_id", "photo_index", "asking_price", "property_type", "postcode",
                     "province", "surface_m2", "n_bedrooms", "epc_score", "source",
                     "price_reduced", "price_reduction_pct", "split"}

⚠️ photo_index is the photo's rank when its original filenames are sorted alphabetically (random hashes), not its position in the listing gallery. _000 is not the cover photo. For gallery order use photo_paths in listings.csv: basename k there is gallery position k, and the same basename is the filename in the full-res tars (§4.6).

manifest.json in each set lists shards and per-split counts. Shards are written in the original splits.csv split — to use splits_grouped.csv, read all three directories and re-route each sample by listing_id (snippet in §5.2).

Set Pre-processing Photos
shards_224_v2 EXIF-rotate → resize shorter side to 256 → centre-crop 224; drops <2 KB and near-blank images 116,265
shards plain resize to 224×224 (aspect not kept, EXIF ignored) 116,279
shards_384 resize to 384×384 116,279
mosaic_shards 2×2 mosaic of 4 diverse photos, 448×448, one per listing; JSON adds n_tiles, tile_indices, n_photos_total 5,698

4.6 Full-resolution photos

photos_full_res/<province>_photos.tar → <listing_id>/<hash>.jpg, original resolution, with the same filenames as photo_paths in listings.csv (whose order is the gallery order). Every modelling listing is present with its full gallery. The tars also hold 40 listings that are not in listings.csv — ignore them.

4.7 Statbel

statbel_transactions.csv — official transaction prices per year × municipality (nis_code) × property_type (house / apartment), 2010–2024, 285 municipalities: n_transactions, p25_price, median_price, p75_price. statbel_postcode_nis.csv maps postcode → nis_code. Use for municipality-level external validation (asking vs transaction price), not as a model feature.

4.8 Parquet (Hugging Face configs)

data/processed/parquet/ holds the same data as Parquet, one directory per Hub config, built by scripts/build_parquet.py. Load it with datasets without touching tars or CSVs:

Config Rows Contents Splits
listings (default) 5,490 listings_clean.csv + description_clean + group_id + split_original train 3,842 · validation 827 · test 821 (grouped)
photos_224 116,265 shards_224_v2 images as an Image column + listing_id, photo_index, price, type, postcode, province, surface, bedrooms, EPC, split_original train 81,774 · validation 17,551 · test 16,940 (grouped)
listings_raw 6,067 listings.csv + description_clean full
statbel_transactions / statbel_postcode_nis 8,550 / 316 Statbel tables (§4.7) full

Both split configs use splits_grouped.csv, so a listing and all its photos are in exactly one split. Join photos to listings on listing_id. The same photo_index caveat as §4.5 applies.


5. Quick start

5.1 Tabular

import numpy as np
import pandas as pd

df = pd.read_csv("data/processed/listings_clean.csv", dtype={"postcode": str})
split = pd.read_csv("tabular_data/splits_grouped.csv")
df = df.merge(split, on="listing_id")          # 5,490 rows: listings with photos, deduplicated

y = np.log(df.asking_price)
train, val, test = (df[df.split == s] for s in ("train", "val", "test"))

Target-encode postcode / municipality on train only (see GeoEncoder in models/tabular/xgboost_baseline.py).

5.2 Images with WebDataset

import json, glob
import pandas as pd
import webdataset as wds

split_of = dict(pd.read_csv("tabular_data/splits_grouped.csv")[["listing_id", "split"]].values)
urls = sorted(glob.glob("data/processed/shards_224_v2/*/shard-*.tar"))

def in_split(name):
    return lambda s: split_of.get(json.loads(s["json"])["listing_id"]) == name

train = (wds.WebDataset(urls, shardshuffle=100)
         .select(in_split("train"))
         .shuffle(1000)
         .decode("pil")
         .to_tuple("jpg", "json"))

For the original split, models/data/photo_dataset.py::get_loader(split, batch_size) reads the per-split directories directly.

5.3 Text

text = pd.read_csv("tabular_data/descriptions.csv").set_index("listing_id").description_clean
# tokenizer = AutoTokenizer.from_pretrained("pdelobelle/robbert-v2-dutch-base")

5.4 Reference baseline

XGBoost on listings_clean.csv features (models/tabular/xgboost_baseline.py, experiment E1), on the original splits.csv:

Split R² (log price) MAE MAPE
val 0.787 €63,761 15.8%
test 0.776 €66,366 15.9%

Re-run it on splits_grouped.csv before comparing new models against it.


6. Splits — use splits_grouped.csv

The original splits.csv (stratified by property_type × price quintile, seed 42) assigns listings independently. But the same property often appears under several listing_ids — relisted by another agency, or several units of one new-build project sharing photos. Measured on splits.csv:

  • 615 listings (10.8%) share at least one byte-identical photo with a listing in a different split (the 592 images that appear in ≥5 listings — agency banners/logos — are not counted);
  • 215 listings in 100 groups have identical postcode + surface + bedrooms + price across splits.

A model can memorise the photo in train and look it up in test. CV and fusion test scores on splits.csv are optimistic.

splits_grouped.csv (built by scripts/build_grouped_splits.py) links listings that share a structural key or a non-banner photo into groups (4,990 groups, 433 with >1 listing, largest 8), then assigns whole groups with StratifiedGroupKFold on the same strata (20 folds → 14/3/3 = 70/15/15, seed 42) → 3,990 / 855 / 853 listings. The validator enforces zero cross-split sharing on it (measured: 0 photo-sharing, 0 structural duplicates).

Keep splits.csv only to reproduce results reported before 2026-09-25.


7. Known issues and caveats

7.1 No overpricing labels (blocking for the classification task)

price_reduced_{14,28,42}d are False or NaN for every listing; final_status is active for all. v1 has 2 days of observation, so no reduction could have been seen. The daily price monitor that was meant to fill these ran after v1 was frozen, and its history for March–September 2026 is lost. Since 8 Sept 2026 it records price = NULL for every check (a parser failure), so it cannot supply labels either. Treat price-reduction classification as unsupported.

7.2 Asking price ≠ sale price

The target is what the seller asks, not what the property sold for. Statbel transaction prices are included to quantify the gap at municipality level.

7.3 Location is strong but sparse

444 postcodes over ~5.7k listings (median 7 per postcode; 154 have fewer than 5), so a raw per-postcode mean price overfits (R² 0.06 on log price, grouped test split). Use smoothed target encoding with municipality → province fallback, fitted on train only (GeoEncoder in models/tabular/xgboost_baseline.py). To measure what photos or text add on top of location, run ablations with and without the location features.

7.4 Missingness is informative

Many booleans and orientation fields are filled only when the agent mentions them, and EPC is missing for 35% of listings. listings_clean.csv keeps _missing / _reported flags — keep them as features rather than dropping them.

7.5 data/raw/ is not the source of v1

The March raw scrape (JSON + original photos) no longer exists; v1 survives complete in data/processed/ and tabular_data/. data/raw/immovlan/ is a September 2026 re-scrape of 9,366 listings (only 315 overlap with v1) that captured photos and location but no price, description, coordinates or EPC — the detail-page parser was broken. Do not train on it; it is kept only as an image archive.

7.6 Smaller issues

  • 208 of the 5,698 split listings are dropped as duplicates in listings_clean.csv → tabular experiments use 5,490 rows, image experiments 5,698. Same split, different populations — note it when comparing modalities.
  • 9.4% of photos are exact duplicates (105,318 unique of 116,265 in shards_224_v2): images reused across listings — agency banners/logos (592 images appear in ≥5 listings), relisted properties, shared new-build renders. De-duplicate before contrastive or self-supervised pre-training.
  • 7 price_per_m2 outliers (<€500 or >€15,000/m²) are flagged, not removed.
  • epc_label contains only B–F; no A-labels appeared in the scrape.
  • One snapshot in time (March 2026): no seasonality, no market trend.
  • Photo count correlates with price (Spearman 0.31: expensive listings have bigger galleries) — aggregate per photo, not per listing, if you want the visual signal alone.

8. Quality verification

python scripts/validate_dataset.py          # full run, a few minutes; decodes every 224 px v2 image
python scripts/validate_dataset.py --fast   # samples 1 in 50 images, ~1 min

Exits non-zero on any integrity failure; writes every check and statistic to data/quality_report.json. It checks:

  • Tables — unique keys; joins across listings / labels / descriptions / clean / splits; domains (price > 0, 4-digit postcodes, known provinces, EPC mapping, coordinates inside Belgium); tabular_data/ byte-identical to data/processed/.
  • Labels and text — label positives; description language; emails and Belgian phone numbers (hard failure).
  • Splits — 70/15/15 ratio; stratification balance; structural and photo-level leakage across splits (a hard failure for splits_grouped.csv).
  • Shards — every shard present; sample counts match the manifest; each sample in the right split; images decode at the declared size.
  • Photo archives — each tar reads to the end; every modelling listing present with its full gallery, filed under the right province; images decode.

Result on 2026-09-25: 53 pass · 0 fail · 7 warn — the warnings are exactly the caveats in §6–§7 (no label positives, leakage in the original split, 13 swapped coordinates in the raw table, 7 price/m² outliers, 208 split listings deduplicated out of listings_clean, 40 orphan listings in the photo tars).


9. Provenance

  • Source: public listing pages on immovlan.be (search results → detail page → photo gallery), scraped 8–10 March 2026 with scraping/immovlan_scraper.py.
  • Scope: houses and apartments for sale in the five Flemish provinces.
  • Pipeline: scraping/data_cleaner.py → listings/labels/descriptions.csv → scraping/build_shards.py (splits + shards) → notebooks/02_data_cleaning.ipynb (listings_clean.csv) → scripts/build_grouped_splits.py (splits_grouped.csv).
  • Statbel: Belgian statistics office open data, scraping/statbel_loader.py; postcode → NIS from jief/zipcode-belgium.

10. Privacy, terms and licence

  • The listings are publicly visible commercial adverts, collected for non-commercial academic research. Immovlan's terms of use apply; do not redistribute the data or photos publicly and do not use them commercially.
  • Phone numbers and emails are stripped from descriptions; the validator fails the build if one reappears. (v1.0 still contained 17 Belgian 0476/78.48.30-style numbers that the original scrubber missed — removed in v1.1, and scraping/data_cleaner.py now catches the format.) Agent first names can still occur in sign-off lines ("afspraak met ") — check text before quoting it in public figures or demos.
  • Photos may show building exteriors, streets and occasionally people or number plates; do not publish them outside the project context.
  • Statbel data: Statbel open data (statbel.fgov.be/…/opendata/immo/) — cite Statbel as the source.

11. Changelog

Version Date Change
v1.0 2026-03-10 Scrape, CSVs, 224 px shards, full-res tars
— 2026-03-21 → 05-11 mosaic shards, listings_clean.csv, 384 px shards, 224 px v2 shards
v1.1 2026-09-25 Validator, splits_grouped.csv (leakage fix), 17 phone numbers scrubbed from descriptions.csv, this datasheet, off-site backup
v1.2 2026-09-27 Parquet export (data/processed/parquet/, Hub configs use it, grouped split)

12. Citation

Explainable Multi-modal Property Valuation for the Belgian Real Estate Market.
KU Leuven, Computer Vision & NLP course project, supervisor Prof. T. Goedemé, 2026.
Dataset: Immovlan Flanders v1.1.
Downloads last month
30