Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
ea8ed2c
feat: Add HEALPix curriculum
TillHae Sep 1, 2026
a422f9e
add HL curriculum config
TillHae Sep 1, 2026
6e4572e
change config for testing
TillHae Sep 2, 2026
08e65fc
fix: catch interpolation error during config load
TillHae Sep 2, 2026
fe03107
fix: avoid evaluating streams_directory early and fix imports
TillHae Sep 2, 2026
9f45c08
fix: map curriculum streams natively to avoid omegaconf int key error
TillHae Sep 2, 2026
828f868
fix: remove buggy streams_directory interpolation from config
TillHae Sep 2, 2026
2413625
fix: safely handle integer keys when stripping interpolations from Om…
TillHae Sep 2, 2026
8d329bd
fix: read _curriculum_exit from trainer.cf, not the local cf copy
TillHae Sep 2, 2026
5329eaa
fix: save as _latest checkpoint on curriculum exit so restart can fin…
TillHae Sep 2, 2026
02d1532
fix: re-apply init_ddp on curriculum restart to fix device placement
TillHae Sep 2, 2026
f7d068e
fix: correctly read rank/local_rank from dist when already initialize…
TillHae Sep 2, 2026
ac9cd0c
fix: preserve istep across curriculum restarts so healpix_level is co…
TillHae Sep 2, 2026
61d1804
debug: log curriculum state at first batch to diagnose exit not firing
TillHae Sep 2, 2026
ed67dc6
change isteps for faster testing
TillHae Sep 2, 2026
a1c5130
chore: remove curriculum debug log
TillHae Sep 2, 2026
f573b84
fix: deduplicate string/int keys in healpix_curriculum dictionary on …
TillHae Sep 2, 2026
498c4c6
fix: prevent infinite restart loops when curriculum reaches its maxim…
TillHae Sep 2, 2026
66d763a
increase isteps for meaningful plots
TillHae Sep 2, 2026
28a245f
feat: add clear logging to explain curriculum vs standard training ru…
TillHae Sep 2, 2026
d9fb931
ruff
TillHae Sep 2, 2026
fcbc706
fix: replace getattr with .get to satisfy pylint W0141
TillHae Sep 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions config/config_curriculum.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
healpix_curriculum:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you explain what this config means / describes

3: 250
4: 250
5: 250
6: 250

curriculum_streams:
3: "./config/streams/era5_1deg/"
4: "./config/streams/era5_1deg/"
5: "./config/streams/era5_1deg/"
6: "./config/streams/era5_1deg/"
49 changes: 36 additions & 13 deletions packages/common/src/weathergen/common/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
import yaml.constructor
import yaml.scanner
from omegaconf import DictConfig, ListConfig, OmegaConf
from omegaconf.errors import InterpolationKeyError, InterpolationResolutionError
from omegaconf.omegaconf import open_dict

from weathergen.common.io import StoreType
Expand Down Expand Up @@ -139,25 +140,25 @@ def _strip_interpolation(conf: Config) -> Config:
"""Recursively convert interpolated timedelta/datetime objects to strings."""
stripped = {}
if OmegaConf.is_dict(conf):
for key in list(conf.keys()):
key = str(key)
if OmegaConf.is_missing(conf, key):
for orig_key in list(conf.keys()):
str_key = str(orig_key)
if OmegaConf.is_missing(conf, orig_key):
val = "???"
elif OmegaConf.is_config(conf[key]):
val = _strip_interpolation(conf[key])
elif key.startswith("_"):
elif OmegaConf.is_config(conf[orig_key]):
val = _strip_interpolation(conf[orig_key])
elif str_key.startswith("_"):
continue # Skip hidden/backup keys
elif OmegaConf.is_interpolation(conf, key):
raw_key = f"_{key}"
elif OmegaConf.is_interpolation(conf, orig_key):
raw_key = f"_{str_key}"
assert raw_key in conf, (
f"Backup key: {raw_key} expected for interpolated key: {key}"
f"Backup key: {raw_key} expected for interpolated key: {orig_key}"
)
# Retrieve the value from the backup key (resolves interpolation)
val = conf[raw_key]
else:
val = conf[key]
val = conf[orig_key]

