Production Run — Full Code
This page gives the complete, self-contained code needed to go from raw GWOSC data to a
trained Sage model. It mirrors the structure of runs/o3b/ in the repository.
A production run has three distinct phases:
Dataset preparation — download and index noise data, estimate PSDs.
Configuration — define all hyperparameters in a config class.
Training loop — assemble all components and run epoch-by-epoch.
Phases 1 and 2 are one-time setup; phase 3 is the iterative training.
Phase 1: Dataset preparation
Save this as dataset.py (see runs/o3b/dataset.py):
import math
from sage.data.primer import DataReleaseDownloader, TimelineQuery, EstimatePSD, NoBlackout
from sage.dsp.welch import TorchWelch
from sage.data.noise import MemmapSingleNoiseSampler
from sage.data.psd import smoothing
from sage.core.config import get_cfg, get_data_cfg
def _get_buffer(data_cfg):
return math.ceil(0.2 * data_cfg.sample_rate) / data_cfg.sample_rate
def _get_timeline(data_cfg):
tq = TimelineQuery(
detector=["H1", "L1"],
observing_run=["O3b"],
auto_clean_empty_timelines=True,
)
tq.download_segments()
tq.prune_segments(
rm_short_segments=True, rm_min_duration=22.0,
rm_allevents=True, rm_window_length=30,
)
buffer = _get_buffer(data_cfg)
tq.split_into_mini_segments(
mini_segment_length=512.0 + buffer * 2.0,
minimum_segment_duration=16.0,
)
tq.sanity_check_mini_segments(
mini_segment_length=512.0 + buffer * 2.0,
minimum_segment_duration=16.0,
verbose=True,
)
return tq
def _download(tq, data_cfg):
buffer = _get_buffer(data_cfg)
drd = DataReleaseDownloader(
segments_metadata=tq.timeline,
save_parent_dir="/work/sage/",
noise_low_freq_cutoff=15.0,
minimum_segment_duration=22.0,
corrupt_trim_length=buffer,
max_download_retries=15,
retry_delay=5.0,
num_workers=16,
make_monolithic_file=True,
sample_rate=data_cfg.sample_rate,
save_bin=True,
)
drd.download()
def _make_psds(detector, data_cfg):
epsd = EstimatePSD(
detector=detector,
apply_inverse_spectrum_truncation=False,
max_filter_len=int(round(2048.0 * 2)),
low_frequency_cutoff=15.0,
trunc_method="hann",
psd_method=TorchWelch(
delta_t=1 / 2048,
seg_len=int(2048.0 * 4),
seg_stride=int(2048.0 * 2),
avg_method="median",
),
num_samples=250_000,
store_psds_as_bin=True,
blackout_policy=NoBlackout(),
interpolate_psd=True,
training_sample_length=int(2048.0 * 16),
psd_smoothener=smoothing.LogSplineSmoothing(
smooth_factor=None,
noise_low_frequency_cutoff=data_cfg.noise_low_frequency_cutoff,
),
)
bin_path = f"/work/sage/data_release/data_{detector}_O3b.bin"
epsd.estimate_segment_psds(noise_segments_file=bin_path)
epsd.estimate_raw_psds(
noise_sampler=MemmapSingleNoiseSampler(bin_path, return_tensor=True),
duration=int(round(2048.0 * 16)),
)
def make_dataset():
cfg, data_cfg = get_cfg(), get_data_cfg()
tq = _get_timeline(data_cfg)
_download(tq, data_cfg)
for det in ["H1", "L1"]:
_make_psds(det, data_cfg)
Phase 2: Configuration
Save this as config.py (see runs/o3b/config.py):
import torch
from sage.core.config import register_configs
from sage.core.base_classes import BaseConfig, BaseDataConfig
class RunCFG:
export_dir = "./run_export"
batch_size = 128
device = "cuda:0"
dtype = torch.float32
detectors = ["H1", "L1"]
do_point_estimate = ["tc", "mchirp"] # parameters to regress
autocast = True # mixed precision (float16)
class_balance = 0.5 # fraction of batch = signals
clip_norm = 1.0
num_epochs = 80
training_iterations = int(2_000_000 / batch_size) # ~15 625 batches/epoch
validation_iterations = int(200_000 / batch_size) # ~1 562 batches/epoch
class RunDataCFG:
data_dir = "/work/sage/data_dir"
training_noise_files = [
"/work/sage/data_release/data_H1_O3b.bin",
"/work/sage/data_release/data_L1_O3b.bin",
]
validation_noise_files = [
"/work/sage/o3a/data_release/data_H1_O3a.bin",
"/work/sage/o3a/data_release/data_L1_O3a.bin",
]
sample_rate = 2048.0 # Hz
noise_low_frequency_cutoff = 15.0 # Hz
signal_low_frequency_cutoff= 20.0 # Hz
sample_length_in_s = 12.0 # seconds
padding_length_in_s = 2.0 # seconds
def set_configs():
register_configs(BaseConfig(RunCFG()), BaseDataConfig(RunDataCFG()))
Note
training_noise_files points at O3b data; validation_noise_files points at
O3a data. Validating on a different run’s noise is intentional — it tests
out-of-distribution robustness without contaminating the training set.
Phase 3: Training loop
Save this as train.py (see runs/o3b/train.py):
import os
import torch
torch._dynamo.config.verbose = False
torch._inductor.config.debug = False
torch.backends.cudnn.benchmark = True
torch.autograd.set_detect_anomaly(False)
torch.cuda.empty_cache()
torch._dynamo.reset()
from sage.core.config import get_cfg, get_data_cfg
from sage.data.waveform import read_from_config, ConstantProjection, IMRPhenomPv2
from sage.data.waveform import HalfNorm
from sage.data.waveform.snr import OptimalSNRRescaler
from sage.data.noise import MemmapNoiseSampler, RecolourPostprocess
from sage.dsp.whiten import FiducialWhitening
from sage.dsp.multirate_sampling import MultirateSampler, DyadicPyramidBinning
from sage.core.graph import Preprocessor
from sage.architecture.network import MSCNN1D_2DResNetCBAM_Heteroscedastic
from sage.architecture.custom_losses import BCEWithPEsigmaLoss
from sage.core.logger import HDF5LossLogger
from sage.utils.checkpoint import CheckpointManager
from sage.factory.training import SageUncompiledTraining
from sage.factory.validation import SageUncompiledValidation
import torch.optim as optim
from torch.optim.lr_scheduler import CosineAnnealingWarmRestarts
from config import set_configs
def make_training_graph():
param_sampler = read_from_config("./gwconfig.yaml", seed=150914)
snrscaler = OptimalSNRRescaler(HalfNorm(scale=4.0, loc=5.0, seed=150914))
signal_sampler = IMRPhenomPv2(param_sampler, ConstantProjection(), augment=snrscaler)
noise_sampler = MemmapNoiseSampler(
postprocess_fn=RecolourPostprocess(p_recolour=0.37,
recolour_dataset_dir="/work/sage/data_dir"),
prefetch=8, seed=150914,
)
return signal_sampler, noise_sampler, param_sampler.bounds
def make_validation_graph():
param_sampler = read_from_config("./gwconfig.yaml", seed=170817)
snrscaler = OptimalSNRRescaler(HalfNorm(scale=4.0, loc=5.0, seed=170817))
signal_sampler = IMRPhenomPv2(param_sampler, ConstantProjection(), augment=snrscaler)
noise_sampler = MemmapNoiseSampler(postprocess_fn=None, prefetch=8, seed=170817)
return signal_sampler, noise_sampler
def make_processor(bounds):
return Preprocessor([
FiducialWhitening(),
MultirateSampler(binning_method=DyadicPyramidBinning(bounds)),
])
def run_sage():
set_configs()
cfg, data_cfg = get_cfg(), get_data_cfg()
train_sig, train_noise, bounds = make_training_graph()
val_sig, val_noise = make_validation_graph()
processor = make_processor(bounds)
# ── Model ──────────────────────────────────────────────────────────────
model = MSCNN1D_2DResNetCBAM_Heteroscedastic(
frontend_filters=32,
frontend_kernel=64,
backend_resnet_size=50,
norm_type="instancenorm",
).to(dtype=cfg.dtype, device=cfg.device, memory_format=torch.channels_last)
print(f"Trainable parameters: {sum(p.numel() for p in model.parameters()):,}")
model = torch.compile(model, mode="max-autotune", fullgraph=True, dynamic=True)
# ── Loss, optimiser, scheduler ─────────────────────────────────────────
loss_fn = BCEWithPEsigmaLoss(regression_weight=0.005, coupling_weight=0.005)
optimiser = optim.Adam(model.parameters(), lr=2e-4, weight_decay=1e-6, fused=True)
scheduler = CosineAnnealingWarmRestarts(optimiser, T_0=5, T_mult=2, eta_min=1e-6)
scaler = torch.amp.GradScaler(cfg.device, enabled=cfg.autocast)
# ── Training and validation loops ──────────────────────────────────────
train_sage = SageUncompiledTraining(
train_sig, train_noise, processor, model, loss_fn,
optimiser, scheduler, scaler,
num_iterations=cfg.training_iterations,
num_epochs=cfg.num_epochs,
)
validate_sage = SageUncompiledValidation(
val_sig, val_noise, processor, model, loss_fn,
num_iterations=cfg.validation_iterations,
num_epochs=cfg.num_epochs,
)
# ── Checkpointing and logging ──────────────────────────────────────────
ckpt_mgr = CheckpointManager(
cfg=cfg, data_cfg=data_cfg,
model=model, optimizer=optimiser,
scheduler=scheduler, scaler=scaler,
)
logger = HDF5LossLogger(
path=os.path.join(cfg.export_dir, "losses.h5"),
num_epochs=cfg.num_epochs,
num_components=loss_fn.num_components,
)
# ── Main loop ──────────────────────────────────────────────────────────
for nepoch in range(cfg.num_epochs):
print(f"\nEpoch {nepoch}: Training")
train_sage(nepoch=nepoch)
logger.log(train_sage.loss_components, nepoch, split="training")
if (nepoch + 1) % 5 == 0 or nepoch == 0:
print(f"Epoch {nepoch}: Validating")
validate_sage(nepoch=nepoch)
logger.log(validate_sage.loss_components, nepoch, split="validation")
val_loss = validate_sage.loss_components[nepoch][0].item()
ckpt_mgr.save(epoch=nepoch, val_loss=val_loss)
Running the full pipeline
# Step 1 — one-time dataset preparation (hours on a cluster node)
python3 -c "from config import set_configs; set_configs(); \
from dataset import make_dataset; make_dataset()"
# Step 2 — training (80 epochs, ~2 M samples/epoch with batch 128)
python3 -c "from train import run_sage; run_sage()"
Or use the provided shell script in runs/o3b/start.sh:
bash runs/o3b/start.sh
Export directory layout
After a complete run, run_export/ contains:
run_export/
├── checkpoints/
│ ├── best.pt ← lowest-val-loss checkpoint
│ └── latest.pt ← most recent checkpoint
├── losses.h5 ← per-epoch loss components (training + validation)
├── validation_data.h5 ← per-epoch network outputs and targets for diagnostics
└── fiducial_psds/
├── fiducial_H1_psd.bin
└── fiducial_L1_psd.bin