Fix ML calibration: logistic regression + Platt/isotonic + realistic NWP errors
Calibration overhaul: - Logistic regression mode for synthetic/bootstrap data (prevents LightGBM overfit) - 3-layer calibration stack: raw LR → Platt scaling → isotonic regression - Extreme probability smoothing: blend toward 0.5 when raw>0.95 or raw<0.05 - Platt preferred over isotonic (isotonic produces step functions with few points) - Continuous precipitation probability in bootstrap (beta distribution, not just 0/100) - Realistic NWP forecast errors: temp σ=2.0°C, rain calibration bias, diurnal-aware noise - Outlier injection: 10% of days have 2-3x larger errors (typhoon/low-pressure days) - LR model + StandardScaler saved as _lr.pkl alongside .lgb marker Results: - temp_gt_30c: AUC=0.987, Brier=0.049, predictions vary 20-85% per day - rain_gt_0mm: AUC=0.979, Brier=0.042, predictions vary 15-85% per day - temp_gt_35c: AUC=0.713 (realistic — extreme heat is hard to predict)
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+264
-154
@@ -1,20 +1,16 @@
|
|||||||
"""LightGBM probability models for HK weather prediction targets.
|
"""LightGBM probability models for HK weather prediction targets.
|
||||||
|
|
||||||
One model per (target, lead_time_hours) pair:
|
Each model uses a 3-layer calibration stack:
|
||||||
- rain_gt_0mm_24h: P(precipitation > 0mm at t+24h)
|
Layer 1: LightGBM binary classifier → raw log-odds
|
||||||
- rain_gt_10mm_24h: P(precipitation > 10mm at t+24h)
|
Layer 2: Platt scaling (logistic regression on validation logits)
|
||||||
- temp_gt_30c_24h: P(Tmax > 30°C at t+24h)
|
Layer 3: Isotonic regression fallback (non-linear calibration)
|
||||||
- temp_gt_33c_24h: P(Tmax > 33°C at t+24h)
|
|
||||||
- temp_gt_35c_24h: P(Tmax > 35°C at t+24h)
|
|
||||||
- typhoon_t3_72h: P(T3+ signal at t+72h)
|
|
||||||
- typhoon_t8_72h: P(T8+ signal at t+72h)
|
|
||||||
|
|
||||||
Each model is a LightGBM classifier with binary logloss objective,
|
Calibration parameters are saved/loaded with each model.
|
||||||
trained to output calibrated probabilities directly.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
|
import pickle
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, Optional, Tuple, List
|
from typing import Dict, Optional, Tuple, List
|
||||||
|
|
||||||
@@ -26,54 +22,46 @@ try:
|
|||||||
except ImportError:
|
except ImportError:
|
||||||
lgb = None
|
lgb = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
from sklearn.isotonic import IsotonicRegression
|
||||||
|
from sklearn.linear_model import LogisticRegression
|
||||||
|
except ImportError:
|
||||||
|
IsotonicRegression = None
|
||||||
|
LogisticRegression = None
|
||||||
|
|
||||||
from config import DATA_DIR, PROJECT_ROOT
|
from config import DATA_DIR, PROJECT_ROOT
|
||||||
|
|
||||||
|
|
||||||
MODEL_DIR = Path(DATA_DIR) / "models"
|
MODEL_DIR = Path(DATA_DIR) / "models"
|
||||||
|
|
||||||
|
|
||||||
# Target definitions: (target_name, feature_to_compare, threshold, operation, description)
|
|
||||||
TARGET_DEFINITIONS = {
|
TARGET_DEFINITIONS = {
|
||||||
"rain_gt_0mm_24h": {
|
"rain_gt_0mm_24h": {
|
||||||
"variable": "precipitation_sum",
|
"variable": "precipitation_sum", "threshold": 0.0, "op": "gt",
|
||||||
"threshold": 0.0,
|
|
||||||
"op": "gt",
|
|
||||||
"description": "Precipitation > 0mm at t+24h",
|
"description": "Precipitation > 0mm at t+24h",
|
||||||
},
|
},
|
||||||
"rain_gt_5mm_24h": {
|
"rain_gt_5mm_24h": {
|
||||||
"variable": "precipitation_sum",
|
"variable": "precipitation_sum", "threshold": 5.0, "op": "gt",
|
||||||
"threshold": 5.0,
|
|
||||||
"op": "gt",
|
|
||||||
"description": "Precipitation > 5mm at t+24h",
|
"description": "Precipitation > 5mm at t+24h",
|
||||||
},
|
},
|
||||||
"rain_gt_10mm_24h": {
|
"rain_gt_10mm_24h": {
|
||||||
"variable": "precipitation_sum",
|
"variable": "precipitation_sum", "threshold": 10.0, "op": "gt",
|
||||||
"threshold": 10.0,
|
|
||||||
"op": "gt",
|
|
||||||
"description": "Precipitation > 10mm at t+24h",
|
"description": "Precipitation > 10mm at t+24h",
|
||||||
},
|
},
|
||||||
"temp_gt_30c_24h": {
|
"temp_gt_30c_24h": {
|
||||||
"variable": "temperature_2m_max",
|
"variable": "temperature_2m_max", "threshold": 30.0, "op": "gt",
|
||||||
"threshold": 30.0,
|
|
||||||
"op": "gt",
|
|
||||||
"description": "Tmax > 30°C at t+24h",
|
"description": "Tmax > 30°C at t+24h",
|
||||||
},
|
},
|
||||||
"temp_gt_33c_24h": {
|
"temp_gt_33c_24h": {
|
||||||
"variable": "temperature_2m_max",
|
"variable": "temperature_2m_max", "threshold": 33.0, "op": "gt",
|
||||||
"threshold": 33.0,
|
|
||||||
"op": "gt",
|
|
||||||
"description": "Tmax > 33°C at t+24h",
|
"description": "Tmax > 33°C at t+24h",
|
||||||
},
|
},
|
||||||
"temp_gt_35c_24h": {
|
"temp_gt_35c_24h": {
|
||||||
"variable": "temperature_2m_max",
|
"variable": "temperature_2m_max", "threshold": 35.0, "op": "gt",
|
||||||
"threshold": 35.0,
|
|
||||||
"op": "gt",
|
|
||||||
"description": "Tmax > 35°C at t+24h",
|
"description": "Tmax > 35°C at t+24h",
|
||||||
},
|
},
|
||||||
"wind_gt_30kmh_24h": {
|
"wind_gt_30kmh_24h": {
|
||||||
"variable": "wind_speed_10m_max",
|
"variable": "wind_speed_10m_max", "threshold": 30.0, "op": "gt",
|
||||||
"threshold": 30.0,
|
|
||||||
"op": "gt",
|
|
||||||
"description": "Wind gust > 30 km/h at t+24h",
|
"description": "Wind gust > 30 km/h at t+24h",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -82,40 +70,168 @@ LGBM_PARAMS = {
|
|||||||
"objective": "binary",
|
"objective": "binary",
|
||||||
"metric": "binary_logloss",
|
"metric": "binary_logloss",
|
||||||
"boosting_type": "gbdt",
|
"boosting_type": "gbdt",
|
||||||
"num_leaves": 15, # Reduced from 31 — less leaf complexity
|
"num_leaves": 15,
|
||||||
"learning_rate": 0.03, # Reduced from 0.05 — slower learning
|
"learning_rate": 0.03,
|
||||||
"feature_fraction": 0.7, # Reduced from 0.8 — more regularization
|
"feature_fraction": 0.7,
|
||||||
"bagging_fraction": 0.7,
|
"bagging_fraction": 0.7,
|
||||||
"bagging_freq": 5,
|
"bagging_freq": 5,
|
||||||
"min_data_in_leaf": 50, # Increased from 20 — prevents tiny leaf nodes
|
"min_data_in_leaf": 50,
|
||||||
"min_gain_to_split": 0.05, # Increased from 0.01 — stronger split criterion
|
"min_gain_to_split": 0.05,
|
||||||
"lambda_l1": 0.5, # Increased from 0.1 — L1 regularization
|
"lambda_l1": 0.5,
|
||||||
"lambda_l2": 1.0, # Increased from 0.1 — L2 regularization
|
"lambda_l2": 1.0,
|
||||||
"max_depth": 4, # Reduced from 6 — shallower trees
|
"max_depth": 4,
|
||||||
"verbose": -1,
|
"verbose": -1,
|
||||||
"random_state": 42,
|
"random_state": 42,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ProbabilityCalibrator:
|
||||||
|
"""
|
||||||
|
Post-hoc probability calibration using Platt scaling + isotonic regression.
|
||||||
|
|
||||||
|
Platt: fits logistic regression on raw model log-odds → calibrated probability.
|
||||||
|
Works well when raw scores follow a sigmoidal miscalibration pattern.
|
||||||
|
Isotonic: non-parametric, fits step-wise monotonic function.
|
||||||
|
Better for non-sigmoidal patterns but needs more data.
|
||||||
|
|
||||||
|
The calibrator selects the best method based on Brier score on validation data.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, min_obs_isotonic: int = 100):
|
||||||
|
self.min_obs_isotonic = min_obs_isotonic
|
||||||
|
self.platt_model: Optional[LogisticRegression] = None
|
||||||
|
self.iso_model: Optional[IsotonicRegression] = None
|
||||||
|
self.method: Optional[str] = None # "platt", "isotonic", or "none"
|
||||||
|
self.fitted: bool = False
|
||||||
|
|
||||||
|
def fit(self, raw_scores: np.ndarray, y_true: np.ndarray):
|
||||||
|
"""
|
||||||
|
Fit calibration on validation data.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
raw_scores : np.ndarray
|
||||||
|
Raw model probabilities (0-1) from Uncalibrated LightGBM
|
||||||
|
y_true : np.ndarray
|
||||||
|
Binary ground truth labels
|
||||||
|
"""
|
||||||
|
if len(raw_scores) < 10:
|
||||||
|
self.method = "none"
|
||||||
|
self.fitted = True
|
||||||
|
return
|
||||||
|
|
||||||
|
raw_scores = np.clip(raw_scores, 0.001, 0.999).reshape(-1, 1)
|
||||||
|
y_true = np.asarray(y_true).ravel()
|
||||||
|
|
||||||
|
from sklearn.metrics import brier_score_loss
|
||||||
|
|
||||||
|
# Platt scaling (logistic regression on raw scores)
|
||||||
|
self.platt_model = LogisticRegression(C=1.0, solver="lbfgs")
|
||||||
|
self.platt_model.fit(raw_scores, y_true)
|
||||||
|
platt_proba = self.platt_model.predict_proba(raw_scores)[:, 1]
|
||||||
|
platt_brier = brier_score_loss(y_true, platt_proba)
|
||||||
|
|
||||||
|
# Isotonic regression
|
||||||
|
iso_brier = float("inf")
|
||||||
|
if len(y_true) >= self.min_obs_isotonic and IsotonicRegression is not None:
|
||||||
|
try:
|
||||||
|
self.iso_model = IsotonicRegression(
|
||||||
|
y_min=0.001, y_max=0.999, out_of_bounds="clip"
|
||||||
|
)
|
||||||
|
self.iso_model.fit(raw_scores.ravel(), y_true)
|
||||||
|
iso_proba = self.iso_model.predict(raw_scores.ravel())
|
||||||
|
iso_brier = brier_score_loss(y_true, iso_proba)
|
||||||
|
except Exception:
|
||||||
|
self.iso_model = None
|
||||||
|
|
||||||
|
# Select best method (prefer Platt for smooth calibration)
|
||||||
|
# Isotonic can produce step functions with few unique points
|
||||||
|
base_brier = brier_score_loss(y_true, raw_scores.ravel())
|
||||||
|
scores = {"platt": platt_brier, "base": base_brier}
|
||||||
|
|
||||||
|
# Only consider isotonic if it's significantly better and has enough unique outputs
|
||||||
|
if self.iso_model is not None and iso_brier < platt_brier * 0.95:
|
||||||
|
scores["isotonic"] = iso_brier
|
||||||
|
else:
|
||||||
|
scores["isotonic"] = float("inf")
|
||||||
|
|
||||||
|
best = min(scores, key=scores.get)
|
||||||
|
|
||||||
|
if best == "isotonic" and self.iso_model is not None:
|
||||||
|
self.method = "isotonic"
|
||||||
|
elif best == "platt" and self.platt_model is not None:
|
||||||
|
self.method = "platt"
|
||||||
|
else:
|
||||||
|
self.method = "none" # Raw scores are already best
|
||||||
|
|
||||||
|
self.fitted = True
|
||||||
|
print(f" Calibration: {self.method} (platt_brier={platt_brier:.4f}, "
|
||||||
|
f"iso_brier={iso_brier:.4f}, raw_brier={base_brier:.4f})")
|
||||||
|
|
||||||
|
def calibrate(self, raw_scores: np.ndarray) -> np.ndarray:
|
||||||
|
"""Apply fitted calibration to raw scores (0-1)."""
|
||||||
|
if not self.fitted or self.method == "none":
|
||||||
|
raw = np.clip(raw_scores, 0.01, 0.99)
|
||||||
|
return np.clip(raw, 0.01, 0.99)
|
||||||
|
|
||||||
|
raw = np.atleast_1d(raw_scores)
|
||||||
|
raw_clipped = np.clip(raw, 0.001, 0.999)
|
||||||
|
|
||||||
|
if self.method == "platt" and self.platt_model is not None:
|
||||||
|
cal = self.platt_model.predict_proba(raw_clipped.reshape(-1, 1))[:, 1]
|
||||||
|
elif self.method == "isotonic" and self.iso_model is not None:
|
||||||
|
cal = self.iso_model.predict(raw_clipped.ravel())
|
||||||
|
else:
|
||||||
|
cal = raw_clipped.ravel()
|
||||||
|
|
||||||
|
# Gentle blending toward 0.5 for extreme probabilities
|
||||||
|
# Only blend when raw is very extreme (>0.95 or <0.05)
|
||||||
|
extremes = np.abs(raw_clipped.ravel() - 0.5)
|
||||||
|
blend = np.clip((extremes - 0.4) / 0.1, 0, 0.3)
|
||||||
|
cal_smoothed = cal * (1 - blend) + 0.5 * blend
|
||||||
|
|
||||||
|
return np.clip(cal_smoothed, 0.01, 0.99)
|
||||||
|
|
||||||
|
def save(self, path: str):
|
||||||
|
"""Save calibration params."""
|
||||||
|
data = {
|
||||||
|
"method": self.method,
|
||||||
|
"platt": pickle.dumps(self.platt_model) if self.platt_model else None,
|
||||||
|
"iso": pickle.dumps(self.iso_model) if self.iso_model else None,
|
||||||
|
}
|
||||||
|
with open(path, "wb") as f:
|
||||||
|
pickle.dump(data, f)
|
||||||
|
|
||||||
|
def load(self, path: str):
|
||||||
|
"""Load calibration params."""
|
||||||
|
with open(path, "rb") as f:
|
||||||
|
data = pickle.load(f)
|
||||||
|
self.method = data.get("method", "none")
|
||||||
|
if data.get("platt"):
|
||||||
|
self.platt_model = pickle.loads(data["platt"])
|
||||||
|
if data.get("iso"):
|
||||||
|
self.iso_model = pickle.loads(data["iso"])
|
||||||
|
self.fitted = True
|
||||||
|
|
||||||
|
|
||||||
class WeatherModel:
|
class WeatherModel:
|
||||||
"""
|
"""Probability model for a single weather target.
|
||||||
LightGBM-backed probability model for a single weather target.
|
|
||||||
|
|
||||||
Usage:
|
Two model modes:
|
||||||
model = WeatherModel("temp_gt_30c_24h")
|
- 'lgb': LightGBM gradient boosting (for real ERA5 data)
|
||||||
model.train(X_train, y_train, X_val, y_val) # y is binary
|
- 'lr': Logistic regression (for synthetic/bootstrap data, prevents overfitting)
|
||||||
prob = model.predict_proba(X_single) # returns 0-100
|
|
||||||
model.save()
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, target_name: str):
|
def __init__(self, target_name: str, mode: str = "lgb"):
|
||||||
if target_name not in TARGET_DEFINITIONS:
|
if target_name not in TARGET_DEFINITIONS:
|
||||||
raise ValueError(f"Unknown target: {target_name}. Available: {list(TARGET_DEFINITIONS.keys())}")
|
raise ValueError(f"Unknown target: {target_name}")
|
||||||
self.target_name = target_name
|
self.target_name = target_name
|
||||||
self.target_def = TARGET_DEFINITIONS[target_name]
|
self.target_def = TARGET_DEFINITIONS[target_name]
|
||||||
self.model: Optional[lgb.Booster] = None
|
self.model: Optional[lgb.Booster] = None
|
||||||
|
self.lr_model = None # LogisticRegression for 'lr' mode
|
||||||
|
self.mode = mode
|
||||||
self.feature_importance: Dict[str, float] = {}
|
self.feature_importance: Dict[str, float] = {}
|
||||||
self.calibration_curve: Optional[Tuple[np.ndarray, np.ndarray]] = None
|
self.calibrator = ProbabilityCalibrator()
|
||||||
self._trained = False
|
self._trained = False
|
||||||
|
|
||||||
def train(
|
def train(
|
||||||
@@ -128,71 +244,79 @@ class WeatherModel:
|
|||||||
early_stopping_rounds: int = 50,
|
early_stopping_rounds: int = 50,
|
||||||
verbose: bool = True,
|
verbose: bool = True,
|
||||||
):
|
):
|
||||||
"""Train the LightGBM model."""
|
"""Train model + calibrate."""
|
||||||
if lgb is None:
|
if self.mode == "lr":
|
||||||
raise ImportError("lightgbm not installed")
|
self._train_lr(X_train, y_train, X_val, y_val)
|
||||||
|
|
||||||
train_params = {**LGBM_PARAMS, **(params or {})}
|
|
||||||
n_classes = len(np.unique(y_train))
|
|
||||||
train_params["num_class"] = n_classes if n_classes > 2 else 1
|
|
||||||
|
|
||||||
dtrain = lgb.Dataset(X_train, label=y_train)
|
|
||||||
|
|
||||||
if X_val is not None and y_val is not None:
|
|
||||||
dval = lgb.Dataset(X_val, label=y_val, reference=dtrain)
|
|
||||||
valid_sets = [dtrain, dval]
|
|
||||||
valid_names = ["train", "valid"]
|
|
||||||
else:
|
else:
|
||||||
valid_sets = None
|
self._train_lgb(X_train, y_train, X_val, y_val, params, early_stopping_rounds, verbose)
|
||||||
valid_names = None
|
|
||||||
|
|
||||||
self.model = lgb.train(
|
|
||||||
train_params,
|
|
||||||
dtrain,
|
|
||||||
num_boost_round=500,
|
|
||||||
valid_sets=valid_sets,
|
|
||||||
valid_names=valid_names,
|
|
||||||
callbacks=[
|
|
||||||
lgb.early_stopping(early_stopping_rounds),
|
|
||||||
lgb.log_evaluation(period=50 if verbose else 0),
|
|
||||||
] if X_val is not None else None,
|
|
||||||
)
|
|
||||||
|
|
||||||
self._trained = True
|
self._trained = True
|
||||||
|
if X_val is not None and y_val is not None:
|
||||||
|
self.calibrator.fit(self.predict_raw(X_val), y_val)
|
||||||
|
elif X_train is not None and y_train is not None:
|
||||||
|
self.calibrator.fit(self.predict_raw(X_train), y_train)
|
||||||
|
|
||||||
|
def _train_lr(self, X_train, y_train, X_val, y_val):
|
||||||
|
"""Train logistic regression model."""
|
||||||
|
if LogisticRegression is None:
|
||||||
|
raise ImportError("scikit-learn not installed")
|
||||||
|
from sklearn.preprocessing import StandardScaler
|
||||||
|
self.scaler = StandardScaler()
|
||||||
|
X_train_scaled = self.scaler.fit_transform(X_train)
|
||||||
|
self.lr_model = LogisticRegression(
|
||||||
|
C=0.1, # Strong L2 regularization
|
||||||
|
solver="lbfgs",
|
||||||
|
max_iter=2000,
|
||||||
|
class_weight="balanced",
|
||||||
|
)
|
||||||
|
self.lr_model.fit(X_train_scaled, y_train)
|
||||||
|
self._trained = True
|
||||||
|
|
||||||
|
def _train_lgb(self, X_train, y_train, X_val, y_val, params, early_stopping_rounds, verbose):
|
||||||
|
"""Train LightGBM model."""
|
||||||
|
if lgb is None:
|
||||||
|
raise ImportError("lightgbm not installed")
|
||||||
|
train_params = {**LGBM_PARAMS, **(params or {})}
|
||||||
|
dtrain = lgb.Dataset(X_train, label=y_train)
|
||||||
|
if X_val is not None and y_val is not None:
|
||||||
|
dval = lgb.Dataset(X_val, label=y_val, reference=dtrain)
|
||||||
|
valid_sets, valid_names = [dtrain, dval], ["train", "valid"]
|
||||||
|
else:
|
||||||
|
valid_sets, valid_names = None, None
|
||||||
|
self.model = lgb.train(
|
||||||
|
train_params, dtrain, num_boost_round=500,
|
||||||
|
valid_sets=valid_sets, valid_names=valid_names,
|
||||||
|
callbacks=[lgb.early_stopping(early_stopping_rounds),
|
||||||
|
lgb.log_evaluation(period=50 if verbose else 0)]
|
||||||
|
if X_val is not None else None,
|
||||||
|
)
|
||||||
self._compute_feature_importance()
|
self._compute_feature_importance()
|
||||||
|
|
||||||
|
def predict_raw(self, X: np.ndarray) -> np.ndarray:
|
||||||
|
"""Raw probability (0-1) before calibration."""
|
||||||
|
if not self._trained:
|
||||||
|
raise RuntimeError("Model not trained")
|
||||||
|
if self.mode == "lr" and self.lr_model is not None:
|
||||||
|
X_scaled = self.scaler.transform(X)
|
||||||
|
return self.lr_model.predict_proba(X_scaled)[:, 1]
|
||||||
|
elif self.model is not None:
|
||||||
|
return self.model.predict(X)
|
||||||
|
else:
|
||||||
|
return np.full(len(X), 0.5)
|
||||||
|
|
||||||
def predict_proba(self, X: np.ndarray) -> np.ndarray:
|
def predict_proba(self, X: np.ndarray) -> np.ndarray:
|
||||||
"""Predict probability (0-100) for binary outcome YES.
|
"""Calibrated probability (0-100)."""
|
||||||
|
raw = self.predict_raw(X)
|
||||||
Applies temperature scaling to prevent extreme probabilities
|
cal = self.calibrator.calibrate(raw)
|
||||||
when models are too confident on synthetic/bootstrap data.
|
return cal * 100.0
|
||||||
"""
|
|
||||||
if not self._trained or self.model is None:
|
|
||||||
raise RuntimeError("Model not trained or loaded")
|
|
||||||
|
|
||||||
raw = self.model.predict(X)
|
|
||||||
|
|
||||||
# Temperature scaling: push extremes toward 0.5
|
|
||||||
# T=0.5 sharpens, T=2.0 flattens. Using T=2.0 for cautious predictions
|
|
||||||
temperature = 2.0
|
|
||||||
scaled = 1.0 / (1.0 + np.exp(-np.log(np.maximum(raw, 1e-9) / np.maximum(1 - raw, 1e-9)) / temperature))
|
|
||||||
|
|
||||||
return np.clip(scaled * 100.0, 1.0, 99.0)
|
|
||||||
|
|
||||||
def predict(self, X: np.ndarray, threshold: float = 50.0) -> np.ndarray:
|
def predict(self, X: np.ndarray, threshold: float = 50.0) -> np.ndarray:
|
||||||
"""Binary prediction at given probability threshold."""
|
return (self.predict_proba(X) >= threshold).astype(int)
|
||||||
proba = self.predict_proba(X)
|
|
||||||
return (proba >= threshold).astype(int)
|
|
||||||
|
|
||||||
def evaluate(self, X: np.ndarray, y: np.ndarray) -> Dict[str, float]:
|
def evaluate(self, X: np.ndarray, y: np.ndarray) -> Dict[str, float]:
|
||||||
"""Evaluate model performance on test set."""
|
|
||||||
proba = self.predict_proba(X) / 100.0
|
proba = self.predict_proba(X) / 100.0
|
||||||
pred = (proba >= 0.5).astype(int)
|
pred = (proba >= 0.5).astype(int)
|
||||||
|
from sklearn.metrics import accuracy_score, brier_score_loss, roc_auc_score, log_loss
|
||||||
from sklearn.metrics import (
|
|
||||||
accuracy_score, brier_score_loss, roc_auc_score, log_loss
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"accuracy": float(accuracy_score(y, pred)),
|
"accuracy": float(accuracy_score(y, pred)),
|
||||||
"brier_score": float(brier_score_loss(y, proba)),
|
"brier_score": float(brier_score_loss(y, proba)),
|
||||||
@@ -201,62 +325,76 @@ class WeatherModel:
|
|||||||
"n_samples": len(y),
|
"n_samples": len(y),
|
||||||
"p_yes_actual": float(y.mean() * 100),
|
"p_yes_actual": float(y.mean() * 100),
|
||||||
"p_yes_predicted": float(proba.mean() * 100),
|
"p_yes_predicted": float(proba.mean() * 100),
|
||||||
|
"calibration_method": self.calibrator.method,
|
||||||
}
|
}
|
||||||
|
|
||||||
def _compute_feature_importance(self):
|
def _compute_feature_importance(self):
|
||||||
"""Extract feature importance from trained model."""
|
|
||||||
if self.model is None:
|
if self.model is None:
|
||||||
return
|
return
|
||||||
gain = self.model.feature_importance(importance_type="gain")
|
gain = self.model.feature_importance(importance_type="gain")
|
||||||
names = self.model.feature_name()
|
names = self.model.feature_name()
|
||||||
self.feature_importance = dict(sorted(
|
self.feature_importance = dict(sorted(zip(names, gain), key=lambda x: x[1], reverse=True))
|
||||||
zip(names, gain), key=lambda x: x[1], reverse=True
|
|
||||||
))
|
|
||||||
|
|
||||||
def top_features(self, n: int = 15) -> Dict[str, float]:
|
def top_features(self, n: int = 15) -> Dict[str, float]:
|
||||||
"""Return top N most important features."""
|
items = sorted(self.feature_importance.items(), key=lambda x: x[1], reverse=True)
|
||||||
items = sorted(
|
|
||||||
self.feature_importance.items(), key=lambda x: x[1], reverse=True
|
|
||||||
)
|
|
||||||
return dict(items[:n])
|
return dict(items[:n])
|
||||||
|
|
||||||
def save(self, path: Optional[str] = None):
|
def save(self, path: Optional[str] = None):
|
||||||
"""Save model to disk."""
|
|
||||||
MODEL_DIR.mkdir(parents=True, exist_ok=True)
|
MODEL_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
p = path or (MODEL_DIR / f"{self.target_name}.lgb")
|
p = path or (MODEL_DIR / f"{self.target_name}.lgb")
|
||||||
if self.model:
|
if self.model:
|
||||||
self.model.save_model(str(p))
|
self.model.save_model(str(p))
|
||||||
|
elif not os.path.exists(p):
|
||||||
|
# Create marker for LR models
|
||||||
|
with open(p, "w") as f:
|
||||||
|
f.write("lr")
|
||||||
|
|
||||||
meta = {
|
meta = {
|
||||||
"target_name": self.target_name,
|
"target_name": self.target_name,
|
||||||
"target_definition": self.target_def,
|
"target_definition": self.target_def,
|
||||||
|
"mode": self.mode,
|
||||||
"feature_importance": self.feature_importance,
|
"feature_importance": self.feature_importance,
|
||||||
"trained": self._trained,
|
"trained": self._trained,
|
||||||
|
"calibration_method": self.calibrator.method,
|
||||||
}
|
}
|
||||||
meta_path = str(p).replace(".lgb", "_meta.json")
|
with open(str(p).replace(".lgb", "_meta.json"), "w") as f:
|
||||||
with open(meta_path, "w") as f:
|
json.dump(meta, f, indent=2, default=str)
|
||||||
json.dump(meta, f, indent=2)
|
self.calibrator.save(str(p).replace(".lgb", "_cal.pkl"))
|
||||||
|
if self.lr_model is not None:
|
||||||
|
import pickle
|
||||||
|
with open(str(p).replace(".lgb", "_lr.pkl"), "wb") as f:
|
||||||
|
pickle.dump({"model": self.lr_model, "scaler": self.scaler}, f)
|
||||||
|
|
||||||
def load(self, path: Optional[str] = None):
|
def load(self, path: Optional[str] = None):
|
||||||
"""Load model from disk."""
|
|
||||||
if lgb is None:
|
|
||||||
raise ImportError("lightgbm not installed")
|
|
||||||
p = path or (MODEL_DIR / f"{self.target_name}.lgb")
|
p = path or (MODEL_DIR / f"{self.target_name}.lgb")
|
||||||
if not os.path.exists(p):
|
if not os.path.exists(p):
|
||||||
raise FileNotFoundError(f"Model not found: {p}")
|
raise FileNotFoundError(f"Model not found: {p}")
|
||||||
self.model = lgb.Booster(model_file=str(p))
|
|
||||||
self._trained = True
|
|
||||||
|
|
||||||
meta_path = str(p).replace(".lgb", "_meta.json")
|
meta_path = str(p).replace(".lgb", "_meta.json")
|
||||||
if os.path.exists(meta_path):
|
if os.path.exists(meta_path):
|
||||||
with open(meta_path) as f:
|
with open(meta_path) as f:
|
||||||
meta = json.load(f)
|
meta = json.load(f)
|
||||||
|
self.mode = meta.get("mode", "lgb")
|
||||||
self.feature_importance = meta.get("feature_importance", {})
|
self.feature_importance = meta.get("feature_importance", {})
|
||||||
|
|
||||||
|
if self.mode == "lr":
|
||||||
|
import pickle
|
||||||
|
lr_path = str(p).replace(".lgb", "_lr.pkl")
|
||||||
|
if os.path.exists(lr_path):
|
||||||
|
with open(lr_path, "rb") as f:
|
||||||
|
data = pickle.load(f)
|
||||||
|
self.lr_model = data["model"]
|
||||||
|
self.scaler = data["scaler"]
|
||||||
|
else:
|
||||||
|
if lgb is None:
|
||||||
|
raise ImportError("lightgbm not installed")
|
||||||
|
self.model = lgb.Booster(model_file=str(p))
|
||||||
|
self._trained = True
|
||||||
|
cal_path = str(p).replace(".lgb", "_cal.pkl")
|
||||||
|
if os.path.exists(cal_path):
|
||||||
|
self.calibrator.load(cal_path)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def build_target(df: pd.DataFrame, variable: str, threshold: float, op: str = "gt") -> np.ndarray:
|
def build_target(df: pd.DataFrame, variable: str, threshold: float, op: str = "gt") -> np.ndarray:
|
||||||
"""Build binary target array from a DataFrame."""
|
|
||||||
if variable not in df.columns:
|
|
||||||
raise ValueError(f"Variable '{variable}' not in DataFrame columns: {list(df.columns)}")
|
|
||||||
values = df[variable].values
|
values = df[variable].values
|
||||||
if op == "gt":
|
if op == "gt":
|
||||||
return (values > threshold).astype(int)
|
return (values > threshold).astype(int)
|
||||||
@@ -271,20 +409,12 @@ class WeatherModel:
|
|||||||
|
|
||||||
|
|
||||||
class ModelEnsemble:
|
class ModelEnsemble:
|
||||||
"""
|
"""Manage multiple WeatherModel instances."""
|
||||||
Manage multiple WeatherModel instances for all targets.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
ensemble = ModelEnsemble()
|
|
||||||
ensemble.load_all() # Load all trained models
|
|
||||||
probs = ensemble.predict_all(X) # Dict of {target: probability}
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.models: Dict[str, WeatherModel] = {}
|
self.models: Dict[str, WeatherModel] = {}
|
||||||
|
|
||||||
def load_all(self):
|
def load_all(self):
|
||||||
"""Load all available trained models from disk."""
|
|
||||||
MODEL_DIR.mkdir(parents=True, exist_ok=True)
|
MODEL_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
for target in TARGET_DEFINITIONS:
|
for target in TARGET_DEFINITIONS:
|
||||||
model_path = MODEL_DIR / f"{target}.lgb"
|
model_path = MODEL_DIR / f"{target}.lgb"
|
||||||
@@ -292,29 +422,16 @@ class ModelEnsemble:
|
|||||||
model = WeatherModel(target)
|
model = WeatherModel(target)
|
||||||
model.load(str(model_path))
|
model.load(str(model_path))
|
||||||
self.models[target] = model
|
self.models[target] = model
|
||||||
|
|
||||||
if not self.models:
|
|
||||||
print(f"No trained models found in {MODEL_DIR}. Run ml/train.py first.")
|
|
||||||
|
|
||||||
return self.models
|
return self.models
|
||||||
|
|
||||||
def load(self, target: str):
|
|
||||||
"""Load a specific model."""
|
|
||||||
model = WeatherModel(target)
|
|
||||||
model.load()
|
|
||||||
self.models[target] = model
|
|
||||||
return model
|
|
||||||
|
|
||||||
def predict_all(self, X: np.ndarray) -> Dict[str, float]:
|
def predict_all(self, X: np.ndarray) -> Dict[str, float]:
|
||||||
"""Predict all targets for a feature vector."""
|
|
||||||
if X.ndim == 1:
|
if X.ndim == 1:
|
||||||
X = X.reshape(1, -1)
|
X = X.reshape(1, -1)
|
||||||
return {name: float(model.predict_proba(X)[0]) for name, model in self.models.items()}
|
return {name: float(model.predict_proba(X)[0]) for name, model in self.models.items()}
|
||||||
|
|
||||||
def predict(self, target: str, X: np.ndarray) -> float:
|
def predict(self, target: str, X: np.ndarray) -> float:
|
||||||
"""Predict a single target."""
|
|
||||||
if target not in self.models:
|
if target not in self.models:
|
||||||
raise KeyError(f"Model '{target}' not loaded. Available: {list(self.models.keys())}")
|
raise KeyError(f"Model '{target}' not loaded.")
|
||||||
return float(self.models[target].predict_proba(X)[0])
|
return float(self.models[target].predict_proba(X)[0])
|
||||||
|
|
||||||
def has(self, target: str) -> bool:
|
def has(self, target: str) -> bool:
|
||||||
@@ -323,10 +440,3 @@ class ModelEnsemble:
|
|||||||
@property
|
@property
|
||||||
def available_targets(self) -> List[str]:
|
def available_targets(self) -> List[str]:
|
||||||
return list(self.models.keys())
|
return list(self.models.keys())
|
||||||
|
|
||||||
def print_feature_importance(self, top_n: int = 10):
|
|
||||||
"""Print top features for each model."""
|
|
||||||
for name, model in self.models.items():
|
|
||||||
print(f"\n--- {name} ({model.target_def['description']}) ---")
|
|
||||||
for feat, imp in list(model.top_features(top_n).items()):
|
|
||||||
print(f" {feat:30s} {imp:>10.1f}")
|
|
||||||
|
|||||||
+2
-8
@@ -1,13 +1,7 @@
|
|||||||
"""ML-powered signal generator for HK weather prediction markets.
|
"""ML-powered signal generator for HK weather prediction markets.
|
||||||
|
|
||||||
Replaces heuristic sigmoids with LightGBM probability models.
|
Uses three-layer calibrated LightGBM models (raw → Platt → isotonic)
|
||||||
Integrates probability calibration, ensemble disagreement, and
|
combined with spatial features, typhoon model, and portfolio Kelly.
|
||||||
feature engineering into a unified inference pipeline.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
predictor = MLPredictor()
|
|
||||||
probs = predictor.predict("tomorrow") # All targets for tomorrow
|
|
||||||
signal = predictor.generate_signal("temp_gt_30c_24h", market_price=0.45)
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
|
|||||||
+211
-203
@@ -1,19 +1,14 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Train LightGBM models for HK weather prediction targets.
|
"""
|
||||||
|
Train LightGBM models for HK weather prediction targets.
|
||||||
|
|
||||||
Uses ERA5 reanalysis data or Open-Meteo historical data to train
|
The training data simulates the relationship between NWP model forecasts
|
||||||
probability models for rain, temperature, and wind thresholds.
|
and actual observations. NWP models have systematic errors:
|
||||||
|
- Temperature: RMSE ~1.5°C at 24h lead
|
||||||
|
- Precipitation probability: poor calibration, often overconfident
|
||||||
|
- Wind: RMSE 3-5 km/h at 24h lead
|
||||||
|
|
||||||
Data preparation:
|
The model learns to MAP noisy forecast features → binary outcome truth.
|
||||||
Option 1 (ERA5): Requires CDS API setup. Downloads daily + hourly data.
|
|
||||||
Option 2 (Synthetic bootstrap): Generate plausible training data from
|
|
||||||
historical HK climate normals + Open-Meteo forecast structure.
|
|
||||||
Option 3 (Open-Meteo archive): Use Open-Meteo historical weather API.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
python ml/train.py # Train all models
|
|
||||||
python ml/train.py --target temp_gt_30c_24h # Single target
|
|
||||||
python ml/train.py --bootstrap # Bootstrap from climate normals
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
@@ -33,253 +28,275 @@ from ml.model import WeatherModel, TARGET_DEFINITIONS, MODEL_DIR
|
|||||||
from config import HK_COORDS
|
from config import HK_COORDS
|
||||||
|
|
||||||
|
|
||||||
def bootstrap_training_data(n_samples: int = 5000) -> Tuple[pd.DataFrame, pd.DataFrame]:
|
def _add_nwp_forecast_error(
|
||||||
|
daily_truth: pd.DataFrame,
|
||||||
|
hourly_truth: pd.DataFrame,
|
||||||
|
rng: np.random.RandomState,
|
||||||
|
) -> Tuple[pd.DataFrame, pd.DataFrame]:
|
||||||
"""
|
"""
|
||||||
Generate synthetic training data from HK climate normals + variability.
|
Add realistic NWP forecast errors to truth data.
|
||||||
|
|
||||||
This is a bootstrap approach when ERA5/Open-Meteo historical data isn't
|
Returns (daily_forecast, hourly_forecast) simulating:
|
||||||
available. It samples from known HK climate distributions with realistic
|
- Temperature: RMSE 1.5-2.5°C, warm bias in summer anticyclones
|
||||||
seasonal cycles, correlations, and day-to-day persistence.
|
- Precipitation: continuous calibrated probabilities (not just 0/100),
|
||||||
|
systematic overforecasting of light rain, underforecasting of heavy
|
||||||
|
- Wind: multiplicative errors 0.7-1.5x
|
||||||
|
- Cloud cover: RMSE 15-20%
|
||||||
|
"""
|
||||||
|
n_days = len(daily_truth)
|
||||||
|
|
||||||
While not as good as real reanalysis data, it:
|
# === TEMPERATURE: larger noise for wider training distribution ===
|
||||||
- Captures correct seasonal patterns (hot+wet summer, cool+dry winter)
|
temp_error_max = rng.normal(0.3, 2.0, n_days) # μ=0.3 bias, σ=2.0 RMSE
|
||||||
- Maintains realistic correlations (rain↔cloud↔temperature)
|
temp_error_min = rng.normal(0.2, 1.8, n_days)
|
||||||
- Includes meaningful day-to-day autocorrelation
|
# Random injection of larger errors (10% of days have outlier errors)
|
||||||
- Trains a model that can be replaced with real data later
|
outlier_mask = rng.random(n_days) < 0.10
|
||||||
|
temp_error_max[outlier_mask] += rng.normal(0, 3.0, outlier_mask.sum())
|
||||||
|
temp_error_min[outlier_mask] += rng.normal(0, 2.5, outlier_mask.sum())
|
||||||
|
|
||||||
|
daily_fc = daily_truth.copy()
|
||||||
|
if "temperature_2m_max" in daily_fc.columns:
|
||||||
|
daily_fc["temperature_2m_max"] = np.clip(daily_truth["temperature_2m_max"] + temp_error_max, 5, 42)
|
||||||
|
if "temperature_2m_min" in daily_fc.columns:
|
||||||
|
daily_fc["temperature_2m_min"] = np.clip(daily_truth["temperature_2m_min"] + temp_error_min, 0, 33)
|
||||||
|
|
||||||
|
# === PRECIPITATION: continuous calibrated probabilities + multiplicative rain error ===
|
||||||
|
# NWP models output continuous probabilities, not 0/100
|
||||||
|
# Base prob from truth, then add calibration noise
|
||||||
|
true_prob = daily_truth["precipitation_probability_max"].values / 100.0
|
||||||
|
|
||||||
|
# Systematic miscalibration: NWP overestimates low prob, underestimates high prob
|
||||||
|
calibration_bias = 0.15 * (0.5 - true_prob) # +7.5% at prob=0, -7.5% at prob=1
|
||||||
|
calibration_noise = rng.normal(0, 0.15, n_days)
|
||||||
|
fc_prob = np.clip(true_prob + calibration_bias + calibration_noise, 0.01, 0.99)
|
||||||
|
|
||||||
|
if "precipitation_probability_max" in daily_fc.columns:
|
||||||
|
daily_fc["precipitation_probability_max"] = fc_prob * 100.0
|
||||||
|
|
||||||
|
# Rain amount: multiplicative error, more noise on heavy rain
|
||||||
|
rain_mult_error = np.where(
|
||||||
|
daily_truth["precipitation_sum"] > 5,
|
||||||
|
rng.lognormal(0, 0.4, n_days), # High variance for heavy rain
|
||||||
|
rng.lognormal(0, 0.25, n_days), # Lower variance for light rain
|
||||||
|
)
|
||||||
|
if "precipitation_sum" in daily_fc.columns:
|
||||||
|
daily_fc["precipitation_sum"] = daily_truth["precipitation_sum"] * rain_mult_error
|
||||||
|
|
||||||
|
# === WIND: multiplicative with 10% outlier days ===
|
||||||
|
wind_mult = rng.lognormal(0, 0.20, n_days)
|
||||||
|
gust_mult = rng.lognormal(0, 0.30, n_days)
|
||||||
|
outlier_wind = rng.random(n_days) < 0.10
|
||||||
|
wind_mult[outlier_wind] *= rng.uniform(1.3, 2.0, outlier_wind.sum())
|
||||||
|
gust_mult[outlier_wind] *= rng.uniform(1.3, 2.5, outlier_wind.sum())
|
||||||
|
|
||||||
|
for col, mult in [("wind_speed_10m_max", wind_mult), ("wind_gusts_10m_max", gust_mult)]:
|
||||||
|
if col in daily_fc.columns:
|
||||||
|
daily_fc[col] = np.clip(daily_truth[col] * mult, 0, 200)
|
||||||
|
|
||||||
|
# === CLOUD COVER: systematic bias (underestimate in convective conditions) ===
|
||||||
|
if "weather_code" in daily_fc.columns:
|
||||||
|
daily_fc["weather_code"] = daily_truth["weather_code"]
|
||||||
|
|
||||||
|
# === HOURLY: larger noise ranges ===
|
||||||
|
hourly_fc = hourly_truth.copy()
|
||||||
|
n_hours = len(hourly_fc)
|
||||||
|
|
||||||
|
# Temperature: diurnal-cycle-aware errors (larger at night)
|
||||||
|
hour_of_day = np.array([i % 24 for i in range(n_hours)])
|
||||||
|
t_noise_scale = 1.2 + 0.8 * np.sin(2 * np.pi * (hour_of_day - 14) / 24) # Peak error at night
|
||||||
|
if "temperature_2m" in hourly_fc.columns:
|
||||||
|
hourly_fc["temperature_2m"] = np.clip(
|
||||||
|
hourly_truth["temperature_2m"] + rng.normal(0, 2.0, n_hours) * t_noise_scale,
|
||||||
|
-5, 45,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Humidity: large errors (NWP struggles with boundary layer moisture)
|
||||||
|
if "relative_humidity_2m" in hourly_fc.columns:
|
||||||
|
rh_err = rng.normal(-3, 12, n_hours)
|
||||||
|
# More error during convective hours
|
||||||
|
convective_mask = (hour_of_day > 11) & (hour_of_day < 19)
|
||||||
|
rh_err[convective_mask] *= 1.5
|
||||||
|
hourly_fc["relative_humidity_2m"] = np.clip(hourly_truth["relative_humidity_2m"] + rh_err, 15, 100)
|
||||||
|
|
||||||
|
# Cloud cover: large RMSE
|
||||||
|
if "cloud_cover" in hourly_fc.columns:
|
||||||
|
hourly_fc["cloud_cover"] = np.clip(hourly_truth["cloud_cover"] + rng.normal(0, 20, n_hours), 0, 100)
|
||||||
|
for level in ["cloud_cover_low", "cloud_cover_mid", "cloud_cover_high"]:
|
||||||
|
if level in hourly_fc.columns:
|
||||||
|
hourly_fc[level] = np.clip(hourly_truth[level] + rng.normal(0, 15, n_hours), 0, 100)
|
||||||
|
|
||||||
|
# Pressure: typical errors
|
||||||
|
if "surface_pressure" in hourly_fc.columns:
|
||||||
|
hourly_fc["surface_pressure"] = hourly_truth["surface_pressure"] + rng.normal(0, 3.0, n_hours)
|
||||||
|
|
||||||
|
# Wind: multiplicative
|
||||||
|
for col in ["wind_speed_10m", "wind_speed_100m", "wind_gusts_10m"]:
|
||||||
|
if col in hourly_fc.columns:
|
||||||
|
mult = rng.lognormal(0, 0.25, n_hours)
|
||||||
|
hourly_fc[col] = hourly_truth[col] * mult
|
||||||
|
|
||||||
|
return daily_fc, hourly_fc
|
||||||
|
|
||||||
|
|
||||||
|
def bootstrap_training_data(n_samples: int = 8000) -> Tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame, pd.DataFrame]:
|
||||||
|
"""
|
||||||
|
Generate NWP forecast + observation training pairs.
|
||||||
|
|
||||||
|
Returns (daily_forecast, hourly_forecast, daily_truth, hourly_truth)
|
||||||
|
where forecast has realistic NWP errors and truth is the actual observation.
|
||||||
"""
|
"""
|
||||||
rng = np.random.RandomState(42)
|
rng = np.random.RandomState(42)
|
||||||
|
rng_noise = np.random.RandomState(99)
|
||||||
|
|
||||||
# Generate dates covering 10 years
|
|
||||||
start_date = datetime(2015, 1, 1)
|
start_date = datetime(2015, 1, 1)
|
||||||
dates = [start_date + timedelta(days=i) for i in range(n_samples)]
|
dates = [start_date + timedelta(days=i) for i in range(n_samples)]
|
||||||
|
|
||||||
# HK seasonal cycles (sinusoidal with harmonics)
|
|
||||||
doy = np.array([d.timetuple().tm_yday for d in dates])
|
doy = np.array([d.timetuple().tm_yday for d in dates])
|
||||||
doy_sin = np.sin(2 * np.pi * doy / 365.25)
|
|
||||||
doy_cos = np.cos(2 * np.pi * doy / 365.25)
|
|
||||||
|
|
||||||
# === TEMPERATURE ===
|
|
||||||
# HK: mean Tmax 26°C, range 18-35°C, seasonal amplitude ~7°C
|
|
||||||
tmax_base = 26.0 + 7.0 * np.sin(2 * np.pi * (doy - 200) / 365.25) # Peak Aug
|
|
||||||
tmax = tmax_base + rng.normal(0, 2.0, n_samples)
|
|
||||||
tmax = np.clip(tmax, 8, 38)
|
|
||||||
|
|
||||||
tmin = tmax - (7.0 + rng.exponential(2.0, n_samples)) # Diurnal range
|
|
||||||
tmin = np.clip(tmin, 4, 30)
|
|
||||||
|
|
||||||
|
# === Generate OBSERVATION TRUTH (clean, no NWP error) ===
|
||||||
|
tmax_base = 26.0 + 7.0 * np.sin(2 * np.pi * (doy - 200) / 365.25)
|
||||||
|
tmax = np.clip(tmax_base + rng_noise.normal(0, 2.0, n_samples), 8, 38)
|
||||||
|
tmin = np.clip(tmax - (7.0 + rng_noise.exponential(2.0, n_samples)), 4, 30)
|
||||||
tmean = (tmax + tmin) / 2
|
tmean = (tmax + tmin) / 2
|
||||||
|
|
||||||
# Apparent temperature (feels-like, always >= temp in HK humidity)
|
rain_seasonal = 4.0 + 10.0 * np.maximum(0, np.sin(2 * np.pi * (doy - 172) / 365.25))
|
||||||
apparent_t_max = tmax + rng.exponential(2.0, n_samples)
|
rain_day_mask_prob = 0.3 + 0.4 * np.maximum(0, np.sin(2 * np.pi * (doy - 172) / 365.25))
|
||||||
apparent_t_max = np.clip(apparent_t_max, tmax, tmax + 12)
|
rain_day = rng_noise.random(n_samples) < rain_day_mask_prob
|
||||||
|
rain_sum = np.where(rain_day, rng_noise.exponential(rain_seasonal, n_samples), 0)
|
||||||
|
rain_sum = np.where(rain_sum < 0.1, 0, rain_sum)
|
||||||
|
|
||||||
# === HUMIDITY ===
|
# Continuous precipitation probability: beta distribution centered on actual prob
|
||||||
# HK: mean RH 78%, range 55-98%, lower in winter, higher in summer
|
from scipy.stats import beta as beta_dist
|
||||||
rh_base = 78 + 12 * doy_sin # Higher in summer
|
precip_prob = np.zeros(n_samples)
|
||||||
rh_mean = rh_base + rng.normal(0, 6, n_samples)
|
for i in range(n_samples):
|
||||||
rh_mean = np.clip(rh_mean, 45, 98)
|
# Center the beta around the climatological rain prob
|
||||||
|
p = rain_day_mask_prob[i]
|
||||||
|
a = max(0.5, p * 8)
|
||||||
|
b = max(0.5, (1 - p) * 8)
|
||||||
|
precip_prob[i] = rng_noise.beta(a, b) * 100.0
|
||||||
|
precip_prob = np.clip(precip_prob, 0.5, 99.5)
|
||||||
|
|
||||||
rh_min = rh_mean - rng.exponential(5, n_samples)
|
|
||||||
rh_min = np.clip(rh_min, rh_mean - 30, rh_mean)
|
|
||||||
|
|
||||||
# Dewpoint (from temp and RH)
|
|
||||||
dewpoint = tmean - ((100 - rh_mean) / 5.0) + rng.normal(0, 0.5, n_samples)
|
|
||||||
dewpoint = np.clip(dewpoint, -5, 28)
|
|
||||||
|
|
||||||
# === PRECIPITATION ===
|
|
||||||
# Rain: Poisson-like, strongly seasonal, zero-inflated
|
|
||||||
rain_seasonal = 4.0 + 10.0 * np.maximum(0, doy_sin) # Peak summer
|
|
||||||
rain_day_mask = rng.random(n_samples) < (0.3 + 0.4 * np.maximum(0, doy_sin))
|
|
||||||
rain_sum = np.where(rain_day_mask, rng.exponential(rain_seasonal, n_samples), 0)
|
|
||||||
rain_sum[rain_sum < 0.1] = 0 # Trace → 0
|
|
||||||
|
|
||||||
precip_prob = 100.0 * rain_day_mask + rng.normal(0, 5, n_samples)
|
|
||||||
precip_prob = np.clip(precip_prob, 0, 100)
|
|
||||||
|
|
||||||
rain_minor_threshold = np.where(rain_sum > 1.0, rng.binomial(1, 0.6, n_samples), 0) # Heavy vs light
|
|
||||||
|
|
||||||
# === WIND ===
|
|
||||||
# Wind: seasonal, typhoon-season peaks
|
|
||||||
wind_base = 15 + 8 * np.maximum(0, np.sin(2 * np.pi * (doy - 180) / 365.25))
|
wind_base = 15 + 8 * np.maximum(0, np.sin(2 * np.pi * (doy - 180) / 365.25))
|
||||||
wind_speed_max = wind_base + rng.exponential(5, n_samples)
|
wind_max = np.clip(wind_base + rng_noise.exponential(5, n_samples), 3, 120)
|
||||||
wind_speed_max = np.clip(wind_speed_max, 3, 120)
|
gusts_max = np.clip(wind_max * (1.0 + rng_noise.exponential(0.5, n_samples)), wind_max, 200)
|
||||||
|
|
||||||
wind_gusts_max = wind_speed_max * (1.0 + rng.exponential(0.5, n_samples))
|
wind_dir = rng_noise.uniform(0, 360, n_samples)
|
||||||
wind_gusts_max = np.clip(wind_gusts_max, wind_speed_max, 200)
|
cloud = np.clip(30 + rng_noise.beta(2, 3, n_samples) * 70 * (0.5 + 0.5 * (rain_sum > 0)), 0, 100)
|
||||||
|
pressure = np.clip(1013 - 5 * np.sin(2 * np.pi * (doy - 172) / 365.25) + rng_noise.normal(0, 3, n_samples), 980, 1035)
|
||||||
|
sw_rad = np.clip(5.0 + 10.0 * np.sin(2 * np.pi * (doy - 172) / 365.25) * (1 - cloud / 100) + rng_noise.normal(0, 2, n_samples), 0, 30)
|
||||||
|
|
||||||
wind_speed_100m_max = wind_speed_max * 1.3 + rng.normal(0, 2, n_samples)
|
daily_truth = pd.DataFrame({
|
||||||
wind_speed_100m_max = np.clip(wind_speed_100m_max, wind_speed_max, wind_speed_max * 2.5)
|
|
||||||
|
|
||||||
wind_dir = rng.uniform(0, 360, n_samples)
|
|
||||||
|
|
||||||
# === CLOUD COVER ===
|
|
||||||
cloud_cover = 30 + rng.beta(2, 3, n_samples) * 70
|
|
||||||
cloud_cover = np.clip(cloud_cover, 0, 100)
|
|
||||||
cloud_cover *= (0.5 + 0.5 * (rain_sum > 0)) # More clouds when raining
|
|
||||||
|
|
||||||
cloud_low = cloud_cover * rng.beta(2, 5, n_samples)
|
|
||||||
cloud_mid = cloud_cover * rng.beta(2, 5, n_samples) * 0.5
|
|
||||||
cloud_high = cloud_cover * rng.beta(2, 5, n_samples) * 0.3
|
|
||||||
|
|
||||||
# === PRESSURE ===
|
|
||||||
# Mean sea level pressure: 1013 hPa ± seasonal
|
|
||||||
pressure = 1013 - 5 * doy_sin + rng.normal(0, 3, n_samples)
|
|
||||||
pressure = np.clip(pressure, 980, 1035)
|
|
||||||
|
|
||||||
# === VISIBILITY ===
|
|
||||||
visibility = 15000 - rain_sum * 500 + rng.normal(0, 2000, n_samples)
|
|
||||||
visibility = np.clip(visibility, 500, 25000)
|
|
||||||
|
|
||||||
# === SW RADIATION ===
|
|
||||||
sw_rad = 5.0 + 10.0 * doy_sin * (1 - cloud_cover / 100) + rng.normal(0, 2, n_samples)
|
|
||||||
sw_rad = np.clip(sw_rad, 0, 30)
|
|
||||||
|
|
||||||
# Build daily DataFrame
|
|
||||||
daily_data = {
|
|
||||||
"date": dates,
|
|
||||||
"temperature_2m_max": tmax,
|
"temperature_2m_max": tmax,
|
||||||
"temperature_2m_min": tmin,
|
"temperature_2m_min": tmin,
|
||||||
"temperature_2m_mean": tmean,
|
"temperature_2m_mean": tmean,
|
||||||
"precipitation_sum": rain_sum,
|
"precipitation_sum": rain_sum,
|
||||||
"precipitation_probability_max": precip_prob,
|
"precipitation_probability_max": precip_prob,
|
||||||
"rain_sum": rain_sum,
|
"rain_sum": rain_sum,
|
||||||
"wind_speed_10m_max": wind_speed_max,
|
"wind_speed_10m_max": wind_max,
|
||||||
"wind_gusts_10m_max": wind_gusts_max,
|
"wind_gusts_10m_max": gusts_max,
|
||||||
"wind_direction_10m_dominant": wind_dir,
|
"wind_direction_10m_dominant": wind_dir,
|
||||||
"shortwave_radiation_sum": sw_rad,
|
"shortwave_radiation_sum": sw_rad,
|
||||||
"et0_fao_evapotranspiration": sw_rad * 0.4,
|
"et0_fao_evapotranspiration": sw_rad * 0.4,
|
||||||
"weather_code": np.where(rain_sum > 0, np.where(rain_sum > 10, 63, 61), 0),
|
"weather_code": np.where(rain_sum > 0, np.where(rain_sum > 10, 63, 61), 0),
|
||||||
}
|
}, index=pd.to_datetime(dates))
|
||||||
daily = pd.DataFrame(daily_data).set_index("date")
|
|
||||||
daily.index = pd.to_datetime(daily.index)
|
|
||||||
|
|
||||||
# Generate hourly data with diurnal cycles
|
# Hourly truth
|
||||||
hours_per_day = 24
|
hours_per_day = 24
|
||||||
total_hours = n_samples * hours_per_day
|
total_hours = n_samples * hours_per_day
|
||||||
hour_timestamps = [start_date + timedelta(hours=i) for i in range(total_hours)]
|
|
||||||
hour_of_day = np.tile(np.arange(24), n_samples)
|
hour_of_day = np.tile(np.arange(24), n_samples)
|
||||||
|
|
||||||
# Diurnal temperature: sinusoid between tmin and tmax, peaking at 14:00
|
|
||||||
day_indices = np.repeat(np.arange(n_samples), hours_per_day)
|
|
||||||
t_range = np.repeat(tmax - tmin, hours_per_day)
|
t_range = np.repeat(tmax - tmin, hours_per_day)
|
||||||
t_phase = 2 * np.pi * (hour_of_day - 14) / 24
|
t_phase = 2 * np.pi * (hour_of_day - 14) / 24
|
||||||
t_hourly = np.repeat(tmin, hours_per_day) + t_range * (0.5 + 0.5 * np.cos(t_phase)) + rng.normal(0, 0.5, total_hours)
|
|
||||||
|
|
||||||
# RH: inverse of temperature cycle
|
daily_rh = np.clip(78 + 12 * np.sin(2 * np.pi * (doy - 172) / 365.25) + rng_noise.normal(0, 6, n_samples), 45, 98)
|
||||||
rh_hourly = np.repeat(rh_mean, hours_per_day) - 5 * np.cos(t_phase) + rng.normal(0, 3, total_hours)
|
dewpoint = tmean - ((100 - daily_rh) / 5.0) + rng_noise.normal(0, 0.5, n_samples)
|
||||||
rh_hourly = np.clip(rh_hourly, 20, 100)
|
|
||||||
|
|
||||||
hourly_data = {
|
t_hourly = np.repeat(tmin, hours_per_day) + t_range * (0.5 + 0.5 * np.cos(t_phase)) + rng_noise.normal(0, 0.5, total_hours)
|
||||||
"date": hour_timestamps,
|
rh_hourly = np.clip(np.repeat(daily_rh, hours_per_day) - 5 * np.cos(t_phase) + rng_noise.normal(0, 3, total_hours), 20, 100)
|
||||||
|
|
||||||
|
hour_timestamps = [start_date + timedelta(hours=i) for i in range(total_hours)]
|
||||||
|
hourly_truth = pd.DataFrame({
|
||||||
"temperature_2m": t_hourly,
|
"temperature_2m": t_hourly,
|
||||||
"relative_humidity_2m": rh_hourly,
|
"relative_humidity_2m": rh_hourly,
|
||||||
"dew_point_2m": np.repeat(dewpoint, hours_per_day) + rng.normal(0, 0.5, total_hours),
|
"dew_point_2m": np.repeat(dewpoint, hours_per_day) + rng_noise.normal(0, 0.5, total_hours),
|
||||||
"apparent_temperature": t_hourly + rng.exponential(2.0, total_hours),
|
"apparent_temperature": t_hourly + rng_noise.exponential(2.0, total_hours),
|
||||||
"precipitation_probability": np.repeat(precip_prob, hours_per_day) / 24 + rng.normal(0, 1, total_hours),
|
"precipitation_probability": np.clip(np.repeat(precip_prob, hours_per_day) / 24 + rng_noise.normal(0, 1, total_hours), 0, 100),
|
||||||
"precipitation": np.repeat(rain_sum, hours_per_day) / 24 * rng.uniform(0.5, 1.5, total_hours),
|
"precipitation": np.repeat(rain_sum, hours_per_day) / 24 * rng_noise.uniform(0.5, 1.5, total_hours),
|
||||||
"rain": np.repeat(rain_sum, hours_per_day) / 24,
|
"rain": np.repeat(rain_sum, hours_per_day) / 24,
|
||||||
"cloud_cover": np.repeat(cloud_cover, hours_per_day) + rng.normal(0, 5, total_hours),
|
"cloud_cover": np.clip(np.repeat(cloud, hours_per_day) + rng_noise.normal(0, 5, total_hours), 0, 100),
|
||||||
"cloud_cover_low": np.repeat(cloud_low, hours_per_day),
|
"cloud_cover_low": np.clip(np.repeat(cloud * 0.6, hours_per_day), 0, 100),
|
||||||
"cloud_cover_mid": np.repeat(cloud_mid, hours_per_day),
|
"cloud_cover_mid": np.clip(np.repeat(cloud * 0.3, hours_per_day), 0, 100),
|
||||||
"cloud_cover_high": np.repeat(cloud_high, hours_per_day),
|
"cloud_cover_high": np.clip(np.repeat(cloud * 0.2, hours_per_day), 0, 100),
|
||||||
"wind_speed_10m": np.repeat(wind_speed_max, hours_per_day) * 0.5 * (0.5 + 0.5 * np.cos(t_phase)),
|
"wind_speed_10m": np.repeat(wind_max, hours_per_day) * 0.5 * (0.5 + 0.5 * np.cos(t_phase)),
|
||||||
"wind_speed_100m": np.repeat(wind_speed_100m_max, hours_per_day) * 0.6,
|
"wind_speed_100m": np.repeat(wind_max, hours_per_day) * 1.3 * 0.6,
|
||||||
"wind_gusts_10m": np.repeat(wind_gusts_max, hours_per_day) * (0.3 + 0.7 * rng.beta(2, 5, total_hours)),
|
"wind_gusts_10m": np.repeat(gusts_max, hours_per_day) * (0.3 + 0.7 * rng_noise.beta(2, 5, total_hours)),
|
||||||
"wind_direction_10m": np.repeat(wind_dir, hours_per_day) + rng.normal(0, 10, total_hours),
|
"wind_direction_10m": np.repeat(wind_dir, hours_per_day) + rng_noise.normal(0, 10, total_hours),
|
||||||
"surface_pressure": np.repeat(pressure, hours_per_day) + rng.normal(0, 0.5, total_hours),
|
"surface_pressure": np.repeat(pressure, hours_per_day) + rng_noise.normal(0, 0.5, total_hours),
|
||||||
"visibility": np.repeat(visibility, hours_per_day) + rng.normal(0, 500, total_hours),
|
"visibility": np.clip(15000 - np.repeat(rain_sum, hours_per_day) * 500 + rng_noise.normal(0, 2000, total_hours), 500, 25000),
|
||||||
}
|
}, index=pd.to_datetime(hour_timestamps))
|
||||||
hourly = pd.DataFrame(hourly_data).set_index("date")
|
|
||||||
hourly.index = pd.to_datetime(hourly.index)
|
|
||||||
|
|
||||||
# Clip all values to realistic ranges
|
# === Add NWP forecast errors ===
|
||||||
hourly["cloud_cover"] = np.clip(hourly["cloud_cover"], 0, 100)
|
daily_fc, hourly_fc = _add_nwp_forecast_error(daily_truth.copy(), hourly_truth.copy(), rng)
|
||||||
hourly["cloud_cover_low"] = np.clip(hourly["cloud_cover_low"], 0, 100)
|
|
||||||
hourly["cloud_cover_mid"] = np.clip(hourly["cloud_cover_mid"], 0, 100)
|
|
||||||
hourly["cloud_cover_high"] = np.clip(hourly["cloud_cover_high"], 0, 100)
|
|
||||||
hourly["visibility"] = np.clip(hourly["visibility"], 100, 30000)
|
|
||||||
hourly["precipitation_probability"] = np.clip(hourly["precipitation_probability"], 0, 100)
|
|
||||||
|
|
||||||
return daily, hourly
|
return daily_fc, hourly_fc, daily_truth, hourly_truth
|
||||||
|
|
||||||
|
|
||||||
def train_targets(
|
def train_targets(target_names=None, n_bootstrap=8000, test_split=0.2):
|
||||||
target_names: Optional[list] = None,
|
"""Train models with proper NWP forecast → observation mapping."""
|
||||||
n_bootstrap: int = 5000,
|
|
||||||
test_split: float = 0.2,
|
|
||||||
):
|
|
||||||
"""Train all or selected target models."""
|
|
||||||
if target_names is None:
|
if target_names is None:
|
||||||
target_names = list(TARGET_DEFINITIONS.keys())
|
target_names = list(TARGET_DEFINITIONS.keys())
|
||||||
|
|
||||||
print(f"Training {len(target_names)} models...")
|
print(f"Training {len(target_names)} models with realistic NWP errors")
|
||||||
print(f"Bootstrap samples: {n_bootstrap} (test split: {test_split:.0%})")
|
print(f" Samples: {n_bootstrap} (test: {test_split:.0%})")
|
||||||
print()
|
print()
|
||||||
|
|
||||||
daily, hourly = bootstrap_training_data(n_bootstrap)
|
daily_fc, hourly_fc, daily_truth, hourly_truth = bootstrap_training_data(n_bootstrap)
|
||||||
|
|
||||||
engine = FeatureEngine()
|
engine = FeatureEngine()
|
||||||
X = engine.transform(daily, hourly)
|
# Features from FORECAST (noisy NWP output)
|
||||||
print(f"Features: {X.shape[1]} from {len(engine.FEATURE_GROUPS)} groups")
|
X = engine.transform(daily_fc, hourly_fc)
|
||||||
print(f" Thermal: {len(engine.FEATURE_GROUPS['thermal'])}")
|
print(f"Features: {X.shape[1]} from NWP forecast output")
|
||||||
print(f" Dynamic: {len(engine.FEATURE_GROUPS['dynamic'])}")
|
|
||||||
print(f" Moisture: {len(engine.FEATURE_GROUPS['moisture'])}")
|
|
||||||
print(f" Temporal: {len(engine.FEATURE_GROUPS['temporal'])}")
|
|
||||||
print(f" Interaction: {len(engine.FEATURE_GROUPS['interaction'])}")
|
|
||||||
print()
|
|
||||||
|
|
||||||
# Train/test split (temporal order, no shuffle)
|
split_idx = int(len(daily_fc) * (1 - test_split))
|
||||||
split_idx = int(len(daily) * (1 - test_split))
|
|
||||||
X_train, X_test = X[:split_idx], X[split_idx:]
|
X_train, X_test = X[:split_idx], X[split_idx:]
|
||||||
daily_train, daily_test = daily.iloc[:split_idx], daily.iloc[split_idx:]
|
daily_truth_train, daily_truth_test = daily_truth.iloc[:split_idx], daily_truth.iloc[split_idx:]
|
||||||
|
|
||||||
results = {}
|
results = {}
|
||||||
|
|
||||||
# Feature augmentation: add Gaussian noise to prevent overfitting on synthetic data
|
|
||||||
X_train_noisy = X_train + np.random.RandomState(42).normal(0, 0.1, X_train.shape).astype(np.float32)
|
|
||||||
X_test_noisy = X_test + np.random.RandomState(43).normal(0, 0.05, X_test.shape).astype(np.float32)
|
|
||||||
|
|
||||||
for target_name in target_names:
|
for target_name in target_names:
|
||||||
print(f"{'='*60}")
|
tdef = TARGET_DEFINITIONS[target_name]
|
||||||
print(f"Training: {target_name}")
|
print(f"\n{'='*60}")
|
||||||
print(f" {TARGET_DEFINITIONS[target_name]['description']}")
|
print(f" {target_name} — {tdef['description']}")
|
||||||
print(f"{'='*60}")
|
print(f"{'='*60}")
|
||||||
|
|
||||||
tdef = TARGET_DEFINITIONS[target_name]
|
# Targets from TRUTH (actual observation)
|
||||||
y_train = WeatherModel.build_target(
|
y_train = WeatherModel.build_target(daily_truth_train, tdef["variable"], tdef["threshold"], tdef["op"])
|
||||||
daily_train, tdef["variable"], tdef["threshold"], tdef["op"]
|
y_test = WeatherModel.build_target(daily_truth_test, tdef["variable"], tdef["threshold"], tdef["op"])
|
||||||
)
|
|
||||||
y_test = WeatherModel.build_target(
|
|
||||||
daily_test, tdef["variable"], tdef["threshold"], tdef["op"]
|
|
||||||
)
|
|
||||||
|
|
||||||
p_yes = y_train.mean() * 100
|
p_yes = y_train.mean() * 100
|
||||||
print(f" Class balance: {p_yes:.1f}% YES / {100-p_yes:.1f}% NO")
|
print(f" Class balance: {p_yes:.1f}% YES / {100-p_yes:.1f}% NO")
|
||||||
|
|
||||||
model = WeatherModel(target_name)
|
model = WeatherModel(target_name, mode="lr")
|
||||||
model.train(X_train_noisy, y_train, X_test_noisy, y_test)
|
model.train(X_train, y_train, X_test, y_test)
|
||||||
metrics = model.evaluate(X_test, y_test)
|
metrics = model.evaluate(X_test, y_test)
|
||||||
model.save()
|
model.save()
|
||||||
|
|
||||||
results[target_name] = metrics
|
results[target_name] = metrics
|
||||||
|
|
||||||
print(f" Brier score: {metrics['brier_score']:.4f}")
|
print(f" Pre-calibration Brier: — ")
|
||||||
print(f" ROC AUC: {metrics['roc_auc']:.3f}")
|
print(f" Post-calibration:")
|
||||||
print(f" Predicted mean: {metrics['p_yes_predicted']:.1f}% (actual: {metrics['p_yes_actual']:.1f}%)")
|
print(f" Brier: {metrics['brier_score']:.4f} AUC: {metrics['roc_auc']:.3f}")
|
||||||
print(f" Top 10 features:")
|
print(f" Predicted mean: {metrics['p_yes_predicted']:.1f}% Actual: {metrics['p_yes_actual']:.1f}%")
|
||||||
for feat, imp in list(model.top_features(10).items()):
|
print(f" Calibration: {metrics['calibration_method']}")
|
||||||
|
print(f" Top 8 features:")
|
||||||
|
for feat, imp in list(model.top_features(8).items()):
|
||||||
print(f" {feat:30s} {imp:>10.1f}")
|
print(f" {feat:30s} {imp:>10.1f}")
|
||||||
print()
|
|
||||||
|
|
||||||
# Summary
|
|
||||||
print(f"\n{'='*60}")
|
print(f"\n{'='*60}")
|
||||||
print("TRAINING SUMMARY")
|
print("TRAINING SUMMARY")
|
||||||
print(f"{'='*60}")
|
print(f"{'='*60}")
|
||||||
print(f"{'Target':<25s} {'Brier':>8s} {'ROC AUC':>8s} {'Cal Err %':>10s} {'Samples':>8s}")
|
print(f"{'Target':<25s} {'Brier':>8s} {'AUC':>8s} {'Cal Err%':>9s} {'Cal':>10s}")
|
||||||
print("-" * 62)
|
print("-" * 65)
|
||||||
for name, m in results.items():
|
for name, m in results.items():
|
||||||
cal_err = abs(m["p_yes_predicted"] - m["p_yes_actual"])
|
cal_err = abs(m["p_yes_predicted"] - m["p_yes_actual"])
|
||||||
print(f"{name:<25s} {m['brier_score']:>8.4f} {m['roc_auc']:>8.3f} {cal_err:>10.1f} {m['n_samples']:>8d}")
|
print(f"{name:<25s} {m['brier_score']:>8.4f} {m['roc_auc']:>8.3f} {cal_err:>9.1f} {m['calibration_method']:>10s}")
|
||||||
|
|
||||||
print(f"\nModels saved to: {MODEL_DIR}")
|
print(f"\nModels saved to: {MODEL_DIR}")
|
||||||
return results
|
return results
|
||||||
@@ -287,30 +304,21 @@ def train_targets(
|
|||||||
|
|
||||||
def main():
|
def main():
|
||||||
parser = argparse.ArgumentParser(description="Train HK weather prediction models")
|
parser = argparse.ArgumentParser(description="Train HK weather prediction models")
|
||||||
parser.add_argument("--target", type=str, default=None, help="Train single target (e.g., temp_gt_30c_24h)")
|
parser.add_argument("--target", type=str, default=None)
|
||||||
parser.add_argument("--bootstrap", action="store_true", default=True, help="Use bootstrap training data")
|
parser.add_argument("--samples", type=int, default=8000)
|
||||||
parser.add_argument("--samples", type=int, default=5000, help="Bootstrap sample count")
|
parser.add_argument("--all", action="store_true", default=False)
|
||||||
parser.add_argument("--all", action="store_true", default=False, help="Train all targets")
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
if args.target:
|
targets = [args.target] if args.target else list(TARGET_DEFINITIONS.keys())
|
||||||
targets = [args.target]
|
|
||||||
elif args.all:
|
|
||||||
targets = list(TARGET_DEFINITIONS.keys())
|
|
||||||
else:
|
|
||||||
targets = list(TARGET_DEFINITIONS.keys())
|
|
||||||
|
|
||||||
results = train_targets(targets, n_bootstrap=args.samples)
|
results = train_targets(targets, n_bootstrap=args.samples)
|
||||||
|
|
||||||
# Save summary
|
|
||||||
MODEL_DIR.mkdir(parents=True, exist_ok=True)
|
MODEL_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
summary_path = MODEL_DIR / "training_summary.json"
|
with open(MODEL_DIR / "training_summary.json", "w") as f:
|
||||||
with open(summary_path, "w") as f:
|
|
||||||
json.dump({
|
json.dump({
|
||||||
"training_date": datetime.now().isoformat(),
|
"training_date": datetime.now().isoformat(),
|
||||||
"n_bootstrap_samples": args.samples,
|
"n_samples": args.samples,
|
||||||
"results": results,
|
"results": results,
|
||||||
}, f, indent=2)
|
}, f, indent=2, default=str)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
Reference in New Issue
Block a user