-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy path_train_stage.py
More file actions
2093 lines (1938 loc) · 111 KB
/
Copy path_train_stage.py
File metadata and controls
2093 lines (1938 loc) · 111 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# -*- coding: utf-8 -*-
"""
GeoFormerX pre-training script
"""
from __future__ import annotations
# ==========================
# Use a non-interactive Matplotlib backend for stable training logs
# ==========================
import os
os.environ["MPLBACKEND"] = "Agg"
import argparse
import faulthandler
join = os.path.join
import time
import json
from collections import defaultdict
faulthandler.enable(all_threads=True)
import numpy as np
import random
import torch
import torch.nn as nn
from torch.nn import functional as F
from torch.utils.data import DataLoader, WeightedRandomSampler
try:
from torch.utils.tensorboard import SummaryWriter
except Exception:
class SummaryWriter: # fallback when tensorboard is not installed
def __init__(self, *args, **kwargs):
pass
def add_scalar(self, *args, **kwargs):
pass
def flush(self):
pass
def close(self):
pass
try:
from torchvision.transforms import Resize
except Exception:
class Resize:
def __init__(self, size, antialias=True):
self.size = tuple(size)
self.antialias = antialias
def __call__(self, x):
need_squeeze = (x.dim() == 3)
if need_squeeze:
x = x.unsqueeze(0)
y = F.interpolate(x, size=self.size, mode="bilinear", align_corners=False)
return y.squeeze(0) if need_squeeze else y
from tqdm import tqdm
from datetime import datetime, timedelta
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from segment_anything import sam_model_registry, sam_model_checkpoint
from segment_anything.utils.transforms import ResizeLongestSide
from model import GeoFormerX
from data.dataset import PavementMultiClassTileDB
from utils.multiclass_loss import multiclass_total_loss, logits_with_bg, foreground_mdice_hard
from utils.multiclass_metrics import confusion_matrix, per_class_metrics_from_cm, boundary_f1_per_class
from utils.publication_outputs import nanmean, plot_expert_heatmap, plot_training_curves, write_csv
from utils.logger import get_logger
from utils.moe_loss import moe_aux_loss
from utils.moe_preassign import build_routing_cache, PreassignConfig
from utils.sampler import build_task_mixed_sampler
from utils.train_runtime import (
snapshot_model_state,
restore_model_state,
get_base_lrs,
compute_epoch_lrs,
set_optimizer_lrs,
clip_grad_norm_and_get,
gradients_finite,
parameters_finite,
sanitize_nonfinite_gradients,
)
# 注意:不再依赖 SegmentMetrics 来算 dsc,避免内部 0/0 nan
# ==========================
# Stable Dice/DSC calculation
# ==========================
def dice_batch(pred_bin: torch.Tensor, gt_bin: torch.Tensor, eps: float = 1e-6) -> torch.Tensor:
"""
pred_bin: (B,1,H,W) 0/1 或 bool
gt_bin: (B,1,H,W) 0/1
return: (B,) 每个样本 dice
规则:
- pred 与 gt 都全空 => dice=1
- 其他情况按标准 dice(加 eps 防止 0/0)
"""
pred = pred_bin.float()
gt = gt_bin.float()
pred_f = pred.flatten(1)
gt_f = gt.flatten(1)
inter = (pred_f * gt_f).sum(dim=1)
denom = pred_f.sum(dim=1) + gt_f.sum(dim=1)
dice = (2 * inter + eps) / (denom + eps)
dice = torch.where(denom == 0, torch.ones_like(dice), dice)
return dice
def set_global_seed(seed: int) -> None:
"""Initialize the formal-run RNGs from one explicit seed."""
seed = int(seed)
random.seed(seed)
np.random.seed(seed)
torch.manual_seed(seed)
torch.cuda.empty_cache()
if torch.cuda.is_available():
torch.cuda.manual_seed(seed)
torch.cuda.manual_seed_all(seed)
torch.backends.cudnn.benchmark = False
torch.backends.cudnn.deterministic = True
# ==========================
# Optimizer helper: smaller LR for mask decoder and optionally unfrozen encoder
# ==========================
def get_unfrozen_block_ids(args, total_blocks: int = 12):
n = int(max(0, getattr(args, 'unfreeze_last_n_blocks', 0)))
n = min(n, int(total_blocks))
if n <= 0:
return []
return list(range(int(total_blocks) - n, int(total_blocks)))
def build_optimizer(model_dp, args):
"""Build AdamW with param groups.
- other trainable params use lr
- mask_decoder uses lr * mask_decoder_lr_mult
- partially-unfrozen encoder params use lr * encoder_lr_mult
- early SAM adapter params can optionally use a smaller lr for stability
"""
block_ids = get_unfrozen_block_ids(args)
encoder_tokens = [f'image_encoder.blocks.{i}.' for i in block_ids]
early_adapter_blocks = max(0, int(getattr(args, 'early_adapter_blocks', 0)))
early_adapter_mult = float(getattr(args, 'early_adapter_lr_mult', 1.0))
early_adapter_tokens = [f'sam.image_encoder.blocks.{i}.mlp.adapter_' for i in range(early_adapter_blocks)]
mask_params = []
encoder_params = []
early_adapter_params = []
specialist_params = []
other_params = []
for n, p in model_dp.named_parameters():
if not p.requires_grad:
continue
if 'sam.mask_decoder' in n or ('mask_decoder' in n and 'sam.' in n):
mask_params.append(p)
elif ('sam.image_encoder.neck.' in n) or any(tok in n for tok in encoder_tokens):
encoder_params.append(p)
elif early_adapter_blocks > 0 and any(tok in n for tok in early_adapter_tokens):
early_adapter_params.append(p)
elif 'specialist_refiner' in n:
specialist_params.append(p)
else:
other_params.append(p)
groups = []
if other_params:
groups.append({'params': other_params, 'lr': float(args.lr)})
if early_adapter_params:
groups.append({'params': early_adapter_params, 'lr': float(args.lr) * float(early_adapter_mult)})
if specialist_params:
groups.append({'params': specialist_params, 'lr': float(args.lr) * float(getattr(args, 'specialist_lr_mult', 1.0))})
if mask_params:
groups.append({'params': mask_params, 'lr': float(args.lr) * float(getattr(args, 'mask_decoder_lr_mult', 0.1))})
if encoder_params:
groups.append({'params': encoder_params, 'lr': float(args.lr) * float(getattr(args, 'encoder_lr_mult', 0.05))})
return torch.optim.AdamW(groups, weight_decay=float(args.weight_decay))
def set_partial_encoder_trainability(model_module, enable_neck: bool = False, block_ids=None, enabled: bool = True):
"""Toggle trainability of the partially-unfrozen SAM encoder subset."""
if block_ids is None:
block_ids = []
if hasattr(model_module, 'sam') and hasattr(model_module.sam, 'image_encoder'):
enc = model_module.sam.image_encoder
if bool(enable_neck) and hasattr(enc, 'neck'):
for p in enc.neck.parameters():
p.requires_grad = bool(enabled)
for bid in list(block_ids):
if 0 <= int(bid) < len(enc.blocks):
for p in enc.blocks[int(bid)].parameters():
p.requires_grad = bool(enabled)
def apply_train_scope(model_dp, args, logger=None):
"""Restrict trainable parameters for stable final-stage polishing.
all: keep the default GeoFormerX trainability.
refiners: freeze the SAM/adapters/mask decoder and train only fusion/logit/specialist refiners.
specialist: train only specialist_refiner.
"""
scope = str(getattr(args, 'train_scope', 'all')).lower().strip()
if scope in ('', 'all'):
return
if scope not in ('refiners', 'specialist'):
raise ValueError(f'Unsupported train_scope={scope}')
module = model_dp.module if hasattr(model_dp, 'module') else model_dp
for p in module.parameters():
p.requires_grad = False
enabled = []
def _enable(name):
mod = getattr(module, name, None)
if mod is not None:
for p in mod.parameters():
p.requires_grad = True
enabled.append(name)
if scope == 'refiners':
_enable('fusion_2d3d')
_enable('logit_refiner')
_enable('specialist_refiner')
elif scope == 'specialist':
_enable('specialist_refiner')
if logger is not None:
n_train = sum(p.numel() for p in module.parameters() if p.requires_grad)
logger.info(f'Train scope applied: {scope}. Enabled modules={enabled}. Trainable params={n_train}')
def first_nonfinite_grad_param(model: torch.nn.Module):
for n, p in model.named_parameters():
if p.grad is None:
continue
g = p.grad.detach()
if not torch.isfinite(g).all():
try:
max_abs = float(torch.nan_to_num(g, nan=0.0, posinf=0.0, neginf=0.0).abs().max().item())
except Exception:
max_abs = None
return n, max_abs
return None, None
def parse_class_value_map(spec: str) -> dict:
"""Parse strings like '1:0.25,2:0.10'. Empty -> {}."""
spec = '' if spec is None else str(spec).strip()
if spec == '':
return {}
out = {}
for chunk in spec.split(','):
chunk = chunk.strip()
if not chunk:
continue
if ':' not in chunk:
raise ValueError(f"Invalid class map item: {chunk}. Expected class:value")
k, v = chunk.split(':', 1)
out[int(k.strip())] = float(v.strip())
return out
def summarize_gate_batch(gates):
stats = {}
if not gates:
return stats
for li, g in enumerate(gates):
if g is None:
continue
gd = g.detach()
ent = -(gd * gd.clamp_min(1e-8).log()).sum(dim=1)
top1 = torch.argmax(gd, dim=1)
counts = torch.bincount(top1, minlength=gd.shape[1]).float() / max(1, gd.shape[0])
stats[li] = {
'mean_prob': gd.mean(dim=0).float().cpu().tolist(),
'top1_load': counts.cpu().tolist(),
'entropy': float(ent.mean().item()),
}
return stats
def default_epoch_cfg(args, rare_sampler_map, dice_class_weight_map, aux_ft_map, aux_bnd_map, aux_cldice_map=None):
return {
'stage': 'base',
'rare_sampler_map': dict(rare_sampler_map),
'focus_crop_prob': float(getattr(args, 'focus_crop_prob', 0.0)),
'focus_crop_classes': list(getattr(args, 'focus_crop_classes', [])),
'focus_crop_weights': list(getattr(args, 'focus_crop_weights', [])),
'dice_class_weight_map': dict(dice_class_weight_map),
'aux_ft_map': dict(aux_ft_map),
'aux_bnd_map': dict(aux_bnd_map),
'aux_cldice_map': dict(aux_cldice_map) if aux_cldice_map is not None else {},
'ohem_ratio': float(getattr(args, 'ohem_ratio', 0.0)),
'ce_bg_scale': float(getattr(args, 'ce_bg_scale', 0.5)),
'crack_ft_lambda': float(getattr(args, 'crack_ft_lambda', 0.0)),
'crack_bnd_lambda': float(getattr(args, 'crack_bnd_lambda', 0.0)),
'crack_cldice_lambda': float(getattr(args, 'crack_cldice_lambda', 0.0)),
'crack_cldice_iters': int(getattr(args, 'crack_cldice_iters', 3)),
}
def get_crack_polish_cfg(epoch: int, num_epochs: int, base_cfg: dict, start_epoch: int = 20):
"""A mild late-stage crack polish on top of the GeoFormerX base recipe.
Key idea from the thin-line refinement ablation:
- small-area crack emphasis helped crack rise earlier,
- but aggressive late reweighting + LR shrink on non-finite events flattened the run.
So this schedule stays deliberately gentle and starts later.
"""
cfg = dict(base_cfg)
if int(epoch) < int(start_epoch):
cfg['stage'] = 'base'
return cfg
cfg.update({
'stage': 'crack_polish',
'rare_sampler_map': {1: 1.9, 2: 2.5, 7: 0.8},
'focus_crop_prob': 0.78,
'focus_crop_classes': [1, 2, 7],
'focus_crop_weights': [4.0, 3.8, 0.9],
'dice_class_weight_map': {1: 2.0, 2: 1.9, 7: 1.1},
'aux_ft_map': {2: 0.12, 7: 0.04},
'aux_bnd_map': {2: 0.03},
'ohem_ratio': 0.15625,
'ce_bg_scale': 0.48,
'crack_ft_lambda': max(float(base_cfg.get('crack_ft_lambda', 0.0)), 0.22),
'crack_bnd_lambda': max(float(base_cfg.get('crack_bnd_lambda', 0.0)), 0.11),
'crack_cldice_lambda': max(float(base_cfg.get('crack_cldice_lambda', 0.0)), 0.03),
'crack_cldice_iters': min(int(base_cfg.get('crack_cldice_iters', 2)), 2),
})
return cfg
def get_pavement_final_push_cfg(epoch: int, num_epochs: int, base_cfg: dict):
"""A small curriculum designed from observed base/specialist trade-offs.
Early: recover pothole/patch, Middle: balance, Late: polish crack/joint/marking.
"""
cfg = dict(base_cfg)
n = max(1, int(num_epochs))
if epoch < int(round(0.30 * n)):
cfg.update({
'stage': 'stage_a_pothole_patch',
'rare_sampler_map': {1: 1.3, 2: 3.2, 7: 0.8},
'focus_crop_prob': 0.78,
'focus_crop_classes': [1, 2, 7],
'focus_crop_weights': [2.5, 5.5, 1.0],
'dice_class_weight_map': {1: 1.55, 2: 2.45, 7: 1.1},
'aux_ft_map': {2: 0.20, 7: 0.05},
'aux_bnd_map': {2: 0.05},
'ohem_ratio': 0.125,
'ce_bg_scale': 0.50,
'crack_ft_lambda': max(float(base_cfg.get('crack_ft_lambda', 0.0)), 0.18),
'crack_bnd_lambda': max(float(base_cfg.get('crack_bnd_lambda', 0.0)), 0.10),
})
elif epoch < int(round(0.70 * n)):
cfg.update({
'stage': 'stage_b_balanced',
'rare_sampler_map': {1: 1.8, 2: 2.6, 7: 0.8},
'focus_crop_prob': 0.76,
'focus_crop_classes': [1, 2, 7],
'focus_crop_weights': [3.8, 4.2, 1.0],
'dice_class_weight_map': {1: 1.95, 2: 2.0, 7: 1.15, 6: 1.05},
'aux_ft_map': {2: 0.14, 6: 0.04, 7: 0.04},
'aux_bnd_map': {2: 0.04, 6: 0.02},
'ohem_ratio': 0.1875,
'ce_bg_scale': 0.45,
'crack_ft_lambda': 0.22,
'crack_bnd_lambda': 0.11,
})
else:
cfg.update({
'stage': 'stage_c_thin_polish',
'rare_sampler_map': {1: 2.4, 2: 1.8, 6: 0.7, 7: 0.7},
'focus_crop_prob': 0.80,
'focus_crop_classes': [1, 2, 6, 7],
'focus_crop_weights': [5.0, 3.0, 1.6, 0.8],
'dice_class_weight_map': {1: 2.4, 2: 1.6, 5: 1.10, 6: 1.25, 7: 1.10},
'aux_ft_map': {2: 0.08, 5: 0.03, 6: 0.06, 7: 0.03},
'aux_bnd_map': {2: 0.02, 5: 0.02, 6: 0.03},
'ohem_ratio': 0.25,
'ce_bg_scale': 0.40,
'crack_ft_lambda': 0.24,
'crack_bnd_lambda': 0.12,
})
return cfg
class ModelEMA:
def __init__(self, model_module: nn.Module, decay: float = 0.999):
self.decay = float(decay)
self.shadow = {k: v.detach().clone() for k, v in model_module.save_parameters().items()}
@torch.no_grad()
def reset_from_model(self, model_module: nn.Module):
"""Hard-reset EMA shadow to the current model weights.
Useful when EMA starts several epochs after training began: without a reset,
the shadow can still be dominated by the random initialization right when
eval_with_ema is first enabled.
"""
self.shadow = {k: v.detach().clone() for k, v in model_module.save_parameters().items()}
@torch.no_grad()
def update(self, model_module: nn.Module):
cur = model_module.save_parameters()
d = float(self.decay)
for k, v in cur.items():
vv = v.detach()
if k not in self.shadow:
self.shadow[k] = vv.clone()
continue
# EMA only makes sense for floating tensors. For integer / boolean buffers,
# keep an exact copy of the latest value instead of trying to blend them.
if (not torch.is_floating_point(vv)) and (not torch.is_complex(vv)):
self.shadow[k] = vv.clone()
continue
if (not torch.is_floating_point(self.shadow[k])) and (not torch.is_complex(self.shadow[k])):
self.shadow[k] = vv.clone()
continue
self.shadow[k].mul_(d).add_(vv.to(dtype=self.shadow[k].dtype), alpha=1.0 - d)
def state_dict(self):
return {k: v.detach().clone().cpu() for k, v in self.shadow.items()}
def copy_to(self, model_module: nn.Module):
model_module.load_parameters(self.shadow)
# setup parser
parser = argparse.ArgumentParser("GeoFormerX training", add_help=False)
# model
parser.add_argument("--checkpoint", type=str, default="./checkpoints/sam",
help="path to SAM checkpoint folder")
parser.add_argument("--model_type", type=str, default="vit_b",
help="SAM model scale (e.g vit_b, vit_l, vit_h)")
parser.add_argument("--sam_image_size", type=int, default=256,
help="SAM encoder input size. Larger values use more VRAM and may improve fine-detail segmentation.")
parser.add_argument("--task_name", type=str, default="geoformerx")
parser.add_argument("--method", type=str, default="geoformerx", choices=["geoformerx"])
parser.add_argument("--seed", type=int, default=2028, help="Formal-run seed (2026, 2027, or 2028 in the manuscript).")
parser.add_argument("--bottleneck_dim", type=int, default=16)
parser.add_argument("--embedding_dim", type=int, default=16)
parser.add_argument("--expert_num", type=int, default=8)
parser.add_argument("--fusion_gate_variant", type=str, default="G8", choices=["G8", "G4", "G0"],
help="Range-contribution gate used by the fusion module.")
parser.add_argument("--adapter_variant", type=str, default="S0", choices=["M0", "S0", "A0"],
help="Encoder adaptation: routed MoE (M0), static residual adapter (S0), or decoder-only (A0).")
parser.add_argument("--static_bottleneck_dim", type=int, default=42,
help="Bottleneck width of the single-path S0 adapter.")
# MoE anti-collapse (gate regularization)
parser.add_argument("--moe_topk", type=int, default=2,
help="top-k experts used by the gate (0 = dense softmax, original behavior)")
parser.add_argument("--moe_temp", type=float, default=1.0,
help="gate softmax temperature (smaller -> peakier; >1 -> smoother)")
parser.add_argument("--moe_noise_std", type=float, default=0.0,
help="std of Gaussian noise added to gate logits during training (0 = off)")
parser.add_argument("--moe_lb_coef", type=float, default=0.01,
help="load-balance loss coefficient (0 = disable)")
parser.add_argument("--moe_ent_coef", type=float, default=0.01,
help="entropy bonus coefficient (0 = disable)")
# routing pre-assign (task-first, dynamic-K)
parser.add_argument("--moe_preassign", type=int, default=0, choices=[0,1],
help="Optional offline routing pre-assign. Keep OFF for the standard RGB-D pavement dataset. (default: 0)")
parser.add_argument("--moe_preassign_force", action="store_true",
help="Force rebuild the routing cache even if it already exists.")
parser.add_argument("--moe_route_sup_coef", type=float, default=0.05,
help="Routing supervision loss coefficient (0 = disable). Uses 'moe_target' from dataset cache.")
parser.add_argument("--moe_route_warmup", type=int, default=2,
help="Warmup epochs for routing supervision (linearly ramps from 0 to moe_route_sup_coef).")
parser.add_argument("--moe_route_cap_ratio", type=float, default=0.25,
help="Clip routing supervision term to at most (moe_route_cap_ratio * seg_loss) to avoid destabilizing training when coef is large.")
parser.add_argument("--moe_route_log", type=int, default=1, choices=[0,1],
help="Log routing supervision stats (route_loss/route_coef/route_term and invalid targets) to tqdm and TensorBoard. (default: 1)")
# data
parser.add_argument("--data_path", type=str, default="./data",
help="Dataset root. Expected: <data_path>/{train,val,test}/{image,label} and <data_path>/3Ddate")
# pavement tiling / prompts
parser.add_argument("--tile_size", type=int, default=256, help="Tile size (default: 256)")
parser.add_argument("--tile_stride", type=int, default=128, help="Tile stride (default: 128, 50% overlap)")
# --- Train-time tile sampling tricks (recommended for Crack / thin defects) ---
parser.add_argument(
"--train_use_crack_crop",
type=int,
default=1,
choices=[0, 1],
help="1: enable crack-centered crop (train only) to boost Crack and reduce seam artifacts; 0: disable.",
)
parser.add_argument(
"--crack_crop_prob",
type=float,
default=0.7,
help="Probability to use crack-centered crop when crack pixels exist in the full label.",
)
parser.add_argument(
"--tile_jitter",
type=int,
default=64,
help="Random jitter (pixels) added to tile coords during training (0 disables). Helps avoid fixed seam at x=256.",
)
parser.add_argument(
"--focus_crop_prob",
type=float,
default=0.0,
help="Optional generalized class-aware centered crop probability. If 0, legacy crack crop args are used.",
)
parser.add_argument(
"--focus_crop_classes",
type=int,
nargs='*',
default=[],
help="Class ids for class-aware centered crop, e.g. --focus_crop_classes 1 2 7",
)
parser.add_argument(
"--focus_crop_weights",
type=float,
nargs='*',
default=[],
help="Sampling weights aligned with --focus_crop_classes.",
)
parser.add_argument("--num_fg_classes", type=int, default=7,
help="Number of foreground classes (exclude background). Default: 7")
parser.add_argument("--ce_weight", type=float, default=1.0, help="Cross-entropy weight.")
parser.add_argument("--dice_weight", type=float, default=1.0, help="Dice loss weight (foreground macro).")
parser.add_argument("--dice_present_only", type=int, default=1, choices=[0,1],
help="1: average dice only over classes present in current batch GT. Strongly recommended for rare classes.")
parser.add_argument("--dice_class_weights", type=str, default="",
help="Optional per-class dice weights, e.g. '1:1.5,2:2.0,7:1.2'.")
parser.add_argument("--use_dynamic_ce_weights", type=int, default=1, choices=[0,1],
help="1: inverse-frequency dynamic CE weights per batch; 0: no weighting.")
parser.add_argument("--ce_w_min", type=float, default=0.2, help="Min clamp for dynamic CE weights.")
parser.add_argument("--ce_w_max", type=float, default=10.0, help="Max clamp for dynamic CE weights.")
parser.add_argument("--ce_bg_scale", type=float, default=0.3, help="Multiply background CE weight by this factor.")
# --- OHEM for CE (optional; improves rare classes at the cost of extra compute) ---
parser.add_argument(
"--ohem_ratio",
type=float,
default=0.0,
help="Online hard example mining ratio for CE. 0 disables; typical values: 0.25.",
)
parser.add_argument("--prompt_mode", type=str, default="full", choices=["full", "gt_box"],
help="Prompt mode: 'full' uses full-tile box (prompt-free); 'gt_box' uses GT bbox (not deployment-realistic).")
parser.add_argument("--box_jitter", type=int, default=0, help="BBox jitter for gt_box mode.")
# expert routing
parser.add_argument("--force_class_expert", type=int, default=1, choices=[0, 1],
help="1: force each class to a dedicated expert during training; 0: let gate decide.")
# 2D+3D fusion + weaken style routing
parser.add_argument("--use_fusion_2d3d", type=int, default=1, choices=[0, 1],
help="1: enable trainable 2D+3D fusion head; 0: ignore the 3D channel.")
parser.add_argument("--fusion_hidden", type=int, default=16, help="Fusion gate hidden dim.")
parser.add_argument("--fusion_mode", type=str, default="hybrid", choices=["global", "hybrid", "fixed"],
help="global: scalar alpha; hybrid: scalar alpha times spatial alpha-map.")
parser.add_argument("--use_logit_refiner", type=int, default=1, choices=[0,1],
help="1: enable lightweight RGBD-aware residual logit refinement head.")
parser.add_argument("--refiner_hidden", type=int, default=32,
help="Hidden channels of the residual logit refiner.")
parser.add_argument("--unfreeze_encoder_neck", type=int, default=0, choices=[0,1],
help="1: unfreeze SAM image_encoder.neck with a small lr multiplier.")
parser.add_argument("--unfreeze_last_n_blocks", type=int, default=0,
help="number of last image-encoder transformer blocks to unfreeze (default: 0).")
parser.add_argument("--moe_style_scale", type=float, default=0.25,
help="Scale style embedding in MoE gate input (smaller -> weaker style routing).")
# env
parser.add_argument("--device", type=str, default="cuda:0")
parser.add_argument("--device_ids", type=int, default=[0,1,2,3,4,5,6,7], nargs='+',
help="device ids assignment (e.g 0 1 2 3)")
parser.add_argument("--work_dir", type=str, default="./runs/geoformerx")
# train
parser.add_argument("--num_epochs", type=int, default=30)
parser.add_argument("--batch_size", type=int, default=16)
parser.add_argument("--num_workers", type=int, default=0)
parser.add_argument("--pin_memory", type=int, default=0, choices=[0,1],
help="DataLoader pin_memory. On Windows, 0 is usually more stable.")
parser.add_argument("--persistent_workers", type=int, default=0, choices=[0,1],
help="keep DataLoader workers alive between epochs (only when num_workers>0).")
parser.add_argument("--train_cache_size", type=int, default=0,
help="per-worker LRU cache size for training full images. 0 disables cache for maximum stability.")
parser.add_argument("--val_cache_size", type=int, default=0,
help="per-worker LRU cache size for validation full images. 0 disables cache for maximum stability.")
parser.add_argument("--resume", type=str, default=None,
help="resume training from checkpoint")
# optimizer
parser.add_argument("--lr", type=float, default=0.001, metavar="LR",
help="learning rate (absolute lr default: 0.001)")
parser.add_argument("--weight_decay", type=float, default=0.01,
help="weight decay (default: 0.01)")
parser.add_argument("--mask_decoder_lr_mult", type=float, default=0.1,
help="learning-rate multiplier for SAM mask_decoder params (default: 0.1).")
parser.add_argument("--encoder_lr_mult", type=float, default=0.05,
help="learning-rate multiplier for partially-unfrozen image encoder params.")
parser.add_argument("--encoder_unfreeze_epoch", type=int, default=0,
help="delay unfreezing of encoder neck / last blocks until this epoch. 0 means unfreeze from epoch 0.")
parser.add_argument("--grad_clip_norm", type=float, default=1.0,
help="global grad clipping (norm). 0 disables. (default: 1.0)")
parser.add_argument("--auto_resume", type=int, default=1, choices=[0,1],
help="auto resume from <work_dir>/<task_name>/model_latest.pth if exists and --resume is not set. (default: 1)")
parser.add_argument("--reset_optim", type=int, default=0, choices=[0, 1],
help="If 1, do NOT load optimizer state when resuming (useful when changing lr/weight_decay).")
parser.add_argument("--skip_nonfinite", type=int, default=1, choices=[0,1],
help="skip optimizer step when loss is NaN/Inf to prevent corruption. (default: 1)")
parser.add_argument("--use_amp", action="store_true", default=False,
help="whether to use amp")
parser.add_argument("--amp_init_scale", type=float, default=65536.0,
help="initial GradScaler scale when --use_amp is enabled.")
parser.add_argument("--amp_backoff", type=float, default=0.5,
help="on AMP overflow / non-finite recovery, multiply GradScaler scale by this factor.")
parser.add_argument("--amp_growth_interval", type=int, default=2000,
help="GradScaler growth interval.")
parser.add_argument("--lr_schedule", type=str, default="flatcosine", choices=["none", "cosine", "flatcosine"],
help="epoch-wise lr schedule. flatcosine keeps the base lr for a while, then decays near the end; better when you want the best model near epoch 50.")
parser.add_argument("--warmup_epochs", type=int, default=2,
help="number of warmup epochs for lr schedule (default: 2)")
parser.add_argument("--hold_epochs", type=int, default=0,
help="for flatcosine: keep base lr for this many epochs after warmup before decaying.")
parser.add_argument("--min_lr", type=float, default=1e-6,
help="minimum lr used by cosine schedule (default: 1e-6)")
parser.add_argument("--early_stop_patience", type=int, default=12,
help="stop if validation DSC does not improve for N epochs. 0 disables. (default: 12)")
parser.add_argument("--use_tqdm", type=int, default=1, choices=[0,1],
help="1: show tqdm progress bars; 0: epoch-only logging to avoid console spam.")
parser.add_argument("--overall_progress", type=int, default=1, choices=[0,1],
help="1: show an outer total progress bar across all train/val steps.")
parser.add_argument("--tqdm_mininterval", type=float, default=5.0,
help="minimum seconds between tqdm refreshes.")
parser.add_argument("--log_model_arch", type=int, default=0, choices=[0,1],
help="1: log full model architecture to output.log; 0: only log counts.")
parser.add_argument("--save_epoch_ckpt", type=int, default=0, choices=[0,1],
help="1: also save model_<epoch>.pth each epoch; 0: save latest/best/lowest only.")
parser.add_argument("--restore_on_nonfinite", type=int, default=1, choices=[0,1],
help="1: restore epoch-start weights and reduce lr when non-finite tensors appear.")
parser.add_argument("--nonfinite_reduce_lr", type=float, default=0.5,
help="multiply lr scale by this factor after a non-finite restore.")
parser.add_argument("--nonfinite_reduce_every", type=int, default=0,
help="Reduce lr_scale only once every N non-finite events within an epoch. 0 disables per-batch lr shrinking.")
parser.add_argument("--nonfinite_log_limit", type=int, default=3,
help="maximum number of detailed non-finite warnings printed per epoch.")
parser.add_argument("--abort_epoch_on_nonfinite", type=int, default=0, choices=[0,1],
help="1: abort the rest of the epoch after a non-finite batch; 0: restore and continue from the latest safe snapshot.")
parser.add_argument("--safe_snapshot_interval", type=int, default=25,
help="refresh the in-epoch safe model snapshot every N successful optimizer steps.")
parser.add_argument("--nonfinite_param_log", type=int, default=1, choices=[0,1],
help="1: log the first parameter name whose gradient becomes non-finite.")
parser.add_argument("--tb_batch_log", type=int, default=0, choices=[0,1],
help="1: write per-batch TensorBoard scalars; 0: epoch-only logging to reduce I/O.")
parser.add_argument("--debug_epoch_json", type=int, default=1, choices=[0,1],
help="1: append rich JSON diagnostics for each epoch.")
parser.add_argument("--paper_train_outputs", type=int, default=1, choices=[0,1],
help="1: save paper-facing training CSVs/plots: loss_components.csv, val_metrics_by_epoch.csv, alpha_by_epoch.csv and expert heatmaps.")
parser.add_argument("--debug_nonfinite_dump", type=int, default=1, choices=[0,1],
help="1: dump offending batch names/histograms when non-finite tensors appear.")
parser.add_argument("--task_balanced_sampling", type=int, default=1,
help="1: balance sampling across TaskFolders (recommended for highly imbalanced datasets); 0: plain shuffle")
# TaskFolder imbalance handling (mixed sampling)
parser.add_argument("--task_sampling_alpha", type=float, default=0.25,
help="mixing factor in [0,1] for TaskFolder reweighting. 0=natural, 1=fully balanced.")
parser.add_argument("--task_sampling_power", type=float, default=1.0,
help="strength of inverse-frequency reweighting: 1/(count**power). 1.0=standard, 0.5=milder.")
parser.add_argument("--task_sampling_num_samples", type=int, default=0,
help="samples drawn per epoch when using WeightedRandomSampler (0 => len(dataset)).")
parser.add_argument("--rare_sampler", type=int, default=0, choices=[0,1],
help="1: build a tile-level sampler that up-weights tiles containing rare classes.")
parser.add_argument("--rare_sampler_map", type=str, default="",
help="Class boosts for rare sampler, e.g. '1:1.5,2:3.0,7:0.8'.")
# MoE warmup (recommended)
parser.add_argument("--moe_warmup_epochs", type=int, default=5,
help="linearly warm up MoE auxiliary loss + gate noise for first N epochs (0 disables warmup).")
# Crack / thin-structure tuning (keeps evaluation prompts unchanged)
# These defaults are mild and mainly help very sparse crack tasks (Vehicle*_*Crack).
parser.add_argument("--crack_ft_lambda", type=float, default=0.15,
help="extra focal-tversky loss weight for tasks whose folder name contains 'Crack' (0 disables).")
parser.add_argument("--vehicle_crack_ft_extra", type=float, default=0.15,
help="additional focal-tversky weight for Vehicle*Crack tasks, added on top of crack_ft_lambda.")
parser.add_argument("--vehicle_crack_loss_boost", type=float, default=0.25,
help="multiply seg loss by (1+boost) for Vehicle*Crack samples (0 disables).")
parser.add_argument("--crack_alpha", type=float, default=0.3, help="focal-tversky alpha (FP weight).")
parser.add_argument("--crack_beta", type=float, default=0.7, help="focal-tversky beta (FN weight).")
parser.add_argument("--crack_gamma", type=float, default=0.75, help="focal-tversky gamma (focal exponent).")
# Optional: crack boundary auxiliary loss (helps thin structures)
parser.add_argument("--crack_bnd_lambda", type=float, default=0.05,
help="extra boundary dice loss weight for Crack (0 disables).")
parser.add_argument("--crack_bnd_kernel", type=int, default=3,
help="kernel size for crack boundary morphological gradient (odd int, e.g., 3/5).")
parser.add_argument("--aux_ft_map", type=str, default="",
help="Optional extra per-class focal-Tversky lambdas, e.g. '1:0.20,2:0.15'.")
parser.add_argument("--aux_bnd_map", type=str, default="",
help="Optional extra per-class boundary lambdas, e.g. '1:0.10,2:0.04'.")
parser.add_argument("--use_ema", type=int, default=1, choices=[0,1],
help="1: maintain EMA of trainable weights and evaluate/save with EMA.")
parser.add_argument("--ema_decay", type=float, default=0.999,
help="EMA decay for trainable weights.")
parser.add_argument("--ema_start_epoch", type=int, default=2,
help="Start updating EMA after this epoch.")
parser.add_argument("--eval_with_ema", type=int, default=1, choices=[0,1],
help="1: use EMA weights for validation/model_best when available.")
parser.add_argument("--ema_warm_start", type=int, default=1, choices=[0,1],
help="Reset EMA shadow to the current model at ema_start_epoch before first EMA-evaluated epoch.")
parser.add_argument("--best_metric", type=str, default="cm_mdice", choices=["batch_mdice", "cm_mdice", "weighted"],
help="Validation metric used to choose model_best. cm_mdice is usually closer to final stitched evaluation.")
parser.add_argument("--best_class_weights", type=str, default="",
help="Optional class weights for best_metric=weighted, e.g. '1:1.5,2:2.0'.")
parser.add_argument("--save_class_best_ckpt", type=int, default=0, choices=[0,1],
help="1: save model_best_c{class}.pth whenever a validation class Dice improves.")
parser.add_argument("--class_best_ids", type=str, default="1,5,6",
help="Comma-separated class ids for per-class best checkpoint saving, e.g. '1,5,6'.")
parser.add_argument("--pavement_final_push", type=int, default=0, choices=[0,1],
help="1: enable a 3-stage pavement curriculum tuned to preserve pothole/patch early and polish crack/joint late.")
parser.add_argument("--early_adapter_blocks", type=int, default=0,
help="Apply a smaller lr to adapter params in the first N SAM encoder blocks for stability.")
parser.add_argument("--early_adapter_lr_mult", type=float, default=0.10,
help="LR multiplier for early SAM adapter params when --early_adapter_blocks > 0.")
parser.add_argument("--grad_sanitize_nonfinite", type=int, default=1, choices=[0,1],
help="1: replace a small number of non-finite gradient tensors with zeros instead of restoring the full snapshot.")
parser.add_argument("--grad_sanitize_max_params", type=int, default=1,
help="Maximum number of gradient tensors allowed to be sanitized in one step.")
parser.add_argument("--grad_sanitize_fill", type=float, default=0.0,
help="Fill value used when sanitizing non-finite gradients.")
parser.add_argument("--crack_polish", type=int, default=0, choices=[0,1],
help="1: apply a modest crack-focused late schedule.")
parser.add_argument("--crack_polish_start", type=int, default=20,
help="Epoch index where crack-focused late schedule starts.")
parser.add_argument("--crack_small_area_boost", type=float, default=0.0,
help="Extra sampling boost for crack-positive tiles with very small crack area ratio.")
parser.add_argument("--crack_area_ref", type=float, default=0.02,
help="Reference crack area ratio used by crack small-area sampling.")
parser.add_argument("--crack_area_power", type=float, default=0.5,
help="Power used by crack small-area sampling. 0.5 is a gentle square-root boost.")
parser.add_argument("--crack_area_cap", type=float, default=3.0,
help="Maximum multiplicative thin-area emphasis for crack sampling.")
parser.add_argument("--crack_cldice_lambda", type=float, default=0.0,
help="Crack-only clDice / centerline auxiliary loss weight.")
parser.add_argument("--crack_cldice_iters", type=int, default=3,
help="Number of soft-skeleton iterations used by crack clDice.")
parser.add_argument("--aux_cldice_map", type=str, default='',
help="Per-class clDice lambdas, e.g. '5:0.02,6:0.02'.")
parser.add_argument("--line_group_ce_lambda", type=float, default=0.0,
help="Aux CE over crack/marking/joint subset.")
parser.add_argument("--surface_group_ce_lambda", type=float, default=0.0,
help="Aux CE over pothole/patch subset.")
parser.add_argument("--use_specialist_refiner", type=int, default=0, choices=[0,1],
help="Enable thin/surface specialist residual heads.")
parser.add_argument("--line_refiner_hidden", type=int, default=48,
help="Hidden channels for thin-class specialist head.")
parser.add_argument("--surface_refiner_hidden", type=int, default=32,
help="Hidden channels for surface-class specialist head.")
parser.add_argument("--specialist_scale_init", type=float, default=0.10,
help="Initial residual scale for specialist heads.")
parser.add_argument("--specialist_lr_mult", type=float, default=2.0,
help="LR multiplier for specialist heads.")
parser.add_argument("--train_scope", type=str, default="all", choices=["all", "refiners", "specialist"],
help="Final-stage stability switch. all=normal training; refiners=train only fusion/logit/specialist heads; specialist=train only specialist_refiner.")
parser.add_argument("--init_from", type=str, default=None,
help="Load model weights without resuming optimizer/epoch.")
def main(args):
set_global_seed(int(getattr(args, 'seed', 2028)))
device = torch.device(args.device)
checkpoint = join(args.checkpoint, sam_model_checkpoint[args.model_type])
sam_model = sam_model_registry[args.model_type](
image_size=int(getattr(args, 'sam_image_size', 256)),
keep_resolution=True,
checkpoint=checkpoint,
num_multimask_outputs=int(args.num_fg_classes),
)
if args.method == "geoformerx":
model = GeoFormerX(
sam_model,
args.bottleneck_dim,
args.embedding_dim,
args.expert_num,
gate_topk=getattr(args, 'moe_topk', 2),
gate_temperature=args.moe_temp,
gate_noise_std=getattr(args, 'moe_noise_std', 0.0),
style_bn=bool(getattr(args, 'moe_style_bn', 1)),
style_dropout=getattr(args, 'moe_style_dropout', 0.10),
style_scale=float(getattr(args, 'moe_style_scale', 0.25)),
use_fusion_2d3d=bool(int(getattr(args, 'use_fusion_2d3d', 1)) == 1),
fusion_hidden=int(getattr(args, 'fusion_hidden', 16)),
fusion_mode=str(getattr(args, 'fusion_mode', 'global')),
fusion_gate_variant=str(getattr(args, 'fusion_gate_variant', 'G8')),
adapter_variant=str(getattr(args, 'adapter_variant', 'S0')),
static_bottleneck_dim=int(getattr(args, 'static_bottleneck_dim', 42)),
use_logit_refiner=bool(int(getattr(args, 'use_logit_refiner', 0)) == 1),
refiner_hidden=int(getattr(args, 'refiner_hidden', 32)),
use_specialist_refiner=bool(int(getattr(args, 'use_specialist_refiner', 0)) == 1),
line_refiner_hidden=int(getattr(args, 'line_refiner_hidden', 48)),
surface_refiner_hidden=int(getattr(args, 'surface_refiner_hidden', 32)),
specialist_scale_init=float(getattr(args, 'specialist_scale_init', 0.10)),
unfreeze_encoder_neck=bool(int(getattr(args, 'unfreeze_encoder_neck', 0)) == 1),
unfreeze_last_n_blocks=int(getattr(args, 'unfreeze_last_n_blocks', 0)),
).to(device)
else:
raise NotImplementedError("Method {} not implemented!".format(args.method))
model = nn.DataParallel(model, device_ids=args.device_ids)
work_dir = join(args.work_dir, args.task_name)
os.makedirs(work_dir, exist_ok=True)
log_writer = SummaryWriter(log_dir=work_dir)
logger = get_logger(log_file=os.path.join(work_dir, 'output.log'))
logger.info(f"args: {json.dumps(vars(args), indent=2)}")
aux_ft_map = parse_class_value_map(getattr(args, 'aux_ft_map', ''))
aux_bnd_map = parse_class_value_map(getattr(args, 'aux_bnd_map', ''))
aux_cldice_map = parse_class_value_map(getattr(args, 'aux_cldice_map', ''))
dice_class_weight_map = parse_class_value_map(getattr(args, 'dice_class_weights', ''))
rare_sampler_map = parse_class_value_map(getattr(args, 'rare_sampler_map', ''))
best_class_weight_map = parse_class_value_map(getattr(args, 'best_class_weights', ''))
if len(getattr(args, 'focus_crop_weights', [])) not in (0, len(getattr(args, 'focus_crop_classes', []))):
raise ValueError('--focus_crop_weights must be empty or same length as --focus_crop_classes')
if int(getattr(args, "log_model_arch", 0)) == 1:
logger.info("Model: %s" % str(model))
logger.info("Number of total parameters: %d" % (sum(p.numel() for p in model.parameters())))
logger.info("Number of trainable parameters: %d" % (sum(p.numel() for p in model.parameters() if p.requires_grad)))
logger.info(
f"Runtime knobs: sam_image_size={int(getattr(args, 'sam_image_size', 256))}, tile_size={int(getattr(args, 'tile_size', 256))}, "
f"batch_size={int(getattr(args, 'batch_size', 16))}, use_amp={bool(getattr(args, 'use_amp', False))}, "
f"unfreeze_encoder_neck={int(getattr(args, 'unfreeze_encoder_neck', 0))}, "
f"unfreeze_last_n_blocks={int(getattr(args, 'unfreeze_last_n_blocks', 0))}, "
f"encoder_lr_mult={float(getattr(args, 'encoder_lr_mult', 0.05))}, "
f"encoder_unfreeze_epoch={int(getattr(args, 'encoder_unfreeze_epoch', 0))}, "
f"use_specialist_refiner={int(getattr(args, 'use_specialist_refiner', 0))}, "
f"specialist_lr_mult={float(getattr(args, 'specialist_lr_mult', 1.0))}, "
f"train_scope={str(getattr(args, 'train_scope', 'all'))}, "
f"lr_schedule={str(getattr(args, 'lr_schedule', 'none'))}, hold_epochs={int(getattr(args, 'hold_epochs', 0))}"
)
encoder_block_ids = get_unfrozen_block_ids(args)
delayed_encoder_unfreeze = False
if args.method == "geoformerx" and int(getattr(args, 'encoder_unfreeze_epoch', 0)) > 0 and (bool(int(getattr(args, 'unfreeze_encoder_neck', 0)) == 1) or len(encoder_block_ids) > 0):
set_partial_encoder_trainability(model.module, bool(int(getattr(args, 'unfreeze_encoder_neck', 0)) == 1), encoder_block_ids, enabled=False)
delayed_encoder_unfreeze = True
logger.info(f"Delayed encoder unfreeze enabled: epoch {int(getattr(args, 'encoder_unfreeze_epoch', 0))}, blocks={encoder_block_ids}, neck={int(getattr(args, 'unfreeze_encoder_neck', 0))}")
logger.info("Number of trainable parameters after delayed freeze: %d" % (sum(p.numel() for p in model.parameters() if p.requires_grad)))
init_from_path = getattr(args, 'init_from', None)
if init_from_path is not None and os.path.isfile(init_from_path):
logger.info(f'Loading init_from checkpoint: {init_from_path}')
init_ckpt = torch.load(init_from_path, map_location=device)
if isinstance(init_ckpt, dict) and 'model' in init_ckpt:
model.module.load_parameters(init_ckpt['model'])
else:
model.module.load_parameters(init_ckpt)
logger.info('init_from loaded. Shared weights restored; optimizer/epoch are fresh.')
apply_train_scope(model, args, logger=logger)
logger.info("Number of trainable parameters after train_scope: %d" % (sum(p.numel() for p in model.parameters() if p.requires_grad)))
optimizer = build_optimizer(model, args)
# Multi-class loss: CE(bg+fg) + soft Dice (macro over fg)
logger.info("Loss: CE(bg+fg) + Dice(fg macro/present-only) + optional per-class auxiliary losses")
# ==========================
# MoE routing pre-assign (task-first, dynamic-K)
# This writes <data_path>/train/_moe_routing_cache.json and dataset.py will attach `moe_target`.
# ==========================
if int(getattr(args, "moe_preassign", 0)) == 1 and args.method == "geoformerx":
try:
cfg = PreassignConfig(
include_label_features=True, # label-aware pre-assign (offline)
num_workers=max(4, int(getattr(args, "num_workers", 8))),
)
cache_path = build_routing_cache(join(args.data_path, "train"), expert_num=args.expert_num, cfg=cfg, force_rebuild=args.moe_preassign_force)
logger.info(f"MoE preassign cache ready: {cache_path}")
# Write a pointer file so evaluation can apply training routing to ID/ODD without
# changing command line.
try:
with open(os.path.join(work_dir, "moe_train_cache_path.txt"), "w", encoding="utf-8") as wf:
wf.write(os.path.normpath(cache_path))
except Exception:
pass
except Exception as e:
logger.warning(f"MoE preassign failed (continue without it): {e}")
collect_train_tile_stats = bool(int(getattr(args, 'rare_sampler', 0)) == 1 or int(getattr(args, 'debug_epoch_json', 0)) == 1)
train_dataset = PavementMultiClassTileDB(
data_root=args.data_path,
split="train",
train=True,
tile_size=int(args.tile_size),
tile_stride=int(args.tile_stride),
modal="VehicleProfiler",
prompt_mode=str(args.prompt_mode),
box_jitter=int(args.box_jitter),
use_crack_crop=bool(int(getattr(args, 'train_use_crack_crop', 1)) == 1),
crack_crop_prob=float(getattr(args, 'crack_crop_prob', 0.7)),
crack_class_id=1,
focus_crop_prob=float(getattr(args, 'focus_crop_prob', 0.0)),
focus_crop_classes=list(getattr(args, 'focus_crop_classes', [])),
focus_crop_weights=list(getattr(args, 'focus_crop_weights', [])),
tile_jitter=int(getattr(args, 'tile_jitter', 0)),
cache_size=int(getattr(args, "train_cache_size", 0)),
collect_tile_stats=collect_train_tile_stats,
tile_stats_cache=True,
)
logger.info(f"Number of training tiles: {len(train_dataset)}")
if collect_train_tile_stats:
try:
tile_summary = train_dataset.get_tile_presence_summary()
logger.info(f"Train tile presence summary: {json.dumps(tile_summary, ensure_ascii=False)}")
except Exception as e:
logger.warning(f"Failed to summarize train tile stats: {e}")
base_epoch_cfg = default_epoch_cfg(args, rare_sampler_map, dice_class_weight_map, aux_ft_map, aux_bnd_map, aux_cldice_map)
active_epoch_cfg = dict(base_epoch_cfg)
def build_train_dataloader_for_epoch(epoch_cfg, log_change: bool = False):
train_dataset.focus_crop_prob = float(epoch_cfg.get('focus_crop_prob', 0.0))
train_dataset.focus_crop_classes = [int(x) for x in list(epoch_cfg.get('focus_crop_classes', []))]
train_dataset.focus_crop_weights = [float(x) for x in list(epoch_cfg.get('focus_crop_weights', []))]
sampler = None
active_rare_map = dict(epoch_cfg.get('rare_sampler_map', {}))
if int(getattr(args, 'rare_sampler', 0)) == 1 and len(active_rare_map) > 0:
area_focus_map = {}
if float(getattr(args, 'crack_small_area_boost', 0.0)) > 0.0:
area_focus_map[1] = float(getattr(args, 'crack_small_area_boost', 0.0))
sample_weights = train_dataset.build_presence_sample_weights(
active_rare_map,
area_focus_map=area_focus_map if len(area_focus_map) > 0 else None,
area_focus_ref=float(getattr(args, 'crack_area_ref', 0.02)),
area_focus_power=float(getattr(args, 'crack_area_power', 0.5)),
area_focus_cap=float(getattr(args, 'crack_area_cap', 3.0)),
)
n_samples = int(args.task_sampling_num_samples) if int(args.task_sampling_num_samples) > 0 else len(train_dataset)
sampler = WeightedRandomSampler(sample_weights, num_samples=n_samples, replacement=True)
if log_change:
logger.info(
f"Epoch curriculum [{epoch_cfg.get('stage','base')}] rare sampler: map={active_rare_map}, "
f"focus_classes={train_dataset.focus_crop_classes}, focus_weights={train_dataset.focus_crop_weights}, "
f"focus_prob={float(train_dataset.focus_crop_prob):.3f}, area_focus={area_focus_map}, "
f"min_w={float(sample_weights.min().item()):.4f}, mean_w={float(sample_weights.mean().item()):.4f}, max_w={float(sample_weights.max().item()):.4f}"
)
elif args.task_balanced_sampling == 1:
sampler = build_task_mixed_sampler(
train_dataset.task_folders,
alpha=args.task_sampling_alpha,
power=args.task_sampling_power,
num_samples=args.task_sampling_num_samples,
)
return DataLoader(
train_dataset,
batch_size=args.batch_size,
sampler=sampler,
shuffle=(sampler is None),
num_workers=args.num_workers,
pin_memory=bool(int(getattr(args, "pin_memory", 0)) == 1),
persistent_workers=bool(int(getattr(args, "persistent_workers", 0)) == 1 and int(args.num_workers) > 0),
drop_last=True,
)
train_dataloader = build_train_dataloader_for_epoch(active_epoch_cfg, log_change=True)
val_dataset = PavementMultiClassTileDB(
data_root=args.data_path,
split="val",
train=False,
tile_size=int(args.tile_size),
tile_stride=int(args.tile_stride),
modal="VehicleProfiler",
prompt_mode=str(args.prompt_mode),
box_jitter=0,
use_crack_crop=False,
crack_crop_prob=0.0,
crack_class_id=1,
focus_crop_prob=0.0,
focus_crop_classes=[],
focus_crop_weights=[],
tile_jitter=0,
cache_size=int(getattr(args, "val_cache_size", 0)),
collect_tile_stats=bool(int(getattr(args, 'debug_epoch_json', 0)) == 1),
tile_stats_cache=True,
)
logger.info(f"Number of validation tiles: {len(val_dataset)}")
val_dataloader = DataLoader(
val_dataset,
batch_size=args.batch_size,
shuffle=False,
num_workers=args.num_workers,
pin_memory=bool(int(getattr(args, "pin_memory", 0)) == 1),