| """ |
| All 5 evaluations for Campus Weather VAE. |
| Run: python evaluate.py |
| """ |
| import os, sys, json |
| sys.path.insert(0, os.path.dirname(__file__)) |
| import numpy as np |
| import torch |
| from torch.utils.data import DataLoader |
| from sklearn.metrics import r2_score, mean_absolute_error, mean_squared_error |
| from sklearn.cluster import KMeans |
| from sklearn.linear_model import Ridge |
| from sklearn.decomposition import PCA |
| from model import WeatherVAE, get_config |
| from train import load_nus40, FlatDataset, VAR_NAMES, VAR_UNITS, load_trained, train_model |
|
|
| SAVE = '/app/campus_weather/results' |
| DATA = '/app/data_tmp/imputed' |
| DEVICE = 'cpu' |
|
|
|
|
| def eval_reconstruction(model, data, t_split): |
| """Basic reconstruction quality on test set.""" |
| test = data[t_split:] |
| x = torch.from_numpy(test.reshape(-1, 6)) |
| with torch.no_grad(): |
| out = model(x) |
| pred, true = out['x_hat'].numpy(), x.numpy() |
| |
| results = {} |
| for v, (name, unit) in enumerate(zip(VAR_NAMES, VAR_UNITS)): |
| results[name] = { |
| 'MAE': float(mean_absolute_error(true[:, v], pred[:, v])), |
| 'RMSE': float(np.sqrt(mean_squared_error(true[:, v], pred[:, v]))), |
| 'R2': float(r2_score(true[:, v], pred[:, v])), |
| } |
| return results |
|
|
|
|
| def eval1_spatial_interpolation(data, coords): |
| """ |
| Hold out 5 geographically spread stations. Train on 35. |
| Reconstruct held-out stations using embeddings from neighbouring stations. |
| """ |
| print("\n" + "="*60) |
| print("EVAL 1: Spatial Interpolation (hold-out 5 stations)") |
| print("="*60) |
| |
| N = data.shape[1] |
| |
| holdout_idx = [4, 12, 20, 30, 37] |
| train_idx = [i for i in range(N) if i not in holdout_idx] |
| |
| print(f"Hold-out stations: {[i+1 for i in holdout_idx]}") |
| print(f"Training stations: {len(train_idx)}") |
| |
| |
| T = data.shape[0] |
| t_tr = int(T * 0.7) |
| data_35 = data[:, train_idx, :] |
| |
| cfg = get_config('base') |
| model_35 = WeatherVAE(**cfg) |
| mean = torch.tensor(data_35[:t_tr].mean(axis=(0,1)), dtype=torch.float32) |
| std = torch.tensor(data_35[:t_tr].std(axis=(0,1)), dtype=torch.float32) |
| model_35.set_normalisation(mean, std) |
| |
| |
| import torch.optim as optim |
| opt = optim.AdamW(model_35.parameters(), lr=5e-4, weight_decay=0.01) |
| tr_ds = FlatDataset(data_35[:t_tr]) |
| tr_ld = DataLoader(tr_ds, batch_size=256, shuffle=True, drop_last=True) |
| |
| model_35.train() |
| for ep in range(50): |
| for batch in tr_ld: |
| out = model_35(batch) |
| out['loss'].backward() |
| torch.nn.utils.clip_grad_norm_(model_35.parameters(), 1.0) |
| opt.step(); opt.zero_grad() |
| model_35.eval() |
| |
| |
| from sklearn.neighbors import NearestNeighbors |
| train_coords = coords[train_idx] |
| holdout_coords = coords[holdout_idx] |
| |
| nn_model = NearestNeighbors(n_neighbors=5).fit(train_coords) |
| _, nn_idx = nn_model.kneighbors(holdout_coords) |
| |
| t_test = int(T * 0.85) |
| test_data = data[t_test:] |
| |
| results = {} |
| for hi, ho_station in enumerate(holdout_idx): |
| |
| neighbour_stations = [train_idx[j] for j in nn_idx[hi]] |
| neighbour_data = test_data[:, neighbour_stations, :] |
| |
| |
| with torch.no_grad(): |
| n_flat = torch.from_numpy(neighbour_data.reshape(-1, 6)) |
| n_emb = model_35.get_embedding(n_flat).numpy() |
| n_emb = n_emb.reshape(test_data.shape[0], 5, -1).mean(axis=1) |
| |
| pred = model_35.decode(torch.from_numpy(n_emb)).numpy() |
| |
| true = test_data[:, ho_station, :] |
| |
| station_results = {} |
| for v, name in enumerate(VAR_NAMES): |
| station_results[name] = { |
| 'MAE': float(mean_absolute_error(true[:, v], pred[:, v])), |
| 'R2': float(r2_score(true[:, v], pred[:, v])), |
| } |
| results[f'WS{ho_station+1:02d}'] = station_results |
| print(f" WS{ho_station+1:02d}: AirTemp MAE={station_results['AirTemp']['MAE']:.3f}°C, " |
| f"R²={station_results['AirTemp']['R2']:.4f}") |
| |
| |
| avg = {} |
| for name in VAR_NAMES: |
| maes = [results[s][name]['MAE'] for s in results] |
| r2s = [results[s][name]['R2'] for s in results] |
| avg[name] = {'MAE': float(np.mean(maes)), 'R2': float(np.mean(r2s))} |
| results['average'] = avg |
| print(f"\n Average: AirTemp MAE={avg['AirTemp']['MAE']:.3f}°C R²={avg['AirTemp']['R2']:.4f} | " |
| f"RelHum MAE={avg['RelHum']['MAE']:.3f}% R²={avg['RelHum']['R2']:.4f}") |
| |
| return results |
|
|
|
|
| def eval2_temporal_forecasting(embeddings, data): |
| """ |
| Forecast T+1/6/24 using linear regression on embeddings. |
| Compare vs persistence and climatology baselines. |
| """ |
| print("\n" + "="*60) |
| print("EVAL 2: Temporal Forecasting (embedding vs baselines)") |
| print("="*60) |
| |
| T, N, V = data.shape |
| t_tr, t_te = int(T * 0.7), int(T * 0.85) |
| hours = np.arange(T) % 24 |
| |
| |
| climatology = np.zeros((24, N, V)) |
| for h in range(24): |
| mask = hours[:t_tr] == h |
| climatology[h] = data[:t_tr][mask].mean(axis=0) |
| |
| results = {} |
| for horizon in [1, 6, 24]: |
| |
| tr_X, tr_Y = [], [] |
| for t in range(0, t_tr - horizon): |
| tr_X.append(embeddings[t].reshape(N, -1)) |
| tr_Y.append(data[t + horizon]) |
| tr_X = np.array(tr_X).reshape(-1, embeddings.shape[-1]) |
| tr_Y = np.array(tr_Y).reshape(-1, V) |
| |
| te_X, te_Y = [], [] |
| te_persist, te_clim = [], [] |
| for t in range(t_te, T - horizon): |
| te_X.append(embeddings[t].reshape(N, -1)) |
| te_Y.append(data[t + horizon]) |
| te_persist.append(data[t]) |
| te_clim.append(climatology[(t + horizon) % 24]) |
| te_X = np.array(te_X).reshape(-1, embeddings.shape[-1]) |
| te_Y = np.array(te_Y).reshape(-1, V) |
| te_persist = np.array(te_persist).reshape(-1, V) |
| te_clim = np.array(te_clim).reshape(-1, V) |
| |
| horizon_results = {} |
| for v, name in enumerate(VAR_NAMES): |
| reg = Ridge(alpha=1.0) |
| reg.fit(tr_X, tr_Y[:, v]) |
| pred = reg.predict(te_X) |
| |
| mae_emb = mean_absolute_error(te_Y[:, v], pred) |
| mae_persist = mean_absolute_error(te_Y[:, v], te_persist[:, v]) |
| mae_clim = mean_absolute_error(te_Y[:, v], te_clim[:, v]) |
| |
| horizon_results[name] = { |
| 'MAE_embedding': float(mae_emb), |
| 'MAE_persistence': float(mae_persist), |
| 'MAE_climatology': float(mae_clim), |
| } |
| |
| results[f'T+{horizon}'] = horizon_results |
| print(f"\n T+{horizon}h:") |
| print(f" {'Variable':>12s} {'Embedding':>10s} {'Persist':>10s} {'Climat':>10s} {'Skill':>8s}") |
| for name in ['AirTemp', 'RelHum', 'GlobalRad', 'WindSpeed']: |
| r = horizon_results[name] |
| skill = 1 - r['MAE_embedding'] / r['MAE_persistence'] |
| print(f" {name:>12s} {r['MAE_embedding']:>10.3f} {r['MAE_persistence']:>10.3f} " |
| f"{r['MAE_climatology']:>10.3f} {skill:>7.1%}") |
| |
| return results |
|
|
|
|
| def eval3_clustering(embeddings, coords): |
| """ |
| Unsupervised microclimate zone discovery from station embeddings. |
| """ |
| print("\n" + "="*60) |
| print("EVAL 3: Microclimate Clustering") |
| print("="*60) |
| |
| |
| station_emb = embeddings.mean(axis=0) |
| |
| |
| results = {} |
| for k in [3, 4, 5]: |
| km = KMeans(n_clusters=k, random_state=42, n_init=10) |
| labels = km.fit_predict(station_emb) |
| inertia = km.inertia_ |
| |
| |
| from sklearn.metrics import silhouette_score |
| sil = silhouette_score(station_emb, labels) if k < len(station_emb) else 0 |
| |
| results[f'K={k}'] = { |
| 'labels': labels.tolist(), |
| 'inertia': float(inertia), |
| 'silhouette': float(sil), |
| } |
| print(f" K={k}: silhouette={sil:.3f}") |
| for c in range(k): |
| stations = [i+1 for i in range(len(labels)) if labels[i] == c] |
| lat_mean = coords[labels == c, 0].mean() |
| lng_mean = coords[labels == c, 1].mean() |
| print(f" Cluster {c}: {len(stations)} stations, " |
| f"centroid=({lat_mean:.4f}, {lng_mean:.4f}), stations={stations[:8]}...") |
| |
| |
| pca = PCA(n_components=2) |
| station_2d = pca.fit_transform(station_emb) |
| results['pca_2d'] = station_2d.tolist() |
| results['explained_var'] = pca.explained_variance_ratio_.tolist() |
| |
| return results |
|
|
|
|
| def eval4_anomaly_detection(model, data, embeddings): |
| """ |
| Anomaly detection via reconstruction error. |
| High error = unusual weather the model hasn't learned. |
| """ |
| print("\n" + "="*60) |
| print("EVAL 4: Anomaly Detection") |
| print("="*60) |
| |
| T, N, V = data.shape |
| |
| |
| recon_errors = np.zeros((T, N)) |
| with torch.no_grad(): |
| for t in range(0, T, 500): |
| chunk = data[t:t+500] |
| ct, cn = chunk.shape[0], chunk.shape[1] |
| x = torch.from_numpy(chunk.reshape(-1, V)) |
| out = model(x) |
| err = (out['x_hat'].numpy() - x.numpy()) ** 2 |
| err = err.mean(axis=1) |
| recon_errors[t:t+ct] = err.reshape(ct, cn) |
| |
| |
| station_mean_err = recon_errors.mean(axis=0) |
| |
| |
| campus_err = recon_errors.mean(axis=1) |
| top_anomalies_idx = np.argsort(campus_err)[-20:][::-1] |
| |
| |
| threshold = np.percentile(campus_err, 95) |
| anomaly_mask = campus_err > threshold |
| n_anomalies = anomaly_mask.sum() |
| |
| |
| rain = data[:, :, 5] |
| campus_rain = rain.mean(axis=1) |
| anomaly_rain = campus_rain[anomaly_mask].mean() |
| normal_rain = campus_rain[~anomaly_mask].mean() |
| |
| hours_of_day = np.arange(T) % 24 |
| anomaly_hours = hours_of_day[anomaly_mask] |
| hour_dist = np.bincount(anomaly_hours.astype(int), minlength=24) |
| |
| results = { |
| 'station_mean_error': station_mean_err.tolist(), |
| 'threshold_95': float(threshold), |
| 'n_anomalies': int(n_anomalies), |
| 'anomaly_rate': float(n_anomalies / T), |
| 'mean_globalrad_anomaly': float(anomaly_rain), |
| 'mean_globalrad_normal': float(normal_rain), |
| 'anomaly_hour_distribution': hour_dist.tolist(), |
| 'top_20_indices': top_anomalies_idx.tolist(), |
| 'campus_error_timeseries': campus_err.tolist(), |
| } |
| |
| print(f" Threshold (95th pct): {threshold:.4f}") |
| print(f" Anomalous hours: {n_anomalies}/{T} ({n_anomalies/T*100:.1f}%)") |
| print(f" Mean GlobalRad during anomalies: {anomaly_rain:.1f} vs normal: {normal_rain:.1f} W/m²") |
| print(f" Peak anomaly hours: {np.argsort(hour_dist)[-3:][::-1].tolist()}") |
| |
| return results |
|
|
|
|
| def eval5_future_prediction(embeddings, data): |
| """ |
| Multi-step rolling forecast. At each test hour, predict next 24 hours. |
| Compare vs persistence and climatology. |
| """ |
| print("\n" + "="*60) |
| print("EVAL 5: Rolling 24h Future Prediction") |
| print("="*60) |
| |
| T, N, V = data.shape |
| t_tr, t_te = int(T * 0.7), int(T * 0.85) |
| hours = np.arange(T) % 24 |
| |
| |
| climatology = np.zeros((24, N, V)) |
| for h in range(24): |
| mask = hours[:t_tr] == h |
| climatology[h] = data[:t_tr][mask].mean(axis=0) |
| |
| |
| models = {} |
| for horizon in range(1, 25): |
| tr_X = embeddings[:t_tr - horizon].reshape(-1, embeddings.shape[-1]) |
| tr_Y = data[horizon:t_tr].reshape(-1, V) |
| for v in range(V): |
| reg = Ridge(alpha=1.0) |
| reg.fit(tr_X, tr_Y[:, v]) |
| models[(horizon, v)] = reg |
| |
| |
| n_test = T - t_te - 24 |
| all_pred = np.zeros((n_test, 24, N, V)) |
| all_true = np.zeros((n_test, 24, N, V)) |
| all_persist = np.zeros((n_test, 24, N, V)) |
| all_clim = np.zeros((n_test, 24, N, V)) |
| |
| for i, t in enumerate(range(t_te, t_te + n_test)): |
| emb_t = embeddings[t].reshape(-1, embeddings.shape[-1]) |
| for h in range(24): |
| for v in range(V): |
| all_pred[i, h, :, v] = models[(h + 1, v)].predict(emb_t) |
| all_true[i, h] = data[t + h + 1] |
| all_persist[i, h] = data[t] |
| all_clim[i, h] = climatology[(t + h + 1) % 24] |
| |
| |
| results = {'per_hour': {}} |
| for h in range(24): |
| hr_results = {} |
| for v, name in enumerate(VAR_NAMES): |
| mae_emb = mean_absolute_error(all_true[:, h, :, v].flatten(), all_pred[:, h, :, v].flatten()) |
| mae_per = mean_absolute_error(all_true[:, h, :, v].flatten(), all_persist[:, h, :, v].flatten()) |
| mae_clm = mean_absolute_error(all_true[:, h, :, v].flatten(), all_clim[:, h, :, v].flatten()) |
| hr_results[name] = { |
| 'MAE_embedding': float(mae_emb), |
| 'MAE_persistence': float(mae_per), |
| 'MAE_climatology': float(mae_clm), |
| } |
| results['per_hour'][f'h+{h+1}'] = hr_results |
| |
| |
| for h_show in [0, 5, 11, 23]: |
| hr = results['per_hour'][f'h+{h_show+1}'] |
| print(f"\n h+{h_show+1}:") |
| for name in ['AirTemp', 'RelHum', 'GlobalRad']: |
| r = hr[name] |
| skill = 1 - r['MAE_embedding'] / r['MAE_persistence'] |
| print(f" {name:>12s}: Emb={r['MAE_embedding']:.3f} Pers={r['MAE_persistence']:.3f} " |
| f"Clim={r['MAE_climatology']:.3f} Skill={skill:.1%}") |
| |
| return results |
|
|
|
|
| def run_all(): |
| """Run all 5 evaluations.""" |
| os.makedirs(SAVE, exist_ok=True) |
| |
| |
| model, data, coords, embeddings, ckpt = load_trained(f'{SAVE}/checkpoints') |
| T = data.shape[0] |
| t_te = int(T * 0.85) |
| |
| all_results = {} |
| |
| |
| print("="*60) |
| print("BASELINE: Reconstruction Quality") |
| print("="*60) |
| recon = eval_reconstruction(model, data, t_te) |
| all_results['reconstruction'] = recon |
| for name in VAR_NAMES: |
| r = recon[name] |
| print(f" {name:>12s}: MAE={r['MAE']:.4f} RMSE={r['RMSE']:.4f} R²={r['R2']:.4f}") |
| |
| |
| all_results['spatial_interpolation'] = eval1_spatial_interpolation(data, coords) |
| |
| |
| all_results['temporal_forecasting'] = eval2_temporal_forecasting(embeddings, data) |
| |
| |
| all_results['clustering'] = eval3_clustering(embeddings, coords) |
| |
| |
| all_results['anomaly_detection'] = eval4_anomaly_detection(model, data, embeddings) |
| |
| |
| all_results['future_prediction'] = eval5_future_prediction(embeddings, data) |
| |
| |
| |
| save_results = {} |
| for k, v in all_results.items(): |
| if k == 'anomaly_detection': |
| v2 = {kk: vv for kk, vv in v.items() if kk != 'campus_error_timeseries'} |
| save_results[k] = v2 |
| else: |
| save_results[k] = v |
| |
| with open(f'{SAVE}/all_results.json', 'w') as f: |
| json.dump(save_results, f, indent=2, default=str) |
| |
| |
| np.save(f'{SAVE}/anomaly_errors.npy', np.array(all_results['anomaly_detection']['campus_error_timeseries'])) |
| |
| print(f"\n{'='*60}") |
| print(f"All results saved to {SAVE}/") |
| return all_results |
|
|
|
|
| if __name__ == '__main__': |
| run_all() |
|
|