Skip to content

Year Built Prediction

Jakub Ziolkowski edited this page Oct 14, 2026 · 7 revisions

Year Built Prediction

Predicts the construction year of a building from how it appears in the orthophoto record, read through the per-year detection columns the pipeline stores in building_data.

The chain is: orthophoto imagery → a frozen YOLO detector → per-year detection columns → detector-only dating → a predicted year. Dating is the first-detection rule: the first year the detector saw the building at confidence ≥ 0.5, falling back to the first year it was seen at all — and every stored prediction carries its identity, first-confident-detection@0.5. This repository owns that rule and the stored-year contract; DiGi.GIS.YOLO.UI owns the orchestration.

The ML.NET regressor that used to do the dating is retired from production and kept as the evaluation baseline any new model must beat; to retrain it, see Training-the-Year-Built-Model.

How the stored year is chosen

A building can carry several predictions — one per run that scored it. building_data."Predicted year built" holds the most frequent of them, not the latest. User year built is the same rule over user entries: the most frequent value when several users gave a year. And Calculated year built — the column operators use — is the user entry when one exists, otherwise the predicted year: a fallback, not a third prediction.

Each history entry also carries what produced it — first-confident-detection@0.5 on current runs, a model SHA-256 on the regressor-era ones — so the stored history still tells one predictor's predictions from another's.


What the model actually learns

Worth understanding before trusting a number from it.

The label is not a historical construction year. It is the first year the building appears in the orthophoto series, floored at the earliest imagery year. Since 2026-09 the training label is read from the stored User year built column of building_data — the most frequent exact user year, written by the national Year Built building-data update and never re-derived on the client. Across the five labelled counties (24 047 rows as of 2026-09-29), 84.2 % of rows carry 2008, the earliest year available, and there are 18 distinct values.

That has a consequence the metrics make plain. A rule with no machine learning in it — "predict the first year the detector fires" — reproduces the label exactly on 88.8 % of rows, and is now the production dating rule:

Predictor MAE (years) RMSE R²
constant 2008 1.465 4.033 −0.152
constant mean 2.533 3.758 −0.000
first detection year 0.449 1.750 0.783

Measured on a seeded random 20 % holdout of 20 241 rows, 2026-09-02.

So an R² near 0.9 is not the achievement it looks like: constant-2008 scores negative, and the trivial heuristic already reaches 0.783. Any retrain has to be compared against these baselines, not against zero and not against the incumbent's historical figure alone.

The bar a retrain must clear is the heuristic's figure on the FNV-1a holdout: MAE 0.434, RMSE 1.730 and R² 0.786, all three at once (DiGi.GIS.ML#4). The 2026-09-29 retrain on the stored-column labels, which added the newly labelled county 204, was measured against it and held.

The shipped model (2026-10-08)

