| |
| """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 = { |
| "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) |
|
|
| |
| 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) |
|
|
| |
| 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] |
|
|
| |
| 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") |
|
|
| |
| 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 |
| 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}") |
|
|
| |
| 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 |
|
|
| |
| 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) |
|
|
| |
| 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)) |
|
|
| |
| 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) |
|
|
| |
| 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() |
|
|