orbital-chaos-predictor / scripts /train_expanded_features.py
datamatters24's picture
Upload scripts/train_expanded_features.py with huggingface_hub
202c069 verified
Raw
History Blame Contribute Delete
15.8 kB
#!/usr/bin/env python3
"""Train multi-modal ISS model with expanded solar wind features + ablation.
Tasks:
1. Train full 13-feature model
2. Leave-one-out ablation (12 runs, dropping one feature group each)
3. Re-run storm-conditioned at Kp >= 4, 5, 6
Usage:
python scripts/train_expanded_features.py
python scripts/train_expanded_features.py --skip-ablation
Outputs:
checkpoints/multimodal_iss_6h_13feat_best.pt
results/ablation_results.csv
results/storm_eval_expanded.csv
results/expanded_feature_summary.md
"""
import argparse
import csv
import json
import logging
import sys
import time
from pathlib import Path
import numpy as np
import pandas as pd
import torch
import torch.nn as nn
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
log = logging.getLogger("expanded-features")
DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
CHECKPOINT_DIR = Path("checkpoints")
RESULTS_DIR = Path("results")
CHECKPOINT_DIR.mkdir(exist_ok=True)
RESULTS_DIR.mkdir(exist_ok=True)
SEED = 42
# Feature groups for ablation
# Derived features ablated as single units even if decomposed
FEATURE_GROUPS = {
"bx_gse": ["bx_gse_norm"],
"by_gse": ["by_gse_norm"],
"bz_gse": ["bz_gse_norm"],
"flow_speed": ["flow_speed_norm"],
"proton_density": ["proton_density_norm"],
"kp": ["kp_norm"],
"dst": ["dst_norm"],
"ae": ["ae_norm"],
"al": ["al_norm"],
"au": ["au_norm"],
"clock_angle": ["clock_angle_sin_norm", "clock_angle_cos_norm"],
"dynamic_pressure": ["dynamic_pressure_norm"],
}
def set_seed(seed):
torch.manual_seed(seed)
np.random.seed(seed)
if torch.cuda.is_available():
torch.cuda.manual_seed_all(seed)
def load_and_prepare_data():
"""Load orbit + expanded solar wind, create multimodal windows."""
from scripts.train_gpu import (
load_spacecraft_data, load_solar_wind_data,
preprocess_orbit, preprocess_solar_wind,
create_multimodal_windows,
)
log.info("Loading orbit data...")
orbit_df = load_spacecraft_data("iss")
orbit_processed, orbit_stats = preprocess_orbit(orbit_df, "iss")
log.info(f"Orbit: {len(orbit_processed)} rows")
log.info("Loading solar wind data...")
sw_df = load_solar_wind_data()
sw_processed, sw_stats = preprocess_solar_wind(sw_df)
log.info(f"Solar wind: {len(sw_processed)} rows")
sw_norm_cols = sorted([c for c in sw_processed.columns if c.endswith("_norm")])
log.info(f"Solar wind features ({len(sw_norm_cols)}): {sw_norm_cols}")
log.info("Creating multimodal windows...")
o_wins, s_wins, t_wins = create_multimodal_windows(
orbit_processed, sw_processed, 1440, 360, 360, subsample=1
)
log.info(f"Windows: {len(o_wins)} | orbit: {o_wins.shape} | sw: {s_wins.shape}")
return o_wins, s_wins, t_wins, orbit_stats, sw_stats, sw_norm_cols, sw_df
def split_data(o_wins, s_wins, t_wins):
n = len(o_wins)
n_train, n_val = int(0.7 * n), int(0.15 * n)
return {
"train": (o_wins[:n_train], s_wins[:n_train], t_wins[:n_train]),
"val": (o_wins[n_train:n_train+n_val], s_wins[n_train:n_train+n_val], t_wins[n_train:n_train+n_val]),
"test": (o_wins[n_train+n_val:], s_wins[n_train+n_val:], t_wins[n_train+n_val:]),
}
def train_multimodal_model(orbit_train, sw_train, target_train,
orbit_val, sw_val, target_val,
solar_input_dim, name, epochs=100, patience=15):
"""Two-phase training matching paper Section 4.4."""
from scripts.train_gpu import SolarWindOrbitModel
model = SolarWindOrbitModel(
orbit_input_dim=orbit_train.shape[-1],
solar_input_dim=solar_input_dim,
hidden_dim=128, num_layers=3, nhead=8,
horizon=target_train.shape[1], output_dim=target_train.shape[-1], dropout=0.1,
).to(DEVICE)
log.info(f" {name}: {sum(p.numel() for p in model.parameters()):,} params, sw_dim={solar_input_dim}")
train_ds = torch.utils.data.TensorDataset(
torch.from_numpy(orbit_train), torch.from_numpy(sw_train), torch.from_numpy(target_train))
val_ds = torch.utils.data.TensorDataset(
torch.from_numpy(orbit_val), torch.from_numpy(sw_val), torch.from_numpy(target_val))
train_dl = torch.utils.data.DataLoader(train_ds, batch_size=64, shuffle=True)
val_dl = torch.utils.data.DataLoader(val_ds, batch_size=64)
criterion = nn.MSELoss()
best_val = float("inf")
best_state = None
def run_epoch(dl, training=True):
losses = []
for o, s, t in dl:
o, s, t = o.to(DEVICE), s.to(DEVICE), t.to(DEVICE)
if training:
optimizer.zero_grad()
pred = model(o, s)
ml = min(pred.shape[1], t.shape[1])
loss = criterion(pred[:, :ml], t[:, :ml])
if training:
loss.backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
optimizer.step()
losses.append(loss.item())
return np.mean(losses)
# Phase 1: freeze solar branch (20 epochs, LR=1e-3)
model.freeze_solar_branch()
optimizer = torch.optim.AdamW(
[p for p in model.parameters() if p.requires_grad], lr=1e-3, weight_decay=0.01)
scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=20)
for epoch in range(1, 21):
model.train()
run_epoch(train_dl, training=True)
scheduler.step()
model.eval()
with torch.no_grad():
avg_v = run_epoch(val_dl, training=False)
if avg_v < best_val:
best_val = avg_v
best_state = {k: v.cpu().clone() for k, v in model.state_dict().items()}
if epoch % 10 == 0:
log.info(f" P1 {epoch}/20 val={avg_v:.6f}")
if best_state:
model.load_state_dict(best_state)
# Phase 2: unfreeze all (80 epochs, LR=1e-4)
model.unfreeze_all()
optimizer = torch.optim.AdamW(model.parameters(), lr=1e-4, weight_decay=0.01)
scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=epochs - 20)
patience_ctr = 0
for epoch in range(1, epochs - 20 + 1):
model.train()
run_epoch(train_dl, training=True)
scheduler.step()
model.eval()
with torch.no_grad():
avg_v = run_epoch(val_dl, training=False)
if avg_v < best_val:
best_val = avg_v
patience_ctr = 0
best_state = {k: v.cpu().clone() for k, v in model.state_dict().items()}
else:
patience_ctr += 1
if patience_ctr >= patience:
log.info(f" Early stop P2 epoch {epoch}")
break
if epoch % 20 == 0:
log.info(f" P2 {epoch}/{epochs-20} val={avg_v:.6f}")
if best_state:
model.load_state_dict(best_state)
return model, best_val
def compute_mae(model, orbit_test, sw_test, target_test, orbit_stats):
from scripts.train_gpu import denormalize
model.eval()
all_preds = []
for b in range(0, len(orbit_test), 64):
with torch.no_grad():
o = torch.from_numpy(orbit_test[b:b+64]).float().to(DEVICE)
s = torch.from_numpy(sw_test[b:b+64]).float().to(DEVICE)
all_preds.append(model(o, s).cpu().numpy())
preds = np.concatenate(all_preds)
preds_km = denormalize(preds, orbit_stats)
tgts_km = denormalize(target_test, orbit_stats)
return float(np.mean(np.sqrt(np.sum((preds_km - tgts_km)**2, axis=-1))))
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--skip-ablation", action="store_true")
args = parser.parse_args()
start_time = time.time()
log.info("=" * 60)
log.info("EXPANDED FEATURE TRAINING + ABLATION")
log.info(f"Device: {DEVICE}")
log.info("=" * 60)
o_wins, s_wins, t_wins, orbit_stats, sw_stats, sw_norm_cols, sw_df = load_and_prepare_data()
splits = split_data(o_wins, s_wins, t_wins)
full_feat_count = s_wins.shape[-1]
# ── Train full model ──
log.info(f"\nTRAINING FULL MODEL ({full_feat_count} features)")
set_seed(SEED)
model, best_val = train_multimodal_model(
*splits["train"], *splits["val"][:2], splits["val"][2],
solar_input_dim=full_feat_count,
name=f"multimodal_iss_6h_{full_feat_count}feat",
)
ckpt_name = f"multimodal_iss_6h_{full_feat_count}feat_best.pt"
torch.save({
"model_state_dict": model.state_dict(), "val_loss": best_val,
"feature_count": full_feat_count, "feature_columns": sw_norm_cols,
}, CHECKPOINT_DIR / ckpt_name)
baseline_mae = compute_mae(model, *splits["test"], orbit_stats)
log.info(f"Full model MAE: {baseline_mae:.1f} km")
# ── Leave-one-out ablation ──
ablation_results = []
noise_features = []
if not args.skip_ablation:
log.info(f"\nLEAVE-ONE-OUT ABLATION ({len(FEATURE_GROUPS)} runs)")
for group_name, group_cols in FEATURE_GROUPS.items():
missing = [c for c in group_cols if c not in sw_norm_cols]
if missing:
log.warning(f" Skip {group_name}: missing {missing}")
continue
drop_idx = [sw_norm_cols.index(c) for c in group_cols]
keep_idx = [i for i in range(len(sw_norm_cols)) if i not in drop_idx]
reduced_dim = len(keep_idx)
log.info(f"\n Without {group_name} ({reduced_dim} features)")
set_seed(SEED)
try:
abl_model, _ = train_multimodal_model(
splits["train"][0], splits["train"][1][:, :, keep_idx], splits["train"][2],
splits["val"][0], splits["val"][1][:, :, keep_idx], splits["val"][2],
solar_input_dim=reduced_dim, name=f"without_{group_name}",
)
abl_mae = compute_mae(
abl_model, splits["test"][0], splits["test"][1][:, :, keep_idx],
splits["test"][2], orbit_stats
)
delta = abl_mae - baseline_mae
keep = delta >= 0 # removal worsened MAE = feature helps
if not keep:
noise_features.append(group_name)
log.info(f" MAE: {abl_mae:.1f} km (delta: {delta:+.1f}) {'KEEP' if keep else 'NOISE'}")
ablation_results.append({
"feature": group_name, "mae_without": round(abl_mae, 1),
"delta_vs_baseline": round(delta, 1), "keep": keep,
})
except Exception as e:
log.error(f" FAILED: {e}")
ablation_results.append({
"feature": group_name, "mae_without": None,
"delta_vs_baseline": None, "keep": None,
})
with open(RESULTS_DIR / "ablation_results.csv", "w", newline="") as f:
w = csv.DictWriter(f, fieldnames=["feature", "mae_without", "delta_vs_baseline", "keep"])
w.writeheader()
w.writerows(ablation_results)
log.info(f"\nAblation saved to results/ablation_results.csv")
if noise_features:
log.info(f"Noise features: {noise_features}")
# ── Storm evaluation ──
log.info(f"\nSTORM EVALUATION (Kp >= 4, 5, 6)")
from scripts.eval_storm_conditioned import assign_kp
from scripts.train_gpu import denormalize, load_spacecraft_data, preprocess_orbit
# Get test window timestamps
orbit_df = load_spacecraft_data("iss")
orbit_processed, _ = preprocess_orbit(orbit_df, "iss")
time_diffs = orbit_processed["time"].diff().dt.total_seconds()
orbit_processed["segment_id"] = (time_diffs > 600).cumsum()
window_starts = []
for _, seg in orbit_processed.groupby("segment_id"):
if len(seg) < 1800:
continue
times = seg["time"].values
for i in range(0, len(seg) - 1800, 360):
window_starts.append(times[i + 1440])
test_start = int(0.85 * len(window_starts))
test_times = np.array(window_starts[test_start:])
test_len = min(len(test_times), len(splits["test"][0]))
test_times = test_times[:test_len]
test_kp = assign_kp(test_times, sw_df)
# Compute predictions
model.eval()
all_preds = []
for b in range(0, test_len, 64):
with torch.no_grad():
o = torch.from_numpy(splits["test"][0][b:b+64]).float().to(DEVICE)
s = torch.from_numpy(splits["test"][1][b:b+64]).float().to(DEVICE)
all_preds.append(model(o, s).cpu().numpy())
mm_preds = np.concatenate(all_preds)[:test_len]
mm_preds_km = denormalize(mm_preds, orbit_stats)
tgts_km = denormalize(splits["test"][2][:test_len], orbit_stats)
mm_dist = np.sqrt(np.sum((mm_preds_km - tgts_km)**2, axis=-1))
# Also LSTM
lstm_dist = None
lstm_ckpt = CHECKPOINT_DIR / "lstm_iss_6h_best.pt"
if lstm_ckpt.exists():
from scripts.eval_storm_conditioned import load_model
lstm = load_model("lstm", lstm_ckpt, input_dim=o_wins.shape[-1])
lstm.eval()
lstm_preds_list = []
for b in range(0, test_len, 64):
with torch.no_grad():
o = torch.from_numpy(splits["test"][0][b:b+64]).float().to(DEVICE)
lstm_preds_list.append(lstm(o).cpu().numpy())
lstm_preds = np.concatenate(lstm_preds_list)[:test_len]
lstm_km = denormalize(lstm_preds, orbit_stats)
lstm_dist = np.sqrt(np.sum((lstm_km - tgts_km)**2, axis=-1))
storm_results = []
for model_name, distances in [("multimodal_13feat", mm_dist), ("lstm", lstm_dist)]:
if distances is None:
continue
for threshold in ["all", "quiet", 4, 5, 6]:
if threshold == "all":
mask = np.ones(len(test_kp), dtype=bool)
elif threshold == "quiet":
mask = test_kp <= 3
else:
mask = test_kp >= threshold
n = int(mask.sum())
mae = round(float(np.mean(distances[mask])), 1) if n > 0 else None
underpowered = n < 20 if isinstance(threshold, int) else False
flag = " [UNDERPOWERED]" if underpowered else ""
log.info(f" {model_name} Kp>={threshold}: n={n}, MAE={mae}{flag}")
storm_results.append({
"model": model_name, "kp_threshold": str(threshold),
"n_samples": n, "mae_km": mae, "underpowered": underpowered,
})
with open(RESULTS_DIR / "storm_eval_expanded.csv", "w", newline="") as f:
w = csv.DictWriter(f, fieldnames=["model", "kp_threshold", "n_samples", "mae_km", "underpowered"])
w.writeheader()
w.writerows(storm_results)
# ── Summary ──
elapsed = time.time() - start_time
summary = f"""# Expanded Feature Summary
## Feature Set
- **Total features:** {full_feat_count}
- **F10.7 solar flux:** EXCLUDED (daily cadence only, leakage risk for live pipeline)
- **Features:** {', '.join(c.replace('_norm','') for c in sw_norm_cols)}
## Baseline MAE
- Full model ({full_feat_count} features): **{baseline_mae:.1f} km**
## Ablation
{"See results/ablation_results.csv" if args.skip_ablation else ""}
{("Noise features: " + ", ".join(noise_features)) if noise_features else "No features flagged as noise."}
## Storm Window Counts
See results/storm_eval_expanded.csv
## Runtime
{elapsed/60:.1f} minutes on {DEVICE}
"""
with open(RESULTS_DIR / "expanded_feature_summary.md", "w") as f:
f.write(summary)
log.info(f"\nDone in {elapsed/60:.1f} minutes")
if __name__ == "__main__":
main()