Files
hk-weather-mkt/ml/features.py
T
ramseshk 7d7a67bd20 Add ML prediction pipeline — LightGBM, calibration fix, ensemble disagreement
Tier 1 ML enhancements:
- Feature engineering (37 features across 5 groups: thermal, dynamic,
  moisture, temporal, interaction) from NWP model output
- 7 LightGBM probability models for rain/temp/wind thresholds
- Temperature-scaled probabilities to prevent overconfidence on bootstrap data
- MLPredictor: unified inference pipeline replacing heuristic sigmoids
- Ensemble disagreement signals (composite spread → edge amplification)
- Fixed calibration loop: update_calibration() now functional (EMA of errors)
- record_outcome() wired for post-resolution feedback
- Nautilus strategy updated: ML predictions take priority, heuristics as fallback
- Historical backtest engine with Sharpe/ROI/max-DD simulation
- Bootstrap training data generator from HK climate normals

Run: python ml/train.py && python ml/backtest.py --edge 50
2026-08-10 17:50:07 +08:00

323 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Feature engineering for HK weather prediction from NWP model output.
Transforms raw Open-Meteo daily/hourly forecast data into ML features.
Features are designed to capture the physical processes driving HK weather:
- Thermal: temperature, humidity, heat index
- Dynamic: wind patterns, pressure gradients, shear
- Moisture: precipitation, cloud cover, convergence
- Temporal: day-over-day changes, seasonal cycles
- Ensemble: multi-model disagreement
Input: Open-Meteo daily + hourly DataFrames for HK region
Output: numpy feature matrix with named columns
"""
import numpy as np
import pandas as pd
from datetime import datetime
from typing import Dict, List, Optional, Tuple
class FeatureEngine:
"""
Engineer features from NWP model output for ML training and inference.
Usage:
engine = FeatureEngine()
X = engine.transform(om_daily_df, om_hourly_df)
# X.shape = (n_days, n_features)
"""
# Feature groups for documentation and validation
FEATURE_GROUPS = {
"thermal": [
"t2m_max", "t2m_min", "t2m_mean", "t2m_range",
"rh2m_mean", "rh2m_min", "apparent_t_max",
"heat_index", "dewpoint_depression",
],
"dynamic": [
"wind_speed_10m_max", "wind_gusts_10m_max",
"wind_speed_100m_max", "wind_dir_10m_zonal",
"wind_dir_10m_merid", "surface_pressure_mean",
"pressure_tendency_24h",
],
"moisture": [
"precip_sum", "precip_prob_max", "rain_sum",
"cloud_cover_mean", "cloud_cover_low_mean",
"cloud_cover_mid_mean", "cloud_cover_high_mean",
"visibility_min",
],
"temporal": [
"day_of_year_sin", "day_of_year_cos",
"month_sin", "month_cos",
"t2m_max_delta_24h", "t2m_min_delta_24h",
"precip_prob_delta_24h", "pressure_delta_24h",
],
"interaction": [
"temp_wind_interaction", "heat_humidity_index",
"precip_wind_interaction", "storm_proxy",
"convection_potential",
],
}
def __init__(self):
self.feature_names: List[str] = []
def _build_feature_names(self):
"""Compile ordered feature name list."""
names = []
for group in self.FEATURE_GROUPS.values():
names.extend(group)
self.feature_names = names
def transform(
self,
daily: pd.DataFrame,
hourly: Optional[pd.DataFrame] = None,
) -> np.ndarray:
"""
Transform NWP output into ML feature matrix.
Parameters
----------
daily : pd.DataFrame
Daily forecast with columns: temperature_2m_max, temperature_2m_min,
temperature_2m_mean, precipitation_sum, precipitation_probability_max,
rain_sum, wind_speed_10m_max, wind_gusts_10m_max,
wind_direction_10m_dominant, shortwave_radiation_sum,
et0_fao_evapotranspiration, weather_code
hourly : pd.DataFrame, optional
Hourly forecast with columns: temperature_2m, relative_humidity_2m,
precipitation_probability, precipitation, rain, cloud_cover,
cloud_cover_low, cloud_cover_mid, cloud_cover_high, wind_speed_10m,
wind_speed_100m, wind_gusts_10m, wind_direction_10m, surface_pressure,
visibility
Returns
-------
np.ndarray of shape (n_days, n_features)
"""
self._build_feature_names()
features_list = []
for day_idx, (_, day_row) in enumerate(daily.iterrows()):
day_features = {}
if hourly is not None and not hourly.empty:
# Get hourly slice for this day
day_start = day_row.name.normalize()
if hasattr(day_start, 'tz_localize'):
day_start = day_start.tz_localize(None)
day_end = day_start + pd.Timedelta(days=1)
# Check if hourly index is tz-aware
if hasattr(hourly.index, 'tz') and hourly.index.tz is not None:
hourly_local = hourly.copy()
hourly_local.index = hourly_local.index.tz_localize(None)
else:
hourly_local = hourly.copy()
day_hourly = hourly_local[
(hourly_local.index >= day_start) &
(hourly_local.index < day_end)
]
else:
day_hourly = pd.DataFrame()
# === THERMAL FEATURES ===
day_features["t2m_max"] = float(day_row.get("temperature_2m_max", np.nan))
day_features["t2m_min"] = float(day_row.get("temperature_2m_min", np.nan))
day_features["t2m_mean"] = float(day_row.get("temperature_2m_mean", np.nan))
day_features["t2m_range"] = day_features["t2m_max"] - day_features["t2m_min"]
if not day_hourly.empty:
day_features["rh2m_mean"] = float(day_hourly["relative_humidity_2m"].mean()) if "relative_humidity_2m" in day_hourly else np.nan
day_features["rh2m_min"] = float(day_hourly["relative_humidity_2m"].min()) if "relative_humidity_2m" in day_hourly else np.nan
day_features["apparent_t_max"] = float(day_hourly["apparent_temperature"].max()) if "apparent_temperature" in day_hourly else np.nan
else:
day_features["rh2m_mean"] = np.nan
day_features["rh2m_min"] = np.nan
day_features["apparent_t_max"] = np.nan
# Heat index (Steadman approximation, simplified)
if not np.isnan(day_features.get("t2m_max", np.nan)) and not np.isnan(day_features.get("rh2m_mean", np.nan)):
T = day_features["t2m_max"]
RH = day_features["rh2m_mean"]
day_features["heat_index"] = self._heat_index(T, RH)
else:
day_features["heat_index"] = np.nan
# Dewpoint depression (T - Td, proxy for convection potential)
day_features["dewpoint_depression"] = np.nan
if not day_hourly.empty and "dew_point_2m" in day_hourly:
dp = float(day_hourly["dew_point_2m"].mean())
T = float(day_hourly["temperature_2m"].mean()) if "temperature_2m" in day_hourly else np.nan
if not np.isnan(T) and not np.isnan(dp):
day_features["dewpoint_depression"] = T - dp
# === DYNAMIC FEATURES ===
day_features["wind_speed_10m_max"] = float(day_row.get("wind_speed_10m_max", np.nan))
day_features["wind_gusts_10m_max"] = float(day_row.get("wind_gusts_10m_max", np.nan))
day_features["wind_speed_100m_max"] = np.nan
if not day_hourly.empty:
if "wind_speed_100m" in day_hourly:
day_features["wind_speed_100m_max"] = float(day_hourly["wind_speed_100m"].max())
# Wind direction → zonal/meridional decomposition
if "wind_direction_10m" in day_hourly:
wd_mean = float(day_hourly["wind_direction_10m"].mean())
day_features["wind_dir_10m_zonal"] = -np.sin(np.radians(wd_mean))
day_features["wind_dir_10m_merid"] = -np.cos(np.radians(wd_mean))
else:
day_features["wind_dir_10m_zonal"] = np.nan
day_features["wind_dir_10m_merid"] = np.nan
if "surface_pressure" in day_hourly:
day_features["surface_pressure_mean"] = float(day_hourly["surface_pressure"].mean())
else:
day_features["surface_pressure_mean"] = np.nan
else:
wd = float(day_row.get("wind_direction_10m_dominant", np.nan))
day_features["wind_dir_10m_zonal"] = -np.sin(np.radians(wd)) if not np.isnan(wd) else np.nan
day_features["wind_dir_10m_merid"] = -np.cos(np.radians(wd)) if not np.isnan(wd) else np.nan
day_features["surface_pressure_mean"] = np.nan
day_features["pressure_tendency_24h"] = np.nan # Computed in post-processing
# === MOISTURE FEATURES ===
day_features["precip_sum"] = float(day_row.get("precipitation_sum", 0))
day_features["precip_prob_max"] = float(day_row.get("precipitation_probability_max", 0))
day_features["rain_sum"] = float(day_row.get("rain_sum", 0))
if not day_hourly.empty:
day_features["cloud_cover_mean"] = float(day_hourly["cloud_cover"].mean()) if "cloud_cover" in day_hourly else np.nan
day_features["cloud_cover_low_mean"] = float(day_hourly["cloud_cover_low"].mean()) if "cloud_cover_low" in day_hourly else np.nan
day_features["cloud_cover_mid_mean"] = float(day_hourly["cloud_cover_mid"].mean()) if "cloud_cover_mid" in day_hourly else np.nan
day_features["cloud_cover_high_mean"] = float(day_hourly["cloud_cover_high"].mean()) if "cloud_cover_high" in day_hourly else np.nan
day_features["visibility_min"] = float(day_hourly["visibility"].min()) if "visibility" in day_hourly else np.nan
else:
for c in ["cloud_cover_mean", "cloud_cover_low_mean", "cloud_cover_mid_mean", "cloud_cover_high_mean", "visibility_min"]:
day_features[c] = np.nan
# === TEMPORAL FEATURES ===
date = day_row.name
if hasattr(date, 'to_pydatetime'):
date = date.to_pydatetime()
doy = date.timetuple().tm_yday
day_features["day_of_year_sin"] = np.sin(2 * np.pi * doy / 365.25)
day_features["day_of_year_cos"] = np.cos(2 * np.pi * doy / 365.25)
day_features["month_sin"] = np.sin(2 * np.pi * date.month / 12)
day_features["month_cos"] = np.cos(2 * np.pi * date.month / 12)
# Deltas compute in post-processing
day_features["t2m_max_delta_24h"] = np.nan
day_features["t2m_min_delta_24h"] = np.nan
day_features["precip_prob_delta_24h"] = np.nan
day_features["pressure_delta_24h"] = np.nan
# === INTERACTION FEATURES ===
if not np.isnan(day_features.get("t2m_max", np.nan)) and not np.isnan(day_features.get("wind_speed_10m_max", np.nan)):
day_features["temp_wind_interaction"] = day_features["t2m_max"] * day_features["wind_speed_10m_max"]
else:
day_features["temp_wind_interaction"] = np.nan
if not np.isnan(day_features.get("heat_index", np.nan)) and not np.isnan(day_features.get("rh2m_mean", np.nan)):
day_features["heat_humidity_index"] = day_features["heat_index"] * day_features["rh2m_mean"]
else:
day_features["heat_humidity_index"] = np.nan
if not np.isnan(day_features.get("precip_sum", np.nan)) and not np.isnan(day_features.get("wind_gusts_10m_max", np.nan)):
day_features["precip_wind_interaction"] = day_features["precip_sum"] * day_features["wind_gusts_10m_max"]
else:
day_features["precip_wind_interaction"] = np.nan
# Storm proxy: high wind + high precip + low pressure
if not any(np.isnan(day_features[k]) for k in ["wind_gusts_10m_max", "precip_sum", "surface_pressure_mean"] if k in day_features):
day_features["storm_proxy"] = (
day_features["wind_gusts_10m_max"] / 40.0 +
day_features["precip_sum"] / 50.0 +
(1013.0 - day_features["surface_pressure_mean"]) / 20.0
)
else:
day_features["storm_proxy"] = np.nan
# Convection potential: wind shear × instability proxy
if not day_hourly.empty and "wind_speed_100m" in day_hourly and "wind_speed_10m" in day_hourly:
shear = float(day_hourly["wind_speed_100m"].mean() - day_hourly["wind_speed_10m"].mean())
conv = day_features["precip_prob_max"] * (day_features["t2m_max"] - 20) / 20 if not np.isnan(day_features.get("t2m_max", np.nan)) else 0
day_features["convection_potential"] = shear * conv / 10.0
else:
day_features["convection_potential"] = np.nan
features_list.append(day_features)
df_features = pd.DataFrame(features_list, columns=self.feature_names)
# Post-processing: compute deltas
if len(df_features) > 1:
df_features["t2m_max_delta_24h"] = df_features["t2m_max"].diff()
df_features["t2m_min_delta_24h"] = df_features["t2m_min"].diff()
df_features["precip_prob_delta_24h"] = df_features["precip_prob_max"].diff()
df_features["pressure_tendency_24h"] = df_features["surface_pressure_mean"].diff()
# Fill remaining NaNs with column means (or 0)
X = df_features.fillna(df_features.mean()).fillna(0).values
return X.astype(np.float32)
def transform_single(
self,
daily: pd.DataFrame,
hourly: Optional[pd.DataFrame] = None,
day_index: int = 0,
) -> np.ndarray:
"""Transform a single day's forecast into feature vector for inference."""
self._build_feature_names()
if daily is None or len(daily) <= day_index:
raise ValueError(f"Daily data has {len(daily)} rows, need day_index {day_index}")
# Select single row + context
start = max(0, day_index - 1)
end = min(len(daily), day_index + 2)
subset = daily.iloc[start:end]
if hourly is not None:
day_start = daily.index[day_index]
day_end = day_start + pd.Timedelta(days=1)
if hasattr(hourly.index, 'tz') and hourly.index.tz is not None:
hourly_local = hourly.copy()
hourly_local.index = hourly_local.index.tz_localize(None)
else:
hourly_local = hourly.copy()
h_subset = hourly_local[
(hourly_local.index >= day_start) & (hourly_local.index < day_end)
]
else:
h_subset = None
X = self.transform(subset, h_subset)
return X[max(0, min(day_index, len(subset) - 1))].reshape(1, -1)
@staticmethod
def _heat_index(T: float, RH: float) -> float:
"""
Simplified heat index (Steadman, 1979).
Valid for T > 27°C and RH > 40%.
"""
if T < 27 or RH < 40:
return T
c1, c2, c3 = -8.784695, 1.61139411, 2.338549
c4, c5, c6 = -0.14611605, -1.2308094e-2, -1.6424828e-2
c7, c8, c9 = 2.211732e-3, 7.2546e-4, -3.582e-6
HI = (c1 + c2 * T + c3 * RH + c4 * T * RH +
c5 * T**2 + c6 * RH**2 + c7 * T**2 * RH +
c8 * T * RH**2 + c9 * T**2 * RH**2)
return HI
@property
def n_features(self) -> int:
self._build_feature_names()
return len(self.feature_names)