stripped[key] = val
stripped[str_key] = val
elif OmegaConf.is_list(conf):
stripped = [
_strip_interpolation(item) if OmegaConf.is_config(item) else item for item in conf
Expand Down Expand Up @@ -456,20 +457,42 @@ def load_merge_configs(
with open_dict(base_config):
base_config.from_run_id = from_run_id
# streams from an overwrite's streams_directory replace inherited streams
if any(o.get("streams_directory") is not None for o in overwrite_configs):
if any("streams_directory" in o for o in overwrite_configs):
base_config.streams = None
# use OmegaConf.unsafe_merge if too slow
c = OmegaConf.merge(base_config, private_config, *overwrite_configs)
assert isinstance(c, Config)
c = _sanitize_time_keys(c)

if c.get("healpix_curriculum"):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we avoid this in config.py? And very specific functionality there turned out to be problematic.

istep = c.get("general", {}).get("istep", 0)
cumulative = 0
current_hl = None
unique_curr = {int(hl): steps for hl, steps in c.healpix_curriculum.items()}
for hl in sorted(unique_curr.keys()):
cumulative += unique_curr[hl]
current_hl = hl
if istep < cumulative:
break
c.healpix_level = current_hl

if c.get("curriculum_streams"):
# Support both integer and string keys in the yaml
c.streams_directory = c.curriculum_streams.get(current_hl) or c.curriculum_streams.get(
str(current_hl)
)

return c


def _load_streams_in_config(config: Config) -> Config:
"""If the config contains a streams_directory, loads the streams and returns the config with
the streams set."""
streams_directory = config.get("streams_directory", None)
try:
streams_directory = config.get("streams_directory", None)
except (InterpolationKeyError, InterpolationResolutionError):
streams_directory = None

config = config.copy()
if streams_directory is not None:
streams_directory = Path(streams_directory)
Expand Down
61 changes: 59 additions & 2 deletions src/weathergen/run_train.py
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,36 @@ def run_continue(args):
trainer = Trainer(cf.train_logging)

try:
trainer.run(cf, devices, args.from_run_id, args.mini_epoch)
from_run_id_iter = args.from_run_id
mini_epoch_iter = args.mini_epoch
first_run = True
istep_override = {}
while True:
if not first_run:
cf = config.load_merge_configs(
args.private_config,
from_run_id_iter,
mini_epoch_iter,
args.base_config,
*args.config,
istep_override,
cli_overwrite,
)
cf = config.set_run_id(cf, cf.general.run_id, True)
cf = Trainer.init_ddp(cf)
cf.streams = config.load_streams(Path(cf.streams_directory))
trainer = Trainer(cf.train_logging)

trainer.run(cf, devices, from_run_id_iter, mini_epoch_iter)
first_run = False

if not trainer.cf.get("_curriculum_exit", False):
break

logger.info("Restarting training for next curriculum stage...")
from_run_id_iter = trainer.cf.general.run_id
mini_epoch_iter = -1
istep_override = {"general": {"istep": trainer.cf.general.istep}}
except Exception:
extype, value, tb = sys.exc_info()
traceback.print_exc()
Expand Down Expand Up @@ -188,7 +217,35 @@ def run_train(args):
trainer = Trainer(cf.train_logging)

try:
trainer.run(cf, devices)
from_run_id_iter = None

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This block does not belong to run_train.py. What is done here?

mini_epoch_iter = None
istep_override = {}
while True:
if from_run_id_iter is not None:
cf = config.load_merge_configs(
args.private_config,
from_run_id_iter,
mini_epoch_iter,
args.base_config,
*args.config,
istep_override,
cli_overwrite,
)
cf = config.set_run_id(cf, cf.general.run_id, True)
cf = Trainer.init_ddp(cf)
cf.streams = config.load_streams(Path(cf.streams_directory))
trainer = Trainer(cf.train_logging)
trainer.run(cf, devices, from_run_id_iter, mini_epoch_iter)
else:
trainer.run(cf, devices)

if not trainer.cf.get("_curriculum_exit", False):
break

logger.info("Restarting training for next curriculum stage...")
from_run_id_iter = trainer.cf.general.run_id
mini_epoch_iter = -1
istep_override = {"general": {"istep": trainer.cf.general.istep}}
except Exception:
extype, value, tb = sys.exc_info()
traceback.print_exc()
Expand Down
44 changes: 43 additions & 1 deletion src/weathergen/train/trainer.py
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,24 @@ def run(self, cf, devices, run_id_contd=None, mini_epoch_contd=None):
if self.cf.general.istep > 0 and is_root():
logger.info(f"Continuing run with learning rate: {self.lr_scheduler.get_lr()}")

if hasattr(self.cf, "healpix_curriculum") and self.cf.healpix_curriculum and is_root():

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be performed in a separate function

unique_curr = {int(hl): steps for hl, steps in self.cf.healpix_curriculum.items()}
max_hl = max(unique_curr.keys())
if self.cf.healpix_level < max_hl:
cumulative = sum(
steps for hl, steps in unique_curr.items() if hl <= self.cf.healpix_level
)
logger.info(
f"Curriculum active: Training HEALPix level {self.cf.healpix_level}. "
f"Next stage will begin at istep {cumulative}. "
f"(Note: Total run length is dictated by num_mini_epochs)"
)
else:
logger.info(
f"Curriculum max level ({max_hl}) reached. "
f"Continuing standard training until num_mini_epochs limit."
)

# Instantiate loss calculator modules to compute losses
self.loss_calculator = LossCalculator(cf, self.training_cfg, TRAIN, device=self.device)
val_cfg = self.validation_cfg
Expand Down Expand Up @@ -407,8 +425,16 @@ def run(self, cf, devices, run_id_contd=None, mini_epoch_contd=None):
)
self.save_model(mini_epoch)

if self.cf.get("_curriculum_exit", False):
if is_root():
logger.info("Curriculum stage completed. Exiting training loop.")
break

# log final model
self.save_model(self.training_cfg.num_mini_epochs)
if self.cf.get("_curriculum_exit", False):
self.save_model(-1)
else:
self.save_model(self.training_cfg.num_mini_epochs)

def validate_before_training(self):
"""
Expand Down Expand Up @@ -568,6 +594,22 @@ def train(self, mini_epoch):

self.cf.general.istep += 1

if hasattr(self.cf, "healpix_curriculum") and self.cf.healpix_curriculum:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's try to avoid this complexity in the main training loop. Can you make a suggestion?

unique_curr = {int(hl): steps for hl, steps in self.cf.healpix_curriculum.items()}
max_hl = max(unique_curr.keys())
if self.cf.healpix_level < max_hl:
cumulative = sum(
steps for hl, steps in unique_curr.items() if hl <= self.cf.healpix_level
)
if self.cf.general.istep >= cumulative:
if is_root():
logger.info(
f"Curriculum stage for HEALPix level {self.cf.healpix_level} "
f"finished at istep {self.cf.general.istep}. Exiting early."
)
self.cf._curriculum_exit = True
break

self.dataset.advance()

def validate(self, mini_epoch, mode_cfg, batch_size):
Expand Down
5 changes: 5 additions & 0 deletions src/weathergen/train/trainer_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,11 @@ def init_ddp(cf):
dist.all_reduce(l_seed, op=torch.distributed.ReduceOp.SUM)
cf.data_loader_rng_seed = l_seed.item()

if dist.is_initialized():

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this change in the PR? It seems unrelated to the functionality that is targeted.

rank = dist.get_rank()
world_size = dist.get_world_size()
local_rank = int(os.environ.get("LOCAL_RANK", os.environ.get("SLURM_LOCALID", "0")))

cf.world_size = world_size
cf.rank = rank
cf.local_rank = local_rank
Expand Down
Loading