| |
| """Ensemble: average LSTM + Multi-modal predictions. |
| |
| Usage: |
| python scripts/eval_ensemble.py --spacecraft iss |
| """ |
| import argparse |
| import json |
| import logging |
| import sys |
| from pathlib import Path |
|
|
| import numpy as np |
| import pandas as pd |
| import torch |
| import yaml |
|
|
| 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("ensemble") |
|
|
| RESULTS_DIR = Path("results") |
| CHECKPOINT_DIR = Path("checkpoints") |
| DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu") |
|
|
|
|
| def main(): |
| parser = argparse.ArgumentParser() |
| parser.add_argument("--spacecraft", default="iss") |
| args = parser.parse_args() |
| sc = args.spacecraft |
|
|
| with open("config.yaml") as f: |
| config = yaml.safe_load(f) |
|
|
| from scripts.eval_storm_conditioned import load_model, assign_kp |
| from src.data.preprocessing import OrbitPreprocessor |
|
|
| proc = OrbitPreprocessor() |
| df = pd.read_parquet(f"data/raw/{sc}_2023-01-01_2025-12-31.parquet") |
| processed = proc.preprocess(df, sc) |
| stats = proc.stats |
|
|
| time_res = config["model"]["time_resolution_minutes"] |
| input_steps = (config["model"]["input_hours"] * 60) // time_res |
| horizon_steps = (6 * 60) // time_res |
| stride_steps = horizon_steps |
|
|
| norm_feat_cols = sorted([c for c in processed.columns if c.endswith("_norm")]) |
| norm_tgt_cols = ["x_gse_norm", "y_gse_norm", "z_gse_norm"] |
|
|
| inputs, targets, window_times = [], [], [] |
| for _, seg in processed.groupby("segment_id"): |
| if len(seg) < input_steps + horizon_steps: |
| continue |
| feats = seg[norm_feat_cols].values |
| tgts = seg[norm_tgt_cols].values |
| times = seg["time"].values |
| for i in range(0, len(seg) - input_steps - horizon_steps, stride_steps): |
| inputs.append(feats[i:i + input_steps]) |
| targets.append(tgts[i + input_steps:i + input_steps + horizon_steps]) |
| window_times.append(times[i + input_steps]) |
|
|
| inputs = np.array(inputs, dtype=np.float32) |
| targets = np.array(targets, dtype=np.float32) |
| window_times = np.array(window_times) |
|
|
| test_start = int(0.85 * len(inputs)) |
| test_inputs = inputs[test_start:] |
| test_targets = targets[test_start:] |
| test_times = window_times[test_start:] |
|
|
| sw_path = Path("data/raw/solar_wind_2023-01-01_2025-12-31.parquet") |
| solar_df = pd.read_parquet(sw_path) |
| test_kp = assign_kp(test_times, solar_df) |
|
|
| |
| lstm = load_model("lstm", CHECKPOINT_DIR / f"lstm_{sc}_6h_best.pt", input_dim=inputs.shape[-1]) |
| mm = load_model("multimodal", CHECKPOINT_DIR / f"multimodal_{sc}_6h_best.pt", input_dim=inputs.shape[-1]) |
|
|
| |
| batch_size = 64 |
| lstm_preds_list, mm_preds_list = [], [] |
|
|
| for b in range(0, len(test_inputs), batch_size): |
| with torch.no_grad(): |
| x = torch.from_numpy(test_inputs[b:b+batch_size]).float().to(DEVICE) |
| lstm_preds_list.append(lstm(x).cpu().numpy()) |
| sw_zeros = torch.zeros(x.shape[0], input_steps, 8).to(DEVICE) |
| mm_preds_list.append(mm(x, sw_zeros).cpu().numpy()) |
|
|
| lstm_preds = np.concatenate(lstm_preds_list) |
| mm_preds = np.concatenate(mm_preds_list) |
| ensemble_preds = (lstm_preds + mm_preds) / 2.0 |
|
|
| conditions = { |
| "all": np.ones(len(test_kp), dtype=bool), |
| "quiet": test_kp <= 3, |
| "active": (test_kp >= 4) & (test_kp <= 5), |
| "storm": test_kp >= 6, |
| } |
|
|
| ensemble_results = {} |
| for cond_name, mask in conditions.items(): |
| n = mask.sum() |
| if n == 0: |
| ensemble_results[cond_name] = None |
| continue |
|
|
| preds_km = np.zeros_like(ensemble_preds[mask]) |
| tgts_km = np.zeros_like(test_targets[mask]) |
| for i, col in enumerate(["x_gse", "y_gse", "z_gse"]): |
| std = stats[sc]["std"][col] |
| mean = stats[sc]["mean"][col] |
| preds_km[..., i] = ensemble_preds[mask][..., i] * std + mean |
| tgts_km[..., i] = test_targets[mask][..., i] * std + mean |
|
|
| distances = np.sqrt(np.sum((preds_km - tgts_km)**2, axis=-1)) |
| mae = round(float(np.mean(distances)), 1) |
| ensemble_results[cond_name] = mae |
| log.info(f"Ensemble {cond_name} (n={n}): {mae:.1f} km") |
|
|
| |
| storm_path = RESULTS_DIR / "storm_conditioned_mae.json" |
| if storm_path.exists(): |
| with open(storm_path) as f: |
| all_results = json.load(f) |
| else: |
| all_results = {} |
|
|
| if "ensemble" not in all_results: |
| all_results["ensemble"] = {} |
| all_results["ensemble"][sc] = ensemble_results |
|
|
| with open(storm_path, "w") as f: |
| json.dump(all_results, f, indent=2) |
| log.info(f"Saved to {storm_path}") |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|