Datasets:
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
listingsandphotos_224configs are split with the leakage-freesplits_grouped.csv(§6); each row also carriessplit_originalto 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
2 GB) and load_dataset uses), tabular_data/, data/processed/listings_clean.csv,
data/processed/shards_224_v2/ (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_garagescapped at 10; - deduplicates on (
postcode,surface_m2,n_bedrooms,asking_price) → 5,827 rows; - booleans: NaN → False; numerics: median imputation + a
<col>_missingindicator; - sparse columns become
<col>_reportedflags; - 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.csvwith 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_indexis the photo's rank when its original filenames are sorted alphabetically (random hashes), not its position in the listing gallery._000is not the cover photo. For gallery order usephoto_pathsinlistings.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_m2outliers (<€500 or >€15,000/m²) are flagged, not removed. epc_labelcontains 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 todata/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 fromjief/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, andscraping/data_cleaner.pynow 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