Repository navigation
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.
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.
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 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.
| 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.
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.
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.
Set-Location "<repo>\DiGi.GIS.YOLO.UI\bin"
.\DiGi.GIS.YOLO.UI.ConsoleApp.exe YearBuiltPredictionPipelineOptions.jsonTwo things about that invocation matter more than they look:
-
Run from
bin.ScratchDirectoryandModelPathresolve 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.jsonfrom its output directory — whatever county that file happens to name. Pass the file you mean.
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
|
| 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.
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.
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.
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.
- Training-the-Year-Built-Model — retraining the parked regressor, the evaluation baseline
- DiGi.GIS.YOLO.UI — the pipeline orchestrator and console runner
- DiGi.YOLO — the detector and its Python scripts
-
DiGi.GIS.IO —
Query.YearBuiltPredictionInputColumns, the feature allow-list