rockglacierNET / development_log.md
kabiriitb's picture
Upload folder using huggingface_hub
820fab0 verified
|
Raw History Blame Contribute Delete
14.8 kB
# Rock-Glacier Segmentation β€” Complete Development Log
A chronological record of the **entire** project: every experiment, every bug, every dead end, and
every fix β€” in the order it happened. For the clean topic-organised summary see `ablation.md`; this
document is the full story.
**Final result:** 3-seed Swin-UNet ensemble + 8-way TTA β†’ **test Dice 0.668, IoU 0.501**
(5-fold CV 0.583 Β± 0.014). Data: 671 clean 10 m patches from the Kumar et al. (2026) InSAR inventory.
---
## PHASE 0 β€” Starting Point & First Diagnostics
**Context:** A 30 m model existed (val Dice 0.7618, but this used a metric that was later found to be
inflated). The team migrated to a new 10 m dataset (`patches_10m/`, 28 bands) and performance
collapsed. The investigation started here.
### 0.1 Cohen's *d* separability script review (`check_separability.py`)
Found and fixed three bugs:
1. **Pooled-std formula wrong** β€” used `(nβˆ’1)Β·pop_var` instead of `nΒ·pop_var`.
2. **Class imbalance read from band-1's filtered count**, not the raw mask β†’ wrong if band-1 had NoData.
3. **Hard-coded conclusion** ("607 pairs", "4.5:1") not computed from data. Made dynamic.
(Also: Windows absolute paths made portable.)
### 0.2 Data health analysis of the old 10 m data (temp script, then deleted)
Key findings that explained the regression:
- **NaN contamination:** ~20 % of pixels NaN across almost all 28 bands (only bands 18 & 27 clean);
267 M NaN values total; 73.6 % of patches affected.
- **Severe class imbalance:** 19.8 : 1 background : glacier (glacier β‰ˆ 4.8 % of pixels).
- **52.4 % of patches were pure background** (all-BG masks).
### 0.3 Fixed NaN normalisation bug in `data_generator.py`
Old code filled NaN with 0 *before* normalisation β†’ those pixels became `βˆ’mean/std` (a biased,
non-zero signal). Fixed to fill NaN β†’ 0 *after* normalisation (= band mean, a neutral value).
---
## PHASE 1 β€” Diagnosing the Broken Training Pipeline (old data)
Analysed the 9 existing band-selection runs (`run_12_native` … `run_28_all`). All poor (best val Dice
β‰ˆ 0.49). Found **four training-pipeline bugs**:
| # | Bug | Effect | Fix |
| :- | :--- | :--- | :--- |
| 1 | **Metric inflation** β€” empty (all-bg) batches returned `1e-6/1e-6 = 1.0` | val Dice inflated +0.35–0.45; wrong "best" epoch saved | `tf.cond` returns 0 for empty batches |
| 2 | **Loss weights inverted** β€” Ξ±_FP=0.6 > Ξ²_FN=0.4 at 19.8:1 imbalance | model learned "predict background when unsure" | flipped to Ξ±=0.3/Ξ²=0.7 (later 0.4/0.6) |
| 3 | **NaN bias** (see 0.3) | phantom signal on ~20 % of pixels | fill after normalisation |
| 4 | **LR killed early** β€” ReduceLROnPlateau(patience=5) | LR collapsed before convergence | later replaced by cosine annealing |
Other changes: `BATCH_SIZE` 8 β†’ 32 (A6000s were idle), `ModelCheckpoint` monitor β†’ `val_loss`.
**Result β€” `run_20_FIXED`:** val Dice **0.5304** (up from ~0.43). Confirmed the model was sound; the
remaining gap was a **data-quality problem**.
---
## PHASE 2 β€” Attempts to Squeeze the Old Data (mostly dead ends)
These were tried on the noisy `patches_10m` data. Most failed and were reverted.
| Attempt | Result | Verdict |
| :--- | :-- | :--- |
| `require_glacier=True` (drop 1,611 bg patches) | imbalance 19.8:1 β†’ 8.9:1 | kept |
| `min_glacier_pct=5%` filter | dropped noisy sliver patches | mixed |
| **NaN-pixel exclusion from loss** (`valid=0` on NaN) | **βˆ’0.036 Dice** | ❌ reverted β€” model over-predicted glacier on cloud pixels; the band-mean fill was useful regularisation |
| **Hard-negative mining** (`bg_keep_n=300`) | **βˆ’0.11 Dice** (0.55β†’0.42) | ❌ reverted β€” Focal-Tversky gives loss=1.0 on any FP in an all-bg patch, suppressing glacier globally |
| 256Γ—256 patches (re-extract) | underperformed | ❌ confounded by other data issues |
**Conclusion of Phase 2:** the old data (20 % NaN, mixed seasons, snow) had a hard ceiling. No training
trick could fix it. **Decision: rebuild the data.**
---
## PHASE 3 β€” New Data Collection (the pivot that worked)
### 3.1 Diagnosis of the old GEE collection
The old script used a **3-year all-season median** with only QA60 cloud masking β†’ retained cloud
shadow, haze, and seasonal snow, leaving ~20 % NaN at high altitude.
### 3.2 New pipeline design
- **Temporal window:** October–November only (post-monsoon, pre-winter) β†’ glacier exposed, no fresh
snow, minimal cloud.
- **Cloud masking:** Sentinel-2 **cloud-probability product < 35 %** (catches shadow/haze, unlike QA60).
- **Terrain:** Copernicus GLO-30 DEM derivatives computed at native res then resampled.
- **SAR:** Sentinel-1 VV Oct–Nov composite.
- Project: `rock-glacier-net`.
### 3.3 Collection bugs fixed (many)
| Bug | Fix |
| :--- | :--- |
| `gcloud not found` on headless server | notebook auth + copy `~/.config/earthengine/credentials` from laptop |
| `TopologyException` invalid geometry | `buffer(0)` + drop invalid before union |
| `focalStd` doesn't exist in EE Python | `reduceNeighborhood(stdDev)` |
| `reduceToImage` band named "first" not "glacier" | `.rename('glacier')` |
| **All-NaN patches** (100 % missing) | NIR shadow mask `nir>0.15` too aggressive at altitude β†’ removed; widened window to Sep–Nov; cloud threshold 20β†’35 % |
| **Slope/Aspect/Hillshade/TWI all NaN** | `ee.Terrain.*` needs a projected CRS β†’ reproject DEM to UTM 32645 first |
### 3.4 Key design change β€” per-glacier collection (user's idea)
Instead of one global mosaic, the pipeline (`collect_patches_gee.py`) is **driven by the polygon
inventory**: each glacier β†’ 128Γ—128 window at its centroid, with **all polygons in the window
rasterised jointly** (so adjacent glaciers are labelled correctly), then the 28-band feature patch is
clipped for that window.
### 3.5 Result β€” clean dataset `patches_v2`
- **671 patches** (613 glacier, 58 background) β†’ 469 train / 101 val / 101 test.
- **Optical NaN: 0 %** (verified across all patches β€” vs 20 % before).
- All 28 bands valid (fixed terrain). Elevation 4,500–5,900 m. Verified with multi-band plots.
> **The clean data collection was the single largest accuracy driver in the whole project.**
### 3.6 Inventory provenance (confirmed later)
The 626-polygon inventory is from **Kumar et al. (2026), *iScience*** β€” SBAS-InSAR time-series
detection (2022–2024) + STL filtering + Google Earth Pro optical refinement. Cited in the paper.
---
## PHASE 4 β€” First Training on Clean Data
| Run | Setup | Val Dice | Note |
| :--- | :--- | :-- | :--- |
| sanity (20 ep) | val=train | 0.5588 | quick check β€” data learns cleanly |
| `run_v2_full` | require_glacier + min_glacier 5 % | 0.4994 | filters *hurt* on small clean data |
| `run_v2_full2` | **all 469 patches** | 0.5514 | filtering removed β†’ better |
Lesson: on the small clean dataset, aggressive patch filtering hurts; use all patches.
---
## PHASE 5 β€” Band Selection (2 rounds, 13 experiments)
### 5.1 Cohen's *d* ranking (clean data)
Terrain dominates β€” top 4 are all terrain: Surface Roughness (0.496), Slope (0.489), TPI (0.424),
TRI (0.361). Then NDVI, B12, TWI, SAR, Elevation… Bottom 4 (NDSI, IronOxide, Plan/Profile Curvature)
are noise (*d* < 0.05).
### 5.2 All 13 experiments (Attention U-Net, val Dice)
| Config | Bands | Dice | | Config | Bands | Dice |
| :-- | :-: | :-- | :- | :-- | :-: | :-- |
| **Top-20** | 20 | **0.5700** | | Top-8 | 8 | 0.5288 |
| Top-22 | 22 | 0.5540 | | Curated-18 | 18 | 0.5143 |
| All-28 | 28 | 0.5514 | | Terrain+SAR | 8 | 0.5086 |
| Top-24 | 24 | 0.5497 | | Terrain-only | 10 | 0.4738 |
| Top-16 | 16 | 0.5448 | | Optical-only | 12 | 0.4465 |
| Top-12 | 12 | 0.5438 | | Top-18 | 18 | 0.5355 |
| Top-20 βˆ’ SAR | 19 | 0.5341 | | | | |
**Winner: Top-20** (0.5700). Adding the bottom-4 noise bands (24, 28) hurts.
### 5.3 Modality ablation
Terrain **+0.124**, Optical **+0.062**, SAR **+0.036** β†’ terrain β‰ˆ 2Γ— optical's discriminative power.
---
## PHASE 6 β€” Model & Training Improvements (cumulative)
| Step | Change | Val Dice* | Ξ” |
| :- | :--- | :-- | :-- |
| A | Attention U-Net, 469 patches, top-20 | 0.5700 | β€” |
| B | + train on train+val (570 patches) | 0.6330 | +0.063 |
| C | + **Swin-UNet** | 0.7171 | +0.084 |
| D | + **cosine LR**, 150 ep | 0.7648 | +0.048 |
| E | + cosine LR, **250 ep** | 0.8174 | +0.053 |
\* Steps B–E used **val = train** (memorisation scores) β€” used only for relative comparison; the honest
numbers are the test-set results in Phase 7.
**Findings:** Swin-UNet gave the biggest single jump (+0.084) and near-zero overfit. Cosine annealing
beat ReduceLROnPlateau by +0.048. 250 ep converged at epoch 206 (150 ep was still rising).
---
## PHASE 7 β€” Honest Test Evaluation
Built `evaluate_ensemble.py` (multi-model, 8-way TTA, threshold sweep). Held-out test = 101 patches
never seen in training.
| Configuration | Test Dice | IoU | Prec | Rec |
| :--- | :-- | :-- | :-- | :-- |
| Single Swin-UNet | 0.6529 | 0.4847 | 0.6256 | 0.6827 |
| **3-seed ensemble (chosen)** | **0.6679** | **0.5014** | 0.6087 | **0.7398** |
| 5-fold CV ensemble | 0.6629 | 0.4957 | 0.6278 | 0.7021 |
| 8-model mega ensemble | 0.6679 | 0.5014 | 0.6482 | 0.6889 |
| 5-model copy-paste ensemble | 0.6528 | 0.4845 | 0.6328 | 0.6741 |
**5-fold CV:** 0.5770 / 0.5639 / 0.5972 / 0.5824 / 0.5948 β†’ **0.583 Β± 0.014** (very stable).
### "Why is test (0.67) > CV (0.58)?" β€” investigated, not leakage
Decomposed on fold-0 (same single model, same method):
- training-time CV metric (per-batch, thr 0.5) β†’ 0.577
- +0.013 pooled + optimal-threshold + TTA
- +0.022 test sample marginally easier (normal variation)
- +0.051 ensembling
= 0.663. All legitimate β€” the numbers are measured differently.
**Ensembling saturates:** 3, 5, 8 models all land at 0.663–0.668. Chose **3-seed** (best Dice/IoU,
fewest models, highest recall 0.740 β€” best for inventory completeness).
---
## PHASE 8 β€” Attempts to Break 0.70 (all failed / marginal)
Once clean data + Swin + cosine + ensemble was in place, tried to push higher. Every attempt A/B-tested:
| Technique | Result | Verdict |
| :--- | :-- | :--- |
| **CutOut augmentation** | 0.5214 (βˆ’0.05 vs 0.57) | ❌ too aggressive for 570 patches |
| **Copy-paste augmentation** | fold-0 +0.022, but full 5-fold CV only +0.005 (variance ↑); test ensemble **0.6528** | ❌ fold-0 gain was luck; hurt on test |
| **LovÑsz-Softmax loss** | 0.5777 vs 0.5770 (+0.0008) | ❌ neutral, not worth complexity |
| **Higher weight decay** (5e-4) | negligible | β€” |
| Mega 10-model ensemble | crashed (OOM from other user's job), rerun ~0.66 | no gain |
**Root cause of the ceiling:** the model is **data-limited, not overfit** (CV std 0.014, train-test gap
mostly a measurement artefact). No training trick moved it. Only **more labelled data** would help β€”
which the team chose not to collect.
---
## PHASE 9 β€” Baselines, Figures & Paper
### 9.1 Random-Forest baseline (the "do you need DL?" check)
Per-pixel RF (300 trees) on the same 20 bands β†’ **test Dice 0.6005**. Competitive with the CNN
baselines (matches U-Net 0.610, Attn 0.604; beats SegFormer 0.555), but well below Swin (0.653).
**Message:** deep learning helps here *only* with the right inductive bias.
### 9.2 Four-architecture benchmark (held-out test)
| Model | Dice | IoU | Prec | Rec |
| :--- | :-- | :-- | :-- | :-- |
| Random Forest | 0.6005 | 0.4291 | 0.5190 | 0.7125 |
| Plain U-Net | 0.6104 | 0.4392 | 0.6072 | 0.6135 |
| Attention U-Net | 0.6035 | 0.4322 | 0.5910 | 0.6166 |
| SegFormer (MiT-B0) | 0.5547 | 0.3838 | 0.5056 | 0.6145 |
| **Swin-UNet** | **0.6529** | **0.4847** | **0.6256** | **0.6827** |
SegFormer had the best *validation* but generalised worst on test β€” global attention over-adapts on a
small dataset; Swin's windowed attention is the better inductive bias.
### 9.3 Figures & paper
- `make_paper_figures.py`: georeferenced (lat/lon) 5-panel per-patch figures, top-5 detailed,
best/worst montages (GT-yellow vs pred-cyan outlines), training curves.
- `paper.tex`: 3 TikZ diagrams (data pipeline, Swin-UNet architecture, ensemble inference) + 7 tables
+ 6 figures. Title finalised: *"A Multi-Modal Deep-Learning Benchmark for Rock Glacier Mapping in the
Sikkim Himalaya at 10 m Resolution."* Inventory cited (Kumar et al., 2026). No named "novel model" β€”
framed honestly as an application/benchmark study.
---
## Full Journey β€” One Table
| # | Milestone | Score | Type |
| :- | :--- | :-- | :--- |
| 1 | Old 10 m attempt (noisy data) | 0.5304 | val |
| 2 | Clean data + band selection (Attn) | 0.5700 | val |
| 3 | + train on train+val | 0.6330 | val* |
| 4 | + Swin-UNet | 0.7171 | val* |
| 5 | + cosine LR, 250 ep | 0.8174 | val* |
| 6 | **Single Swin-UNet** | **0.6529** | **test** |
| 7 | **3-seed ensemble + TTA (final)** | **0.6679** | **test** |
| 8 | 5-fold cross-validation | 0.583 Β± 0.014 | CV |
\* val = train (memorisation). Bold rows are the honest held-out test numbers reported in the paper.
---
## Everything That FAILED (so it isn't retried)
- ❌ NaN-pixel exclusion from loss (βˆ’0.04)
- ❌ Hard-negative background mining (βˆ’0.11)
- ❌ CutOut augmentation (βˆ’0.05)
- ❌ Copy-paste augmentation (fold-luck; washed out; hurt on test)
- ❌ LovÑsz-Softmax loss (neutral)
- ❌ 256Γ—256 patches (underperformed)
- ❌ Larger ensembles beyond 3 models (saturated at ~0.667)
- ❌ Higher weight decay (negligible)
## Everything That WORKED
- βœ… Fixing the 4 pipeline bugs (metric inflation, loss weights, NaN, LR) β€” restored the model
- βœ… **Clean Oct–Nov cloud-masked data collection (0 % NaN)** β€” biggest single gain
- βœ… Per-glacier collection with joint polygon rasterisation (correct adjacent-glacier labels)
- βœ… 20-band selection (terrain-dominated)
- βœ… Swin-UNet architecture (+0.084)
- βœ… Cosine annealing LR (+0.048)
- βœ… Training on train+val for the final model
- βœ… 3-seed ensemble + 8-way TTA (+0.015)
- βœ… Random-Forest baseline (proves DL is justified)
---
## The Single Most Important Lesson
**It was a data problem, not a model problem.** The model was sound from early on; the 10 m regression
was caused by ~20 % NaN pixels, metric bugs, and inverted loss weights. Rebuilding the data collection
(clean post-monsoon cloud-masked composites) delivered the largest gain, and the final ceiling
(~0.67 test) is set by dataset size (671 patches, CV std 0.014 = near-zero overfitting), not by the
architecture. More labelled data β€” not more training tricks β€” is the path beyond 0.67.
---
*This log records the full experimental history from the chat session. Numbers are from actual runs
stored under `bandselection/`. See `ablation.md` for the topic-organised summary and `paper.tex` for
the manuscript.*