BlueGreenCity-KI AutoGluon Framework
The BlueGreenCity-KI project implements a machine learning pipeline for predicting drought-damage risk in urban trees. This complements the existing weighted-sum static scoring with data-driven predictions based on historical tree inventory records.
The first approach (master thesis) static scoring approach (weighted criteria) achieves low correlation with actual tree damage (Spearman ρ ≈ 0.007). An ML model trained on historical outcomes can learn which factors actually predict poor tree condition, providing:
┌─────────────────────────────────────────────────────────────┐
│ PIPELINE: Static Score vs. ML Score (Parallel) │
├─────────────────────────────────────────────────────────────┤
│ │
│ Criterion Scores (25 criteria, 21 active) │
│ ↓ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Static Path │ ML Path │ │
│ │ Weighted Sum │ (if enabled in config) │ │
│ │ → drought_risk │ │ │
│ │ _rulebased │ Tree Features + Training Data │ │
│ │ (float 0–1) │ ↓ │ │
│ │ │ AutoGluon Model │ │
│ │ │ ↓ │ │
│ │ │ P(drought damage) │ │
│ │ │ → drought_risk_ml (float) │ │
│ │ │ → drought_risk_ml_class (cat) │ │
│ │ │ → confidence / class margin │ │
│ │ │ → local drivers for extremes │ │
│ └─────────────────────────────────────────────────────┘ │
│ ↓ │
│ Output both columns in GPKG │
│ │
└─────────────────────────────────────────────────────────────┘
Both scoring approaches run in parallel. ML does not replace the static score; they coexist for comparison.
This section reflects the production state. Earlier sections of this guide document the development history and are kept for reference. When in doubt, this section takes precedence.
| Parameter | Value |
|---|---|
| Operative label | future_drought_damage_severe_t1 |
| Label strategy | operational_panel_drought_damage — one label per tree×snapshot_year |
| Training approach | Multi-year panel (2018–2024 snapshots) |
| Model | AutoGluon WeightedEnsemble_L2 (refit_final; ensemble over GBM/CAT/XGB/NN_TORCH[GPU]/RF/XT candidates) |
| Eval metric | ROC-AUC |
| Model path | data/models/autogluon/operational/2026_10/train_2018_2024__predict_2025/model/drought_risk_t1/ (Stand: 2026-10-Re-Training) |
| Feature count | 54 active features in the current training allowlist (2026-10 bundle: +7 spatial, +SWBM soil-water, +A/B re-entries; 4 removed on 2026-09-15, Issue #217) |
| Score calibration | Platt sigmoid fitted on the external 2024 holdout each validation round (Issue #253); drought_risk_ml_calibrated is the operative decision score, Brier 0.041 → 0.029 |
| Standard validation reference | external 2024 holdout ROC-AUC 0.9697 (2026-10 validation run; OOF gap +0.016); the operational run (predict_2025 only) carries no test metrics |
| Grouped CV robustness check | 5-fold StratifiedGroupKFold by objectid, ROC-AUC 0.888, Precision 0.704, Recall 0.435 (2026-10) |
| Thresholds | on the calibrated score: critical ≥ 0.85 · high ≥ 0.30 · medium ≥ 0.10 · low < 0.10 (seit 2026‑10‑08, Issue #253; davor auf dem Rohscore 0.85/0.30/0.15) |
| Operative NDVI feature | no active NDVI feature in the current allowlist |
dataset_mode |
"operational_panel" |
future_drought_damage_severe_t1future_drought_damage_severe_t1 = 1 if drought_damage_severe is observed
in (snapshot_year + 1) to (snapshot_year + 1)
0 otherwise
This is a next-year forecast: given tree features in year t, predict whether the tree
shows severe drought damage in year t+1. The label is extracted by
LabelExtractor.extract_operational_drought_damage_labels(snapshot_year=t).
Current definition B+ (LHH-aligned, decision 2026-08-30/31, see
docs/plans/done/2026-08-30-kaskade-label-feature-reduktion.md, Block B):
score = 2 * crown_dieback(Wipfeldürre) + 1 * foliage_anomaly(Belaubung)
+ 1 * vitality_change_t1(last observation t+1 > last observation t)
severe = score >= 2 OR felled in t+1 with reason "absterbend"
Totholz is deliberately excluded from the score (cumulative record, not a
next-year drought signal); it is still emitted as context
(future_totholz_binary_t1).
Label-history note: Runs before 2026-09 used the older 3-point definition (A) which included Totholz in the score. The switch to B+ changes the labelled population (e.g. on the 2024 snapshot: −836 / +16 severe cases on the 23,390 common trees — fully explained by the definition change, not by the v6 inventory extract; see issue #228 comment 2026-10-06). Do not compare recall/precision numbers across the A/B+ boundary without this caveat.
2026-10-07 — B+ kept as-is after re-evaluation. Two follow-up ideas were analyzed and rejected: (1) a felling-age restriction (“absterbend” only for trees <10 years old at felling, via
Hauptdaten Pflanzjahr) — felling contributes only ~1–2% of severe positives (168 in the 2019 window → 8 in the 2025 window), Pflanzjahr is known for only 33.6% of absterbend fellings (2019–2025), and the net label effect is 17.77% → 17.18% positive — negligible; (2) raising the positive rate to ~33% for class balance — the ~18% rate is the honest base rate of severe drought damage, is not a class-balance problem (the 0.96-AUC baseline was achieved at this rate), and forcing 33% would require including Totholz (label contamination). If imbalance ever becomes an issue, useml.training.class_weight_positive(sample weighting) instead of diluting the label. Treelife-Behandlung is added as an independent validation signal (model top-risk vs. actual LHH interventions, issue #257); a second model with a Treelife label was discussed and rejected (endogenous treatment decisions, expected redundancy with B+).
The current operative model (“Modell Hannover”) uses a curated 54-feature allowlist defined in config/config.yaml (feature_columns).
It combines:
The older 17-feature NDVI-heavy snapshot should be treated as a historical intermediate state. For the current panel-aware internal validation, the important distinction is not between individual feature ranks, but between the two internal validation levels:
objectid as the stricter panel-aware robustness estimate.| Class | Calibrated probability | Action |
|---|---|---|
| critical | ≥ 0.85 | Immediate priority watering |
| high | ≥ 0.30 | Priority inspection / targeted watering |
| medium | ≥ 0.10 | Scheduled inspection |
| low | < 0.10 | Normal monitoring |
Thresholds are operational policy, set in config/config.yaml under
ml.thresholds. Since 2026‑10‑08 they apply to the Platt‑calibrated
score (drought_risk_ml_calibrated), not the raw model output (Issue
#253): the calibration makes the numbers honest probabilities, so the
medium bound was re-anchored 0.15 → 0.10 (reference-stable ≈ 20% of trees
on the 2024 holdout class) while high/critical become rarer but sharper.
Before that, since 2026‑09‑19 the high/medium bounds were lowered on the
raw score (0.60/0.40 → 0.30/0.15). The ML run stamps
drought_risk_ml_class with the thresholds active at its run time, but the
daily operational refresh re‑derives the class from the calibrated score
(and the current config; reclassify_risk in
bluegreencity_ki/operational_products.py), so a threshold change takes
effect at the next refresh, not the next ML run. The raw score
(drought_risk_ml) is a model artifact and is never modified. Irrigation
stays hard‑gated on SWBM water demand (irrigation_demand_liters > 0); a
class without an acute deficit means monitoring, not watering.
This section documents how we arrived at the current AutoGluon-based framework, including experiments, decisions, and lessons learned.
We started with ArcGIS Pro AutoML because:
.dlpk model package + HTML reportpflanzjahr)art – Tilia, Acer, Quercus)Decision: Use ArcGIS AutoML as exploratory prototype only; migrate to open-source solution for production.
Historical tree inventory contains multiple crown diagnosis columns:
Multiple observations per tree (2010–2025), encoded as J (Ja = Yes) or N (Nein = No).
| Label | Positive Rate | Sample Size | Decision |
|---|---|---|---|
wipfelduerre_binary |
96.4% | 5,357 trees | ❌ Too imbalanced |
totholz_binary |
95.2% | 4,821 trees | ❌ Too imbalanced |
belaubung_auffaellig_binary |
87.3% | 3,204 trees | ❌ Too imbalanced |
drought_damage_binary |
99.7% | 25,834 trees | ❌ Extremely imbalanced |
drought_damage_severe |
25.8% | 6,672 trees | ✅ Chosen |
drought_damage_severe = (score >= 3).astype(int)
where:
score = Wipfeldürre_binary * 3
+ Totholz_binary * 2
+ Belaubung_binary * 1
Rationale:
Rejected alternative: Training separate models per symptom → too imbalanced for reliable predictions.
| Framework | Pros | Cons | Decision |
|---|---|---|---|
| scikit-learn | Simple, well-documented | Manual hyperparameter tuning required | ❌ Too much manual work |
| PyCaret | Very beginner-friendly, unified API | Maintenance mode since Apr 2024, no active development | ❌ Long-term risk |
| H2O AutoML | Mature, good documentation | Java dependency, complex setup | ❌ Infrastructure overhead |
| TPOT | Genetic algorithm, creative | Very slow, unstable | ❌ Impractical runtime |
| MLjar-supervised | AutoML with explanations | Limited model stacking | ❌ Not flexible enough |
| AutoGluon | Stacking, fast, Apache 2.0, active development | Learning curve | ✅ Chosen |
best_quality (vs. 4 hours for TPOT)Key decision: Use presets="best_quality" with num_bag_folds=5 as the stable training shell, but control the benchmark portfolio explicitly via custom hyperparameters so feature ablations are compared under the same reproducible model families.
After the first ablation runs, we found that benchmark fairness had become blurred by changes in the AutoGluon setup itself. In particular, the experimental extreme portfolio introduced additional foundation-model families, optional dependencies, GPU/backend variability, and very different inference costs. That is useful for method exploration, but not for the main scientific question of this project.
Decision: separate exploratory AutoML experimentation from the primary city-green benchmark.
The concrete comparison of the four stable reference and ablation runs is documented in reports/stable_ablation_report_2026-04-03.md.
The active reference benchmark is now intentionally conservative:
GBM, CAT, XGB, NN_TORCH, RF, XTNN_TORCHThis change also reflects the project goal more accurately: the research focus is urban-tree drought risk, not AutoML portfolio research.
PyCaret was initially considered due to its reputation for beginner-friendliness and unified API. However, critical research revealed:
Deal-breaker: Maintenance Mode (April 2024)
Comparison: PyCaret vs. AutoGluon (Feb 2026)
| Aspect | PyCaret | AutoGluon | FLAML |
|---|---|---|---|
| Development Status | ⚠️ Maintenance only | ✅ Active (Jan 2026 release) | ✅ Active (Dec 2025) |
| Community | Declining | Growing (AWS-backed) | Growing (Microsoft) |
| Long-term Support | ❌ Uncertain | ✅ Roadmap to 2030+ | ✅ Enterprise support |
| Performance | ⭐⭐⭐ Good | ⭐⭐⭐⭐⭐ State-of-the-art | ⭐⭐⭐⭐⭐ State-of-the-art |
| Ease of Use | ⭐⭐⭐⭐⭐ Excellent | ⭐⭐⭐⭐ Very good | ⭐⭐⭐⭐ Very good |
| Model Stacking | ⭐⭐⭐ Basic | ⭐⭐⭐⭐⭐ Advanced (multi-level) | ⭐⭐⭐⭐ Advanced |
| Sponsor Lock-in? | ❌ No | ❌ No (Apache 2.0) | ❌ No (MIT) |
| Best For | Quick prototyping (if maintained) | Production, best performance | Resource-constrained training |
Verdict: While PyCaret’s simplicity is attractive, choosing a framework in maintenance mode would create technical debt. AutoGluon provides similar ease of use with active development and superior performance. FLAML (Microsoft) is also a good alternative but AutoGluon’s multi-level stacking and benchmark performance made it the preferred choice.
Decision: If ml.enabled=true but trained model doesn’t exist, log warning and continue (don’t crash pipeline).
Rationale: Allow gradual rollout; not all deployments have trained models yet.
try:
predictor = DroughtRiskPredictor(model_path)
trees["drought_risk_ml"] = predictor.predict_proba(trees)
except FileNotFoundError:
logger.warning("ML model not found; skipping ML scoring")
trees["drought_risk_ml"] = None
Decision: Output both overall_drought_risk (weighted sum) and drought_risk_ml (AutoGluon).
Rationale:
Decision: Implement metrics in pure numpy/pandas (no sklearn dependency for validation).
Rationale:
Implemented metrics:
binary_metrics() – TP, FP, precision, recall, F1roc_auc_score() – trapezoid rule integrationbrier_score() – mean squared errorcalibration_curve() – reliability diagram (n_bins=10)extreme_group_comparison() – top/bottom 5% liftDecision: Use OOF predictions from k-fold CV for threshold analysis, not direct predictions.
Why?
# ❌ Overfitted
proba = predictor.predict_proba(training_data) # ROC-AUC = 1.0
# ✅ Honest
proba_oof = predictor.predict_proba_oof() # ROC-AUC = 0.84
conda activate <project-conda-env>config/config.yamlExtract drought-damage labels from crown diagnosis CSV:
python -m scripts.ml.generate_ml_labels --drought-stats-only
Output: Statistics on label distribution.
Before creating the training dataset, materialize the yearly operational panel feature table in PostGIS for all configured snapshot years:
python scripts/ml/build_operational_panel_snapshots.py --config config/config.yaml
This step fills bgcki.analysis_tree_risk_scores_ml_operational_panel with
one feature snapshot per tree and snapshot_year. The training dataset script
does not compute these features itself; it only reads the already materialized
panel rows from PostGIS and joins them with the extracted labels.
After the panel table is complete, join PostGIS features with CSV labels:
python -m bluegreencity_ki.ml.training.dataset_builder
Output: data/derived/ml/2026_07_23_training_dataset_ml_operational_panel_2018_2024.csv
The current production allowlist in config/config.yaml defines the operative
ML feature set (54 features, see Feature Set in the
“Current Operative Setup” section) plus the technical join column objectid.
The table below is the September 2026 generation (35 features), kept for
reference.
| Feature Group | Columns |
|---|---|
| Taxonomy and species profile | species_genus, species_binomial |
| Individual tree attributes (inventory) | st_umfang, kr_durchm |
| Street and site context | dist_road_m, street_type_value, salt_value, land_use_value, planting_site_surface_type, planting_site_area_m2, planting_site_tree_count |
| Microclimate (thermal and shading) | m_svf_value, m_sunlit_hours_value, m_air_temp_14h_value, m_air_temp_04h_value, m_cold_air_kvs_value |
| Groundwater | groundwater_level_latest |
| Tree vitality (remote sensing) | dwsi_summer_value |
| CiTree species profile | citree_lichtanspruch, citree_bodenverdichtungstoleranz, citree_staunaessetoleranz, citree_salzvertraeglichkeit, citree_grundigkeit, citree_bodeneigenschaft, citree_bodenfeuchtetoleranz, citree_kronenform, citree_oberflaechennahe_wurzeln, citree_trockenheitstoleranz, citree_hitzevertraeglichkeit |
| Hydroclimatic drought context (DWD) | precip_may_oct_mm, precip_nov_apr_mm, temp_mean_may_oct_c, et0_may_oct_mm, p_et0_ratio_may_oct, p_et0_ratio_anomaly_may_oct |
Note: Several features formerly listed here (
ga_lang,vitality_value,field_capacity_value,groundwater_trend,groundwater_level_median/max/std,construction_dist_m,planting_structure_neighbor_count,pit_size_value,tree_disc_value,m_uhi_intensity_value,m_cold_air_kpr_value,albedo_value, the seasonalm_sunlit_hours_*split, the NDVI columns, andcitree_wuchsgeschwindigkeit/citree_kronendurchlaessigkeit) were removed during the correlation-cleaned selection (August 2026) or the 2026-09-15 reduction (Issue #217); seedocs/risk_assessment_ml/automl_features.mdfor the per-feature rationale. They remain available as pipeline columns but are not ML features in the current allowlist.
The Citree matching metadata columns citree_art_id, citree_name, citree_match_rule, and citree_match_score are available in the pipeline output for QA, but they are intentionally not part of the ML allowlist.
Before training a new model, the project now performs a dedicated sanity check on
the final target drought_damage_severe after the drought-window filter and
after the feature join.
This is important because two different failure modes are common in environmental ML workflows:
For the current production setup, the target is defined as:
drought_damage_severe = (drought_damage_score >= 3).astype(int)
with labels extracted only from the drought window 2018..2024.
The following numbers were computed from the April 2026 files
drought_labels_2018_2024.csv / training_dataset.csv in
data/derived/ml/ (superseded; the current panel is
training_dataset_v6_operational_panel_2018_2024.csv):
For context, the earlier pre-window setup aggregated labels over the broader historical period and produced the following headline rates that were used in the first ML iteration:
| Setup | Target | Positive rate | Sample size |
|---|---|---|---|
| Earlier broad historical setup | drought_damage_binary |
99.7% | 25,834 trees |
| Earlier broad historical setup | drought_damage_severe |
25.8% | 6,672 trees |
| Current drought-window setup before join | drought_damage_severe |
22.22% | 25,890 labelled trees |
| Current drought-window setup after join | drought_damage_severe |
22.14% | 24,607 training rows |
This comparison is intentionally kept in the documentation so the shift from the
older broad label period to the current 2018..2024 drought window remains
traceable.
| Stage | Rows | Severe positives | Severe rate |
|---|---|---|---|
| Windowed label dataset | 25,890 | 5,752 | 22.22% |
| Final training dataset after feature join | 24,607 | 5,448 | 22.14% |
This means the PostGIS feature join retains 95.04% of the labelled trees.
More importantly, the severe rate stays almost unchanged (22.22% -> 22.14%).
That is a strong indication that the join is not introducing a major class bias.
drought_damage_binary remains far too imbalanced for primary training use
(97.1% positive in the final training dataset).drought_damage_severe stays in a healthy binary-classification range at
roughly 22% positive.The final training dataset shows the following distribution for
drought_damage_score:
| Score | Count |
|---|---|
| 0 | 707 |
| 1 | 547 |
| 2 | 17,905 |
| 3 | 1,694 |
| 4 | 290 |
| 5 | 1,407 |
| 6 | 2,057 |
Interpretation:
0-2 form the non-severe class.3-6 form the severe class.For the severe class in the final training dataset:
first_damage_year min = 2018first_damage_year median = 2018first_damage_year max = 2024damage_years_count mean = 5.734For the non-severe class:
damage_years_count mean = 4.868Interpretation:
The following class-wise means were compared on the final training dataset:
| Feature | Severe mean | Non-severe mean | Delta |
|---|---|---|---|
vitality_value |
2.6017 | 1.9698 | +0.6319 |
age_value |
49.7430 | 56.2799 | -6.5369 |
ndvi_value_2024 |
0.0586 | 0.0617 | -0.0031 |
m_uhi_intensity_value |
11.9204 | 11.1118 | +0.8086 |
m_svf_value |
0.5635 | 0.5645 | -0.0010 |
groundwater_level_std |
0.2376 | 0.1820 | +0.0556 |
field_capacity_value |
120.2580 | 122.4891 | -2.2311 |
albedo_value |
0.1364 | 0.1340 | +0.0024 |
dist_road_m |
8.8458 | 8.9593 | -0.1135 |
Interpretation:
vitality_value (worse vitality stage), higher urban heat
intensity, slightly lower NDVI, and higher groundwater variability.age_value is lower on average in the severe group. That should not be
over-interpreted as a protective effect of old age; it may indicate a cohort
effect, planting-history effect, or the vulnerability of younger street-tree
cohorts in the drought years.m_svf_value shows almost no mean separation. This does not necessarily make
the feature useless, but it suggests that its contribution may be nonlinear,
interaction-driven, or partly limited by missingness.Some features are unequally available across classes and should be interpreted carefully during model diagnostics:
m_svf_value: missing in 25.99% of severe rows vs. 9.55% of non-severe rowsdist_road_m: missing in 5.45% of severe rows vs. 6.70% of non-severe rowsfield_capacity_value: missing in 7.51% of severe rows vs. 8.29% of non-severe rowsThe strongest asymmetry is currently in m_svf_value. This should be kept in
mind when interpreting future feature-importance rankings.
To ensure the severe class is not dominated exclusively by one single species, the dataset was checked for species with at least 100 rows and the highest severe share:
| Species | Rows | Severe rate |
|---|---|---|
Ulmus hollandica 'Commelin', Stadt-Ulme |
138 | 66.67% |
Sorbus latifolia, Breitblättrige Mehlbeere |
109 | 64.22% |
Sorbus intermedia, Schwedische Mehlbeere |
610 | 51.80% |
Tilia cordata, Winter-Linde |
289 | 35.64% |
Acer pseudoplatanus, Bergahorn |
553 | 35.08% |
This does not prove causality, but it is another useful plausibility check: the severe target is not random noise. It clusters in recognisable species groups, which is consistent with the biological and site-related differences the model is supposed to learn.
For the current 2018-2024 setup, drought_damage_severe is the right primary
training target because:
drought_damage_binary,This sanity check should be repeated whenever the drought window, the label logic, or the feature join changes.
Train AutoGluon models (1 hour per label with best_quality):
# All labels
python -m scripts.ml.train_autogluon --config config/config.yaml
# Quick test (2 min)
python -m scripts.ml.train_autogluon --config config/config.yaml \
--presets medium_quality --time-limit 120
# Single label
python -m scripts.ml.train_autogluon --config config/config.yaml \
--labels drought_damage_severe --time-limit 1800
The April 2026 ablation round used three focused experiment configs (removed again after the round; results in reports/stable_ablation_report_2026-04-03.md):
config/ml_train_no_vitality_2026-04-02.yaml removed the explicit vitality feature but kept species-profile information.config/ml_train_no_vitality_no_species_label_2026-04-02.yaml removed vitality and the direct species label while retaining Citree-derived profile features.config/ml_train_no_vitality_no_species_profile_2026-04-02.yaml removed vitality and the species-profile block to test how far site and climate variables carry the signal on their own.The pattern is still useful when the goal is not just maximum validation score, but also a better fachliche understanding of how much of the prediction comes from vitality, direct species identity, or species-profile proxies.
Output: data/models/autogluon/<label>/ (trained model + leaderboards + feature importances)
If ml.output.postgis.enabled=true is configured, the training artefacts are also written to PostGIS as tabular tables with the configured prefix (default: ml_training_*). This is intended for BI/Superset consumption of leaderboards and feature importances.
Assess model quality on two complementary levels:
Standard validation:
python -m scripts.ml.validate_model --config config/config.yaml
Output: data/validation/autogluon/
overall_metrics.csv – ROC-AUC, precision, recall, F1calibration_curve.csv – reliability diagramspecies_metrics.csv – per-species performancebaseline_comparison.csv – ML vs. weighted-sum correlationPanel-aware grouped CV:
python -m scripts.ml.evaluate_grouped_cv --config config/config.yaml \
--labels future_drought_damage_severe_t1 \
--group-column objectid \
--n-splits 5
Output: data/models/autogluon/grouped_cv/
summary.csv – aggregated grouped-CV metricsfuture_drought_damage_severe_t1/fold_metrics.csv – per-fold metricsfuture_drought_damage_severe_t1/oof_predictions.csv – grouped out-of-fold probabilitiesCurrent reference run (October 2026 round, 2026_10):
0.9697 (OOF control 0.9861)objectid): 0.8881h3_res8): 0.8032The grouped CV numbers are the stricter internal estimates for the panel
dataset because they keep all yearly observations of one tree (or one H3 cell)
inside the same fold. Earlier rounds: 2026_09 grouped CV 0.8563
(see dual_validation_operational_workflow.md),
June 2026 0.8817 (archived report).
Find optimal probability threshold for your use case:
python scripts/ml/threshold_analysis.py
Output: Table showing precision, recall, F1, and % flagged trees at thresholds 0.10–0.90.
Examples:
Enable ML scoring in config:
# config/config.yaml
ml:
enabled: true
inference:
label: "future_drought_damage_severe_t1"
thresholds:
critical: 0.85
high: 0.30
medium: 0.10
low: 0.00
Run pipeline:
python -m bluegreencity_ki.runners.cli --config config/config.yaml
Output: Standard pipeline results plus:
drought_risk_ml – predicted probability (0.0–1.0)drought_risk_ml_class – risk class (critical/high/medium/low)drought_risk_ml_confidence – distance from the 0.5 decision boundarydrought_risk_ml_class_margin – distance to the nearest class thresholddrought_risk_ml_top_positive_drivers_approx – local positive drivers (perturbation approximation) for extreme-score treesdrought_risk_ml_top_negative_drivers_approx – local negative drivers (perturbation approximation) for extreme-score treesdrought_risk_ml_top_positive_drivers_shap – local positive SHAP drivers (filled by the SHAP stage)drought_risk_ml_top_negative_drivers_shap – local negative SHAP drivers (filled by the SHAP stage)The ML model learns from three base labels extracted from crown diagnosis columns in the historical tree inventory (2010–2025):
| Label | Source Column | Meaning | Positive Rate | Sample Size |
|---|---|---|---|---|
wipfelduerre_binary |
“Krone Wipfeldürre” | Crown dieback observed (J=1, N=0) | 96.4% | 5,357 trees |
totholz_binary |
“Krone Totholzbildung” | Dead wood in crown observed | 95.2% | 4,821 trees |
belaubung_auffaellig_binary |
“Krone Belaubung (Auffälligkeiten)” | Foliage anomalies observed | 87.3% | 3,204 trees |
These three labels are directly extracted from crown diagnosis assessments where foresters recorded visual symptoms (‘J’ = Yes, ‘N’ = No) during inspections from 2010–2025. For each tree, the label represents whether that symptom was EVER observed across all inspection years (MAX aggregation).
From the three base labels above, two additional labels are computed for different modeling purposes:
| Derived Label | Formula | Meaning | Positive Rate | Sample Size |
|---|---|---|---|---|
drought_damage_binary |
OR of three base labels | Any crown damage observed | 99.7% | 25,834 trees |
drought_damage_severe |
score ≥ 3 | Multiple damage types or repeated years | 25.8% | 6,672 trees |
Computation Logic:
# Step 1: Calculate hierarchical severity score
drought_damage_score = (
wipfelduerre_binary * 3 # Crown dieback (most severe)
+ totholz_binary * 2 # Dead wood formation
+ belaubung_auffaellig_binary * 1 # Foliage anomalies (least severe)
)
# Step 2: Derive binary labels
drought_damage_binary = (wipfelduerre_binary | totholz_binary | belaubung_auffaellig_binary).astype(int)
drought_damage_severe = (drought_damage_score >= 3).astype(int)
Example Tree:
Tree 12345:
2019: Wipfeldürre=J, Totholz=N, Belaubung=J → score = 3+0+1 = 4
2022: Wipfeldürre=N, Totholz=J, Belaubung=N → score = 0+2+0 = 2
Combined (MAX aggregation across years):
wipfelduerre_binary = 1 (max of 1, 0)
totholz_binary = 1 (max of 0, 1)
belaubung_auffaellig_binary = 1 (max of 1, 0)
→ drought_damage_score = 3+2+1 = 6
→ drought_damage_binary = 1 (any damage present)
→ drought_damage_severe = 1 (score 6 ≥ 3)
Why Two Derived Labels?
drought_damage_binary: Simple OR logic (any symptom = positive). In the earlier broad historical setup this was 99.7% positive, and in the current 2018-2024 training dataset it is still 97.1% positive. It therefore remains too imbalanced for primary training use.drought_damage_severe: Threshold-based (requires multiple symptoms OR severe symptom). In the earlier broad historical setup it was 25.8% positive; in the current 2018-2024 setup it is 22.1% positive in the final training dataset. The target stays trainable while becoming more specifically tied to the drought period.Train primarily on drought_damage_severe (currently the best-balanced label with sufficient sample size, while still allowing comparison to the older 25.8% baseline).
Avoid training on drought_damage_binary (it was already unusable at 99.7% positive in the old setup and is still massively imbalanced at 97.1% positive in the current 2018-2024 training dataset).
┌─────────────────────────────────────────────────────────────────────┐
│ ML Pipeline Components │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ 1. LABELS (bluegreencity_ki/ml/labels/) │
│ extract_drought_damage_labels() │
│ → wipfelduerre_binary, totholz_binary, ... │
│ → drought_labels.csv │
│ │
│ 2. TRAINING DATA (python -m bluegreencity_ki.ml.training.dataset_builder) │
│ Join: PostGIS features + CSV labels │
│ → training_dataset.csv │
│ │
│ 3. TRAINING (bluegreencity_ki/ml/training/trainer.py) │
│ AutoGluonTrainer.train_all_labels() │
│ → Model: TabularPredictor with 5-fold bagging │
│ → Artefacts: leaderboards, feature_importances │
│ → Output: data/models/autogluon/<label>/ │
│ │
│ 4. INFERENCE (bluegreencity_ki/ml/inference/predictor.py) │
│ DroughtRiskPredictor.predict_proba() │
│ DroughtRiskPredictor.classify_risk() │
│ confidence(), class_margin(), explain_top_drivers() │
│ → Integrated into risk_scoring pipeline │
│ → Output: score, class, confidence, margin, local drivers │
│ │
│ 5. VALIDATION (bluegreencity_ki/ml/validation/validator.py) │
│ ModelValidator.run_all() │
│ → Overall metrics (ROC-AUC, Brier, F1) │
│ → Species-specific performance │
│ → Calibration & extreme-group analysis │
│ → Comparison with static weighted sum │
│ → Output: data/validation/autogluon/ │
│ │
└─────────────────────────────────────────────────────────────────────┘
Script: scripts/ml/generate_ml_labels.py
Input: data/input/trees/Baum-2010-2026-v4.csv
Code:
# bluegreencity_ki/ml/labels/label_extractor.py
@staticmethod
def extract_drought_damage_labels(
csv_path: str,
start_year: int | None = 2018,
cutoff_year: int = 2024,
) -> pd.DataFrame:
"""
Extract four binary labels + score from crown diagnosis data.
Logic:
1. Read CSV with crown diagnosis columns
2. Filter to the configured drought window (2018-2024 for training)
3. For each tree ID, aggregate MAX across years
4. Encode: "J" → 1, "N" → 0, else NaN
5. Compute score: Wip*3 + Tot*2 + Bel*1
6. Derive severity: score ≥ 3 → 1
Returns:
DataFrame with columns:
- objectid (tree ID)
- wipfelduerre_binary
- totholz_binary
- belaubung_auffaellig_binary
- drought_damage_binary (OR of above)
- drought_damage_score (0-6)
- drought_damage_severe (score ≥ 3)
- damage_years_count
- first_damage_year
"""
Output: data/derived/ml/drought_labels.csv
objectid,wipfelduerre_binary,totholz_binary,belaubung_auffaellig_binary,drought_damage_binary,drought_damage_score,drought_damage_severe,damage_years_count,first_damage_year
12345,1,1,1,1,6,1,2,2019
12346,0,0,1,1,1,0,1,2022
...
Entry point: python -m bluegreencity_ki.ml.training.dataset_builder
What it does:
bgcki.analysis_tree_risk_scores_ml_operational_panelsnapshot_year: One panel slice per configured year (2018–2024)objectid == IDCritical prerequisite:
python scripts/ml/build_operational_panel_snapshots.py --config config/config.yaml
Without this step, missing years or stale feature columns in
bgcki.analysis_tree_risk_scores_ml_operational_panel will directly propagate
into the training dataset.
Code:
# bluegreencity_ki/ml/training/dataset_builder.py
# Read one configured panel slice from PostGIS
query = """
SELECT objectid, ga_lang, age_value, vitality_value,
dwsi_summer_value, precip_may_oct_mm, temp_mean_may_oct_c,
snapshot_year
FROM bgcki.analysis_tree_risk_scores_ml_operational_panel
WHERE snapshot_year = 2024
"""
features = pd.read_sql(query, engine)
# Load labels
labels = LabelExtractor.extract_operational_drought_damage_labels(
csv_path="data/input/trees/Baum-2010-2026-v4.csv",
snapshot_year=2024,
label_horizon_years=1,
)
# Join
training_data = features.merge(labels, left_on="objectid", right_on="ID", how="left")
# Export
training_data.to_csv("data/derived/ml/2026_07_23_training_dataset_ml_operational_panel_2018_2024.csv", index=False)
Output: data/derived/ml/2026_07_23_training_dataset_ml_operational_panel_2018_2024.csv
24,607 rows × 57 columns
├── Tree attributes: objectid, ga_lang, st_umfang, st_durchm, kr_durchm
├── Remote sensing: obskh_volume, obskh_meanNDVI, obskh_meanGRI, ...
├── Criterion VALUES: age_value, vitality_value, street_type_value, ...
├── Labels: drought_damage_binary, drought_damage_score, damage_years_count, ...
└── No geometry column in the exported CSV
Important: Criterion VALUES (raw scores before normalization) are used, not risk scores!
Script: scripts/ml/train_autogluon.py
Code:
# bluegreencity_ki/ml/training/trainer.py
class AutoGluonTrainer:
def __init__(self, data: pd.DataFrame):
self.data = data
def prepare_features(self, label: str, excluded_columns=None) -> pd.DataFrame:
"""
Drop leak prevention:
- All OTHER labels (prevent model from cheating)
- ID columns (objectid, BaumID)
- Geometry column
Keep:
- Target label only
- Raw features (tree attributes, criterion values, RS)
"""
def train(self, config: TrainingConfig) -> Dict:
"""
Train one TabularPredictor with:
- problem_type: 'binary'
- eval_metric: 'roc_auc'
- presets: 'best_quality' (tries 10+ algos, stacks them)
- time_limit: 3600 seconds per label
- num_bag_folds: 5 (k-fold CV for bagging)
Steps:
1. Prepare features (drop other labels)
2. Split X (features) and y (target label)
3. Call TabularPredictor.fit(X, y, ...)
4. Export leaderboard (all models + scores)
5. Export feature importances (per model)
Returns:
{
'label': 'drought_damage_severe',
'model_path': 'data/models/autogluon/drought_damage_severe/',
'best_model': 'WeightedEnsemble_L2',
'leaderboard': DataFrame with all models + ROC-AUC
}
"""
AutoGluon automatically:
TabularPredictor wrapping final ensembleCLI Usage:
# All labels (1 hour each)
python -m scripts.ml.train_autogluon --config config/config.yaml
# Custom time & presets
python -m scripts.ml.train_autogluon --config config/config.yaml \
--time-limit 1800 --presets best_quality --labels drought_damage_severe
Output Directory Structure:
data/models/autogluon/
├── drought_damage_severe/
│ ├── model.pkl # Saved TabularPredictor
│ ├── metadata.json # Training info
│ ├── models/
│ │ ├── LightGBM_1/
│ │ ├── LightGBM_2/
│ │ ├── XGBoost_1/
│ │ ├── RandomForest_1/
│ │ ├── NeuralNet_1/
│ │ └── WeightedEnsemble_L2/ ← best model (usually)
│ └── preprocessor.pkl # Feature transformations
├── leaderboards/
│ └── drought_damage_severe_leaderboard.csv
└── feature_importances/
└── drought_damage_severe_importance.csv
Module: bluegreencity_ki/ml/inference/predictor.py
Code:
class DroughtRiskPredictor:
def __init__(self, model_path: str):
"""Load trained TabularPredictor from disk."""
self._predictor = TabularPredictor.load(model_path)
def predict_proba(self, data: pd.DataFrame) -> pd.Series:
"""
Return P(positive) for each tree.
Steps:
1. Align features (select expected columns only)
2. Warn if missing features
3. Call predictor.predict_proba(..., as_multiclass=False)
4. Return Series of probabilities [0, 1]
"""
def predict(self, data: pd.DataFrame) -> pd.Series:
"""Return binary predictions (0/1) at threshold=0.5."""
def classify_risk(self, data: pd.DataFrame, thresholds=None) -> pd.Series:
"""
Map probabilities to risk classes.
Thresholds (configurable, descending; first match wins):
- critical: 0.85
- high: 0.60 # from config.yaml, not part of DEFAULT_THRESHOLDS
- medium: 0.40
- low: 0.00
Logic:
if P >= 0.85: return "critical"
elif P >= 0.60: return "high"
elif P >= 0.40: return "medium"
else: return "low"
"""
Integration in Pipeline:
# Within bluegreencity_ki/pipelines/risk_scoring.py
if ctx.config["ml"]["enabled"]:
try:
model_path = ctx.config["ml"]["training"]["model_path"]
label = ctx.config["ml"]["inference"]["label"]
full_path = f"{model_path}/{label}"
predictor = DroughtRiskPredictor(full_path)
trees["drought_risk_ml"] = predictor.predict_proba(trees)
trees["drought_risk_ml_class"] = predictor.classify_risk(
trees,
thresholds=ctx.config["ml"]["thresholds"]
)
logger.info(f"ML scoring complete: {len(trees)} trees")
except FileNotFoundError:
logger.warning("ML model not found; skipping ML scoring")
trees["drought_risk_ml"] = None
trees["drought_risk_ml_class"] = None
Output Columns:
drought_risk_ml: float ∈ [0, 1]drought_risk_ml_class: categorical (critical/high/medium/low)Script: scripts/ml/validate_model.py
Code:
# bluegreencity_ki/ml/validation/validator.py
class ModelValidator:
def __init__(self, y_true: pd.Series, y_proba: pd.Series, threshold=0.5):
"""
Initialize validator with ground truth labels and predictions.
Args:
y_true: Binary labels (0/1)
y_proba: Predicted probabilities [0, 1]
threshold: Decision threshold for binary metrics
"""
def run_all(self, species=None, baseline_scores=None) -> ValidationResult:
"""
Execute full validation suite:
1. Overall metrics (ROC-AUC, Brier, precision, recall, F1)
2. Extreme-group comparison (top 5% vs bottom 5% lift)
3. Calibration curve (predicted vs observed frequency, 10 bins)
4. Species-specific metrics (per-baumart performance)
5. Baseline comparison (ML vs weighted-sum correlation)
6. Temporal holdout (optional: train/test by year)
Returns:
ValidationResult dataclass with all metrics
"""
CLI Usage:
python -m scripts.ml.validate_model --config config/config.yaml
Output: data/validation/autogluon/
overall_metrics.csv – Single row with ROC-AUC, F1, precision, recall, etc.calibration_curve.csv – 10 bins: predicted vs observed frequencyextreme_group.csv – Top/bottom 5%: prevalence, liftspecies_metrics.csv – Per-baumart: n, ROC-AUC, F1, …baseline_comparison.csv – Spearman correlation, disagreement rateAutoGluon trains an ensemble of ensembles using stacking:
hyperparameters in config/config.yaml, so feature
ablations are comparable under the same reproducible model families):
min_time_limit: 300)Typical Result: WeightedEnsemble_L2 (final model) achieves 3–5% higher ROC-AUC than best single model.
Configured in config/config.yaml:
ml:
training:
presets: "best_quality" # best_quality, high_quality, medium_quality, good_quality
time_limit: 3600 # seconds per label
eval_metric: "roc_auc" # for binary classification
num_bag_folds: 5 # k-fold for bagging
| Preset | Time | Models Tried | Use Case |
|---|---|---|---|
good_quality |
5–10 min | ~8 models | Quick prototyping |
medium_quality |
10–20 min | ~12 models | Testing & CI/CD |
high_quality |
20–40 min | ~20 models | Production (balanced) |
best_quality |
30–60 min | ~30 models | Maximum performance |
The productive “audit” logic now lives in bluegreencity_ki/ml/training/audit.py and is used directly by the trainer to keep AutoGluon portfolios runnable on the workstation. It is no longer a separate operational audit workflow.
select_hyperparameter_portfolio() performs three checks before training starts:
TABICL or TABDPT.ag_args.max_rows limit is below the current training dataset size.The resulting selection metadata is returned as PortfolioFamilySelection records and logged by the trainer. This makes it visible why a family was kept, excluded for configuration reasons, skipped because of missing dependencies, or removed because of row limits.
This filtering is enabled through the training configuration in config/config.yaml, especially:
hyperparameters_configauto_filter_portfolioignore_model_constraints_familiesmodel_family_hyperparameter_overridesThat combination is the supported way to adapt the AutoGluon portfolio to the local workstation without maintaining a separate audit script.
The training CLI now exposes the most important portfolio controls directly, so you can steer runs without rewriting the main config each time.
Common usage patterns are:
--hyperparameters-config--auto-filter-portfolio--include-model-families and --exclude-model-families--ignore-model-constraints-families--num-bag-sets and --num-stack-levels--fit-kwargs-json, for example {"dynamic_stacking": false}In practice this means the same training script can be used for both productive runs and controlled comparison experiments, while the fachliche interpretation still stays tied to the chosen feature set and label definition.
After training, export feature importance:
trainer.export_feature_importances("data/models/autogluon/feature_importances/")
Output: drought_damage_severe_importance.csv
| feature | importance | model |
|---|---|---|
| vitalitaet | 0.2456 | WeightedEnsemble_L2 |
| pflanzjahr | 0.1823 | WeightedEnsemble_L2 |
| obskh_hoehe | 0.1234 | WeightedEnsemble_L2 |
| ndvi_value_2024 | 0.0987 | WeightedEnsemble_L2 |
| art | 0.0876 | WeightedEnsemble_L2 |
Interpretation: Vitality score and tree age are most predictive features.
AutoGluon’s native predictor.feature_importance() remains the default global permutation-ranking export. For BMUV KI-Leuchttürme transparency reporting, the trained TabularPredictor can additionally be explained post-hoc with model-agnostic Kernel SHAP via scripts/ml/explain_autogluon_shap.py. The script explains the positive-class drought-damage probability, so it works for stacked AutoGluon ensembles.
Example:
python -m scripts.ml.explain_autogluon_shap \
--model-path data/models/autogluon/operational/2026_10/train_2018_2024__predict_2025/model/drought_risk_t1 \
--background-sample-size 100 \
--explain-sample-size 25 \
--summary-output data/models/autogluon/operational/2026_10/train_2018_2024__predict_2025/shap/drought_risk_t1_summary.png \
--waterfall-output data/models/autogluon/operational/2026_10/train_2018_2024__predict_2025/shap/drought_risk_t1_waterfall_row0.png
Without --data, the script derives the authoritative pipeline CSV from config/config.yaml via output.path and the active study area. Pass --data only when you intentionally want to explain a different export.
Outputs for reporting:
*_summary.png / *_summary.pdf – global SHAP summary plot for the sampled explanation rows.*_waterfall_row*.png / *_waterfall_row*.pdf – local per-tree waterfall plot for a selected row.Kernel SHAP is computationally expensive. Start with the default 100 background rows and increase toward 200 only when runtime allows. Limit the explanation sample to representative records, for example the highest-risk trees or a balanced inspection subset.
For BMUV progress reporting, reference SHAP as the XAI measure for ML transparency. The AutoGluon ensemble is treated as a black-box probability function and explained with Kernel SHAP on a subsampled background dataset. The exported global summary plot documents feature-level drivers across representative trees. The waterfall plot documents the feature contributions for an individual tree prediction.
When ml.enabled=true, the pipeline:
ml.inference.labelpredict_proba() → returns probabilitiesclassify_risk() → maps probabilities to risk classesconfidence() and class_margin() for downstream prioritizationThe production default is a lightweight, model-agnostic perturbation approximation, not SHAP. For each eligible feature, it compares the original probability with the probability obtained after replacing only that feature with its fixed training reference value:
contribution_j = p(tree) - p(tree with feature_j set to training reference_j)
Numeric reference values are medians and categorical reference values are modes
calculated from the exact rows used to train the deployed model. Training writes
these values to explanation_reference_profile.json beside the AutoGluon model.
All inference batches therefore use the same reference values. Models created
before this artifact was introduced log a warning and fall back to batch
median/mode references.
The current production approximation:
p_value < 0.05, calculated on all fitted training rows,all or probability tails),Default behaviour in the current pipeline configuration:
scope: "all"top_k: 3 limits the displayed positive and negative driversThe contributions are individual counterfactual differences. They are not additive, do not sum to the predicted probability, and do not establish causal effects. Correlated features can also produce combinations that are uncommon in the training data. Candidate-selection methodology and runtime/quality tradeoffs are tracked separately in GitHub issue 198.
Inference now reports progress in the terminal in the same spirit as the criterion loop:
ML inference for batched probability scoringML prediction for batched label prediction when usedML explanations for local driver evaluation across candidate featuresThese progress bars are especially useful for large city-wide runs where inference and local explanation operate on tens of thousands of rows.
# bluegreencity_ki/ml/inference/predictor.py
DEFAULT_THRESHOLDS = {
"critical": 0.85,
"medium": 0.40,
"low": 0.00,
}
Mapping:
Override in config.yaml (the configured thresholds always win over the code default):
ml:
thresholds:
critical: 0.85
high: 0.60
medium: 0.40 # ← adjust via threshold analysis
low: 0.00
If model not found:
try:
predictor = DroughtRiskPredictor(model_path)
except FileNotFoundError:
logger.warning("ML model not found; skipping ML scoring")
# Continue pipeline without ML columns
Pipeline does not crash if ML model missing.
Output Columns:
drought_risk_ml: probability score in [0, 1]drought_risk_ml_class: categorical class (critical, high, medium, low)drought_risk_ml_confidence: confidence proxy based on distance to 0.5drought_risk_ml_class_margin: distance to the nearest class thresholddrought_risk_ml_top_positive_drivers_approx: semicolon-separated positive local drivers (perturbation approximation)drought_risk_ml_top_negative_drivers_approx: semicolon-separated negative local drivers (perturbation approximation)drought_risk_ml_top_positive_drivers_shap: semicolon-separated positive SHAP drivers (SHAP stage)drought_risk_ml_top_negative_drivers_shap: semicolon-separated negative SHAP drivers (SHAP stage)Computed at decision threshold (default: 0.5):
| Metric | Formula | Good Value |
|---|---|---|
| Precision | TP / (TP + FP) | > 0.7 |
| Recall | TP / (TP + FN) | > 0.7 |
| F1 | 2 × (Prec × Rec) / (Prec + Rec) | > 0.7 |
| Accuracy | (TP + TN) / Total | > 0.75 |
Threshold-independent metric:
Implementation (custom numpy):
def roc_auc_score(y_true: pd.Series, y_proba: pd.Series) -> float:
"""Compute ROC-AUC using trapezoid rule."""
# Sort by predicted probability
order = np.argsort(y_proba)[::-1]
y_sorted = y_true.iloc[order].values
# Compute TPR and FPR at each threshold
tps = np.cumsum(y_sorted)
fps = np.cumsum(1 - y_sorted)
tpr = tps / tps[-1]
fpr = fps / fps[-1]
# Integrate using trapezoid rule
return np.trapz(tpr, fpr)
Mean squared error of probabilities:
\[\text{Brier} = \frac{1}{n} \sum_{i=1}^{n} (p_i - y_i)^2\]Tests if model probabilities match reality:
def calibration_curve(y_true, y_proba, n_bins=10) -> pd.DataFrame:
"""
Group predictions into bins and compare predicted vs observed frequency.
Returns:
DataFrame with columns: bin_mid, predicted_mean, observed_freq, count
"""
Example output:
| bin_mid | predicted_mean | observed_freq | count |
|---|---|---|---|
| 0.05 | 0.048 | 0.10 | 342 |
| 0.15 | 0.155 | 0.18 | 521 |
| 0.50 | 0.502 | 0.48 | 634 |
| 0.95 | 0.946 | 0.92 | 287 |
Interpretation: If predicted_mean ≈ observed_freq for all bins, model is well-calibrated.
Compare damage prevalence in top 5% vs bottom 5% of predicted risk:
def extreme_group_comparison(scores, labels, quantile=0.05) -> Dict:
"""
Compute lift: prevalence in top 5% / prevalence in bottom 5%.
Returns:
{
'top_prevalence': 0.85,
'bottom_prevalence': 0.15,
'lift': 5.67,
'top_n': 1292,
'bottom_n': 1292
}
"""
Example:
Top 5%: 85% have damage
Bottom 5%: 15% have damage
→ Lift = 85 / 15 = 5.67×
Compute all metrics per tree species (min. 30 samples):
def compute_species_metrics(species: pd.Series, min_samples=30) -> Dict[str, Dict]:
"""
Returns:
{
"Tilia cordata": {n: 4251, roc_auc: 0.81, f1: 0.71, ...},
"Quercus robur": {n: 892, roc_auc: 0.75, f1: 0.64, ...},
...
}
"""
Use case: Identify species where model struggles (e.g., less training data, different biology).
Compare ML predictions with existing weighted-sum baseline:
def compare_with_baseline(baseline_scores: pd.Series) -> Dict:
"""
Returns:
{
'spearman': 0.42, # Rank correlation ML ↔ baseline
'disagreement_rate': 0.23, # % trees with different risk class
'ml_corr_with_truth': 0.38, # ML ↔ actual labels
'baseline_corr_with_truth': 0.007 # Baseline ↔ actual labels
}
"""
Key insight: ML model correlates much better with actual damage than static weighted sum (0.38 vs 0.007).
The trained model outputs probabilities (e.g., 0.72 = 72% chance of damage). To make binary decisions (“at risk” / “not at risk”), a threshold is needed.
Trade-off:
The right threshold depends on your use case, not on model quality.
threshold_analysis.pyComputes metrics at thresholds 0.10–0.90 using out-of-fold (OOF) predictions:
python scripts/ml/threshold_analysis.py
Example output:
| Threshold | Precision | Recall | F1 | Trees Flagged | % of Stock |
|---|---|---|---|---|---|
| 0.20 | 57.4% | 88.4% | 0.696 | 10,972 | 40.9% |
| 0.35 | 67.9% | 78.3% | 0.728 | 8,218 | 30.6% |
| 0.50 | 77.3% | 64.6% | 0.703 | 5,954 | 22.2% |
| 0.70 | 88.4% | 45.0% | 0.596 | 3,630 | 13.5% |
| 0.90 | 95.2% | 18.3% | 0.307 | 1,364 | 5.1% |
Interpretation (threshold = 0.35):
Recommendation process:
threshold_analysis.py to generate table| Use Case | Threshold | Reasoning |
|---|---|---|
| Prevention-focused (protect at-risk trees) | 0.35 | Recall ≥ 78% (catch most damaged trees) |
| Balanced (general use) | 0.50 | Precision ≈ Recall (77% vs 65%) |
| Resource-constrained (limited budget) | 0.70 | Precision ≥ 88% (only high-confidence trees) |
config/config.yaml:ml:
thresholds:
critical: 0.85 # first match wins (descending)
high: 0.30
medium: 0.10 # ← your chosen threshold
low: 0.00
Threshold analysis must use out-of-fold (OOF) predictions from cross-validation, not direct predictions on training data:
| Method | ROC-AUC (June 2026 reference run) | Reliable? | Reason |
|---|---|---|---|
predict_proba(training_data) |
1.000 | ❌ | Overfitted (model seen this data) |
predict_proba_oof() |
0.9937 | ✅ | Honest row-wise CV predictions (unseen rows) |
grouped CV by objectid |
0.8817 | ✅✅ | Honest panel-aware CV predictions with same-tree rows kept together |
OOF predictions are derived from k-fold cross-validation used during training:
For the operational panel dataset, this row-wise logic is still not the full story because the same tree can appear multiple times across years. That is why the project now also keeps a separate grouped-CV reference run by objectid.
How to get OOF predictions:
from autogluon.tabular import TabularPredictor
# point at the model directory the run created
# (operational runs: data/models/autogluon/operational/<YYYY_MM>_<run>/model/<label>/)
predictor = TabularPredictor.load("<run_model_dir>")
# ✅ Honest (for threshold analysis)
proba_oof = predictor.predict_proba_oof()
# ❌ Overfitted (DO NOT use for threshold analysis)
proba_direct = predictor.predict_proba(training_data)
Enable and configure ML scoring in config/config.yaml.
The current main benchmark uses an explicit stable reference portfolio instead of the earlier experimental extreme track:
ml:
# Enable ML scoring in pipeline (requires trained model)
enabled: true
# Path to training dataset CSV
training_data: "data/derived/ml/training_dataset_v6_operational_panel_2018_2024.csv"
# Crown diagnosis CSV for label extraction
drought_csv: "data/input/trees/Baum-2010-2025-v6_historisch_prod.csv"
labeling:
strategy: "operational_panel_drought_damage"
snapshot_year: 2025
label_horizon_years: 1
target_label: "future_drought_damage_severe_t1"
# AutoGluon training parameters
training:
model_path: "data/models/autogluon" # Base directory for saved models
presets: "best_quality" # Training shell; portfolio is explicitly defined below
time_limit: 3600 # Seconds per label (2026-09-15: 2400 -> 3600 to
# leave budget for NN_TORCH and the L2 stacking level)
eval_metric: "roc_auc" # Evaluation metric (for binary classification)
num_bag_folds: 5 # Cross-validation folds for bagging
num_gpus: 1
fit_kwargs:
dynamic_stacking: false # DyStack is unnecessary; num_stack_levels=1
hyperparameters:
GBM:
- extra_trees: true # GBM (XT variant)
ag_args:
name_suffix: "XT"
ag_args_fit:
num_gpus: 0
- ag_args_fit:
num_gpus: 0
CAT:
ag_args_fit:
num_gpus: 0
XGB:
ag_args_fit:
num_gpus: 0
NN_TORCH:
ag_args_fit:
num_gpus: 1
min_time_limit: 300 # Secured time share so that NN_TORCH can compete
RF:
- criterion: "gini"
ag_args:
name_suffix: "Gini"
ag_args_fit:
num_gpus: 0
- criterion: "entropy"
ag_args:
name_suffix: "Entr"
ag_args_fit:
num_gpus: 0
XT:
- criterion: "gini"
ag_args:
name_suffix: "Gini"
ag_args_fit:
num_gpus: 0
- criterion: "entropy"
ag_args:
name_suffix: "Entr"
ag_args_fit:
num_gpus: 0
# Which model to use for pipeline scoring
inference:
label: "future_drought_damage_severe_t1" # Model is auto-resolved: <training.model_path>/<label>/
# Per-tree local explanation for all trees by default
local_explainability:
enabled: true
scope: "all" # all | extreme_groups | none
extreme_group_quantile: 0.05 # only used when scope=extreme_groups
top_k: 3 # number of driver strings per side
# feature_importance_path: optional override; defaults to the training-only
# importance artifact stored beside the resolved model
# Probability → risk class thresholds (descending; first match wins)
thresholds:
critical: 0.85
high: 0.30
medium: 0.10
low: 0.00
| Parameter | Options | Description |
|---|---|---|
enabled |
true/false | Enable ML scoring in pipeline |
presets |
best_quality, high_quality, medium_quality, good_quality |
Training time vs. quality trade-off |
time_limit |
seconds | Max training time per label |
eval_metric |
roc_auc, f1, accuracy |
Metric for model selection (use roc_auc for imbalanced binary) |
num_bag_folds |
2-10 | K-fold CV for bagging (5 is standard) |
hyperparameters |
mapping | Explicit benchmark portfolio; preferred for stable reference runs and fair ablations |
hyperparameters_config |
AutoGluon portfolio name | Base model family portfolio, e.g. zeroshot_2025_12_18_gpu |
auto_filter_portfolio |
true/false | Filter unusable families and variants before training |
ignore_model_constraints_families |
family list | Keep selected families even if AutoGluon marks them constrained |
model_family_hyperparameter_overrides |
mapping | Per-family overrides such as GPU settings or TABDPT.use_flash=false |
num_gpus |
integer | Number of GPUs exposed to trainer families that support GPU |
label |
label name | Which trained model to use for inference |
local_explainability.enabled |
true/false | Enable or disable local driver generation |
local_explainability.scope |
all, extreme_groups, none |
Whether explanations are computed for all rows, only extremes, or disabled. Production default is all. |
local_explainability.extreme_group_quantile |
0.0-0.5 | Tail share used for low/high extreme groups when scope=extreme_groups |
local_explainability.top_k |
integer | Number of positive/negative drivers returned per tree |
local_explainability.feature_importance_path |
path | CSV used to rank candidate features before local explanation |
thresholds |
floats | Probability cutoffs for risk classes |
For full-city production runs, scope: all computes driver strings for every tree. If you need a shorter diagnostic run, switch to extreme_groups or none temporarily.
ml.enabled=true)Standard pipeline output (GPKG, PostGIS) includes the static score plus the ML scoring bundle:
| Column | Type | Description | Example |
|---|---|---|---|
drought_risk_ml |
float [0, 1] | P(drought damage) from AutoGluon | 0.7234 |
drought_risk_ml_class |
categorical | Risk class based on thresholds | “high” |
drought_risk_ml_confidence |
float [0, 1] | Confidence proxy based on distance from 0.5 | 0.4468 |
drought_risk_ml_class_margin |
float [0, 1] | Distance to the nearest threshold boundary | 0.1234 |
drought_risk_ml_top_positive_drivers_approx |
string | Positive local drivers (approximation) for configured rows | “vitality_value:+0.0522; m_svf_value:+0.0479” |
drought_risk_ml_top_negative_drivers_approx |
string | Negative local drivers (approximation) for configured rows | “ndvi_change_2020to2024:-0.0003” |
drought_risk_ml_top_positive_drivers_shap |
string | Positive SHAP drivers (SHAP stage) | “vitality_value:+0.0480” |
drought_risk_ml_top_negative_drivers_shap |
string | Negative SHAP drivers (SHAP stage) | “m_svf_value:-0.0011” |
overall_drought_risk |
float [0, 1] | Weighted sum (unchanged) | 0.6421 |
Example:
objectid,art,pflanzjahr,drought_risk_rulebased,drought_risk_ml,drought_risk_ml_class,drought_risk_ml_confidence,drought_risk_ml_class_margin,drought_risk_ml_top_positive_drivers_approx,drought_risk_ml_top_negative_drivers_approx
12345,Tilia cordata,1985,0.642,0.723,high,0.446,0.123,"vitality_value:+0.0522; m_svf_value:+0.0479","ndvi_change_2020to2024:-0.0003"
12346,Quercus robur,2005,0.321,0.145,low,0.710,0.145,"m_svf_value:+0.0310; kr_durchm:+0.0201","age_value:-0.0148"
12347,Acer platanoides,1995,0.558,0.812,critical,0.624,0.062,"dist_road_m:+0.0117; vitality_value:+0.0104",""
In the production default (scope: all), every tree receives driver strings in
both columns. In extreme_groups mode the two columns stay empty for the
middle bulk of the dataset and are only populated for the configured low/high
score tails.
Run scripts/ml/validate_model.py to generate:
data/validation/autogluon/overall_metrics.csvSingle row with classification metrics:
| Column | Example | Meaning |
|---|---|---|
roc_auc |
0.8421 | ROC-AUC (threshold-independent); >0.7 is good |
brier_score |
0.1245 | Mean squared error of probabilities; <0.25 is good |
precision |
0.6789 | Of flagged trees, 68% truly have damage |
recall |
0.7830 | Of damaged trees, 78% are detected |
f1 |
0.7284 | Harmonic mean (balanced metric) |
accuracy |
0.7124 | Overall correctness |
n |
25834 | Total trees evaluated |
threshold |
0.35 | Decision threshold used |
data/validation/autogluon/extreme_group.csvTop vs bottom 5% comparison:
| Column | Example | Meaning |
|---|---|---|
quantile |
0.05 | Top/bottom 5% |
top_prevalence |
0.8524 | 85% of top 5% have damage |
bottom_prevalence |
0.1502 | 15% of bottom 5% have damage |
lift |
5.67 | Top group is 5.67× more likely |
top_n |
1292 | Trees in top 5% |
bottom_n |
1292 | Trees in bottom 5% |
data/validation/autogluon/calibration_curve.csvReliability diagram (10 bins):
| bin_mid | predicted_mean | observed_freq | count |
|---|---|---|---|
| 0.05 | 0.048 | 0.10 | 342 |
| 0.15 | 0.155 | 0.18 | 521 |
| 0.50 | 0.502 | 0.48 | 634 |
| 0.95 | 0.946 | 0.92 | 287 |
Good calibration: predicted_mean ≈ observed_freq across all bins.
data/validation/autogluon/species_metrics.csvPer-baumart metrics (min. 30 trees):
| species | n | roc_auc | f1 | precision | recall |
|---|---|---|---|---|---|
| Tilia cordata | 4251 | 0.8124 | 0.7056 | 0.6842 | 0.7289 |
| Quercus robur | 892 | 0.7542 | 0.6385 | 0.6201 | 0.6589 |
| Acer platanoides | 567 | 0.7812 | 0.6814 | 0.6623 | 0.7021 |
data/validation/autogluon/baseline_comparison.csvML vs weighted-sum comparison:
| Column | Example | Meaning |
|---|---|---|
spearman |
0.42 | Rank correlation ML ↔ baseline |
disagreement_rate |
0.23 | 23% trees get different risk class |
ml_corr_with_truth |
0.38 | ML’s correlation with actual labels |
baseline_corr_with_truth |
0.007 | Baseline’s correlation (near zero!) |
After training with scripts/ml/train_autogluon.py:
data/models/autogluon/<label>/Trained model directory:
drought_damage_severe/
├── model.pkl # Saved TabularPredictor
├── metadata.json # Training info (time, presets, etc.)
├── models/ # Individual models
│ ├── LightGBM_1/
│ ├── LightGBM_2/
│ ├── XGBoost_1/
│ ├── RandomForest_1/
│ ├── NeuralNet_1/
│ └── WeightedEnsemble_L2/ ← Final ensemble (best)
└── preprocessor.pkl # Feature transformations
data/models/autogluon/leaderboards/<label>_leaderboard.csvAll trained models with scores:
| model_name | score_val | score_test | fit_time | pred_time |
|---|---|---|---|---|
| WeightedEnsemble_L2 | 0.8421 | 0.8387 | 1823.4 | 0.45 |
| LightGBM_2 | 0.8312 | 0.8269 | 142.3 | 0.12 |
| XGBoost_1 | 0.8289 | 0.8251 | 198.7 | 0.15 |
| RandomForest_1 | 0.8156 | 0.8124 | 256.1 | 0.34 |
data/models/autogluon/shap/Kernel SHAP explainability artefacts for reporting:
<label>_summary.png # global SHAP summary plot
<label>_summary.pdf # optional print/report version
<label>_waterfall_row0.png # local explanation for one selected tree
<label>_waterfall_row0.pdf # optional print/report version
data/models/autogluon/feature_importances/<label>_importance.csvFeature importance (for best model):
| feature | importance | model |
|---|---|---|
| vitalitaet | 0.2456 | WeightedEnsemble_L2 |
| pflanzjahr | 0.1823 | WeightedEnsemble_L2 |
| obskh_hoehe | 0.1234 | WeightedEnsemble_L2 |
| ndvi_value_2024 | 0.0987 | WeightedEnsemble_L2 |
| Problem | Solution |
|---|---|
| Training takes too long | Reduce time_limit (e.g., 600s) or use presets="medium_quality" |
| Out of memory | Reduce num_bag_folds (e.g., 3 instead of 5) or use smaller dataset |
| “No space left on device” | AutoGluon saves many models; clear data/models/autogluon/ or increase disk space |
| Training crashes with Java error | Ensure Java 8+ installed (required for CatBoost) |
| RealTabPFN fails on Windows during import/setup | The trainer now applies a Windows-specific runtime workaround automatically when a TabPFN family is actually selected. If TabPFN still fails, treat it as an optional family and continue with the remaining portfolio. |
| GPU run falls back to CPU | In the stable reference benchmark this is expected for GBM, CAT, XGB, RF, and XT; only NN_TORCH is assigned GPU. If GPU boosting is required, first install and verify matching GPU-enabled LightGBM/XGBoost/CatBoost builds on this workstation. |
| Problem | Solution |
|---|---|
| Model not found at inference | Ensure training completed successfully; check data/models/autogluon/<label>/ exists |
| NaN values in predictions | Ensure input data has all expected feature columns; check logs for “missing features” warning |
| “Missing 50 expected features” | Training and inference data have different columns; re-create training dataset |
Pipeline crashes with ml.enabled=true |
Check try/except block in risk_scoring.py; should log warning and continue |
| Problem | Solution |
|---|---|
| ROC-AUC < 0.55 | Model not better than random; check data quality (labels correct? features complete?) |
| High Brier score (>0.3) | Poor calibration; consider calibration layer or use predict_proba_oof() |
| Lift ≈ 1.0 | Model doesn’t discriminate; check feature importance for uninformative features |
| High disagreement with baseline | Expected! Models learn different feature importance; OK if ML is more predictive |
| Problem | Solution |
|---|---|
| Too few labeled trees (<1000) | Widen the drought window (earlier drought_start_year or later drought_cutoff_year) or use the simpler label (drought_damage_binary) |
| Extreme imbalance (>99% positive) | Use drought_damage_severe instead of drought_damage_binary |
| Many NaN values in features | Check PostGIS query; ensure remote sensing data joined correctly |
| Duplicate tree IDs | Check CSV for duplicate entries; use MAX aggregation in label extraction |
| Problem | Solution |
|---|---|
| Threshold analysis shows ROC-AUC = 1.0 | Using direct predictions (overfitted); use predict_proba_oof() instead |
| All thresholds give same recall | Model predicts same probability for all trees; check feature variance |
| Precision/recall don’t match config threshold | Update config.yaml after running threshold_analysis.py |
Last updated: 2026-03-06
Framework: AutoGluon v1.5+
Python: 3.9+
License: Apache 2.0