The model shipped on 2026-10-08 (DiGi.GIS.ML#13) was trained on the detections of the retrained YOLO detector train9_fresh (DiGi.GIS.YOLO.UI#12) and covers 25 008 labelled buildings over 217 counties. It is no longer the production predictor — dating is detector-only now — but remains the baseline every new model must beat.

Identity
Detector model.pt (train9_fresh, YOLO26x) SHA-256 2dd833e438ffcb1352ae7675063c9f0c97ca1c39ce6410a0bfab96e8b3cbdc9a
Dating rule, stamped on every stored prediction first-confident-detection@0.5
Regressor OrtoBuildingDetectionModel.mlnet — the model below, parked 2e120f495e830f0917cace33ad21c5c4c7bb0c4e8f1711a363682932b7c0b7fa

Dating is detector-only. Each building's year comes from its per-year detection series alone — the first year the detector saw it at confidence ≥ 0.5, falling back to the first year it was seen at all. A building the detector never fired on is not dated: its stored Predicted year built is cleared rather than left stale, while its user entry is never touched, and a building with no stored imagery was never exported, so it is neither dated nor cleared (DiGi.GIS.YOLO.UI#28). The regressor, scored on counties it was not trained on, came out years too late where the rule matched them with MAE 0.15 — which is why it is the baseline rather than the predictor (DiGi.GIS.ML#15).

The rule still reads the stored detection columns, so a county's detections must have been written by train9_fresh before it is dated. A score-only run over columns train8 wrote dates buildings from an old detector's series, and nothing refuses it, because the columns are populated, just by the wrong detector. Every county is therefore rewritten with train9_fresh detections before it is dated (DiGi.GIS.YOLO.UI#23).

A twin was trained without the holdout and scored through the deployed path, on buildings it never saw:

Carve n first detection year (MAE / RMSE / R²) model (MAE / RMSE / R²)
full table, random 20 % 4 956 0.358 / 1.797 / 0.791 0.338 / 1.271 / 0.896
clean (no detector or model has seen them) 179 1.894 / 4.283 / −0.08 1.251 / 2.609 / 0.601
old estate without 204 (the #4 carve) 4 012 0.285 / 1.583 / 0.821 0.277 / 1.129 / 0.909

It beats the heuristic on all three metrics at once on these carves; the full measurement is in the repository's provenance. The better detector makes the heuristic itself stronger: on the #4 carve its MAE drops from 0.434 to 0.285. A bar fixed against an old detector's heuristic is therefore no longer the right reference; compare a model against the heuristic computed on the same detections.

Read the evaluation's deployed path row. YearBuiltPredictionEvaluationConsoleApp scores a --model through ML.NET's TextLoader (the retrained row). For the same model, that row reads about 0.07–0.09 years higher MAE than the production binding. The cause is not yet established.


Requirements

To run predictions

Runtime .NET 10
Python 3.9+ with ultralytics==8.4.165 and torch. The shipped YOLO26x detector does not load under 8.3.130, and the runner's preflight refuses it. A CUDA build is strongly recommended: inference runs at roughly 75–90 images/second on GPU
YOLO weights model.pt (train9_fresh), in user files/YOLO/models/ of the runner — the only model file a run needs; the dating rule requires none
WebAPI key GIS_WebAPI_Client.conf with Key="…", required only when a write flag is on
Disk ~10 images per building at ~13 KB each. A 5 500-building county is ~0.9 GB; a 45 000-building county is ~5 GB

The runner checks the Python environment before exporting anything (DiGi.YOLO.Query.YOLOEnvironmentResult), so a machine without ultralytics fails in seconds rather than after half an hour of image export.

Server side, for the write steps

The write endpoints are guarded twice, and both gates return a bare 401 that looks identical on the wire. In GIS_WebAPI.conf on the host:

Enabled=true
Key="<the same value the client sends>"
AllowUpdateBuildingData=true
AllowUpdateYearBuiltData=true

Warning

AllowUpdateBuildingData reads GetValue<bool> with no default, so an absent flag means false. A conf that predates the flag rejects every write with 401 and no key will fix it. This is the single most likely cause of a write failure on a host whose conf was written before the building_data write endpoint shipped.

The watcher polls the file every 5 seconds, so a corrected conf takes effect without restarting the service.


Running the pipeline — DiGi.GIS.YOLO.UI.ConsoleApp.exe

The tray recipe is the same run started for you: tray application → Predict year built forces the six step and write flags (ExportImages, RunPrediction, Score, UpdateDetections, UpdateYearBuiltData, UpdatePredictedYearBuilt) on, fixes Resume: true, and supplies the county identifiers and an absolute ScratchDirectory; Years, Radiuses, Confidence and ModelPath carry through at their defaults, and CleanScratchDirectory is the dialog's one choice.

Command

Set-Location "<repo>\DiGi.GIS.YOLO.UI\bin"
.\DiGi.GIS.YOLO.UI.ConsoleApp.exe YearBuiltPredictionPipelineOptions.json

Two things about that invocation matter more than they look:

  • Run from bin. ScratchDirectory and ModelPath resolve against the current directory, so launching from elsewhere puts the scratch tree somewhere unexpected or fails to find the weights.
  • The argument is optional but rarely should be omitted. With no argument the runner loads YearBuiltPredictionPipelineOptions.json from its output directory — whatever county that file happens to name. Pass the file you mean.

Options

Deployed as user files/YearBuiltPredictionPipelineOptions.json, copied to bin by CopyUserFiles. The committed files/YearBuiltPredictionPipelineOptions.json sits beside it and is the authority on spelling: a key the class does not declare is dropped in silence, so a misspelt flag reads as an unchanged one.

Option Default Notes
CountyIds null County identifiers, never the four-character code. A code matches no stored building, so the run would report legitimate zeroes everywhere — it is refused up front instead
ScratchDirectory null Images and results.bbrf land in <scratch>/<countyId>/
ModelPath null Resolved against the output directory and user files/
PythonPath / WorkingDirectory null Auto-detected when omitted
Confidence 0.1 Detector threshold
Years null Defaults to 2008..2025
MaxConcurrentRequests 8 Image export concurrency
BatchSize 5000 Rows per bulk write
ReferenceBatchSize 10000 The endpoint's hard cap; a larger page fails the whole page
Resume true Skips images already exported
ExportImages true Step 1
RunPrediction true Step 2 — off reuses the existing results.bbrf
Score true Step 5 — dating, by the first-detection rule
UpdateDetections true Writes the 90 detection columns
UpdateYearBuiltData true Writes the dated history
UpdatePredictedYearBuilt true Writes the latest value to building_data

Exit codes

0 Completed
1 Configuration or argument validation
2 Preflight environment check failed
3 WebAPI key or client configuration missing
4 Pipeline execution failure
5 Cancelled

A county that fails is logged and stepped over so one unreachable county does not cost the run the counties behind it — check FailedStepNames in the result, not just the exit code.

Resuming

Every step is idempotent and the scratch paths derive from the county identifier, so a failed run is cheap to retry. Turn ExportImages and RunPrediction off and the run reuses what is on disk and goes straight to the write:

{ "CountyIds": [104106], "ScratchDirectory": "scratch",
  "ExportImages": false, "RunPrediction": false,
  "Score": false, "UpdateDetections": true,
  "UpdateYearBuiltData": false, "UpdatePredictedYearBuilt": false }

Measured: a Świnoujście run that failed at the write finished in seconds on retry instead of the original 13 minutes. The frozen detector is deterministic over the same imagery — two full passes over słupski produced a byte-identical results.bbrf.

A first pass over a county

Turn the three write flags off. The run then reports what it would do without touching stored data, which is the safe way to size a county before committing to it.


Known behaviour worth expecting

Only buildings the detector fired on are dated. A building it never detected has no detection series — the rule's only input — so it is skipped rather than dated on absent features, and its stored Predicted year built is cleared rather than left stale (DiGi.GIS.YOLO.UI#28); its user entry is untouched, and a building with no stored imagery was never exported, so it is neither dated nor cleared. In practice the never-detected are a small minority: 4 of 20 241 across the four labelled counties.

Orthophoto year coverage is county-specific, and thinner than 18 years suggests. Świnoujście has ten genuine coverage years; the other eight carry between one and six stray images each. Słupski's sparse years were a different set. So detection columns exist for years that carry almost no data, and which years those are varies by county.

Municipality population 2024 and 2025 are zero everywhere. The BDL series does not reach them yet. They are constant rather than missing, so nothing downstream reports them as absent.


Related