Files
hk-weather-mkt/weather/hk_extractor.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

211 lines
7.9 KiB
Python

"""Extract Hong Kong specific forecasts from global model outputs.
Handles regional extraction, downscaling hints, and local calibration
based on HKO station data for the Hong Kong region.
"""
from datetime import datetime, timedelta
from typing import Optional, Dict, List
import numpy as np
import pandas as pd
from config import HK_COORDS, HK_BBOX
from .openmeteo_client import OpenMeteoClient
from .hko_client import HKOClient
class HKExtractor:
"""Extract and calibrate HK-specific weather forecasts from global models."""
# Known stations for calibration (HKO stations with good historical data)
CALIBRATION_STATIONS = [
"Hong Kong Observatory", # Tsim Sha Tsui
"Chek Lap Kok", # Airport
"Sha Tin",
"Tuen Mun",
"Sai Kung",
"Ta Kwu Ling",
"Sheung Shui",
"Stanley",
]
# Calibration offsets - will be learned over time
# (model_bias, model_std) for key variables
DEFAULT_BIAS = {
"temperature_2m_max": 0.0,
"temperature_2m_min": 0.0,
"precipitation_probability_max": 0.0,
"wind_speed_10m_max": 0.0,
}
def __init__(self, calibrate: bool = True):
self.openmeteo = OpenMeteoClient()
self.hko = HKOClient()
self.calibrate = calibrate
self.bias_model = self.DEFAULT_BIAS.copy()
self._load_calibration()
def _load_calibration(self):
"""Load calibration params from stored file if available."""
import os
import json
path = os.path.join(os.path.dirname(__file__), "..", "data", "calibration.json")
if os.path.exists(path):
try:
with open(path) as f:
stored = json.load(f)
self.bias_model.update(stored.get("bias", {}))
except Exception:
pass
def save_calibration(self):
"""Save calibration params for future runs."""
import os
import json
path = os.path.join(os.path.dirname(__file__), "..", "data", "calibration.json")
with open(path, "w") as f:
json.dump({"bias": self.bias_model, "updated": datetime.now().isoformat()}, f, indent=2)
def get_hk_forecast(self, lead_days: int = 7) -> Dict:
"""Get calibrated HK-specific forecast combining multiple sources."""
forecast = {
"fetch_time": datetime.now().isoformat(),
"sources": {},
}
wnext = self.openmeteo.get_forecast(lead_days=lead_days)
if wnext is not None:
forecast["sources"]["weathernext"] = self._calibrate_forecast(wnext)
hko_fc = self.hko.get_forecast()
if hko_fc:
forecast["sources"]["hko"] = hko_fc
current = self.hko.get_current_weather()
if current:
forecast["current_observations"] = current
typhoon = self.hko.get_typhoon_info()
if typhoon:
forecast["typhoon_info"] = typhoon
forecast["consensus"] = self._build_consensus(forecast)
return forecast
def _calibrate_forecast(self, df: pd.DataFrame) -> List[Dict]:
"""Apply calibration to model forecast and return structured data."""
results = []
for idx, row in df.iterrows():
day = {
"date": idx.strftime("%Y-%m-%d"),
"temp_max_calibrated": float(row.get("temperature_2m_max", np.nan)) + self.bias_model.get("temperature_2m_max", 0),
"temp_min_calibrated": float(row.get("temperature_2m_min", np.nan)) + self.bias_model.get("temperature_2m_min", 0),
"temp_max_raw": float(row.get("temperature_2m_max", np.nan)),
"temp_min_raw": float(row.get("temperature_2m_min", np.nan)),
"precipitation_probability_calibrated": min(100, max(0, float(row.get("precipitation_probability_max", 0)) + self.bias_model.get("precipitation_probability_max", 0))),
"precipitation_probability_raw": float(row.get("precipitation_probability_max", 0)),
"precipitation_sum": float(row.get("precipitation_sum", 0)),
"wind_speed_max_calibrated": float(row.get("wind_speed_10m_max", np.nan)) + self.bias_model.get("wind_speed_10m_max", 0),
"wind_speed_max_raw": float(row.get("wind_speed_10m_max", np.nan)),
"wind_gusts_max": float(row.get("wind_gusts_10m_max", np.nan)),
}
results.append(day)
return results
def _build_consensus(self, forecast: Dict) -> Dict:
"""Build a consensus forecast from all available sources."""
consensus = {}
if "weathernext" in forecast.get("sources", {}):
w = forecast["sources"]["weathernext"]
if w:
d0 = w[0]
consensus["tomorrow"] = d0
if "hko" in forecast.get("sources", {}):
h = forecast["sources"]["hko"]
if h and len(h) > 0:
consensus["hko_tomorrow"] = h[0]
current = forecast.get("current_observations", {})
if current:
consensus["current_temp"] = (
current.get("temperature", [{}])[0].get("value") if current.get("temperature") else None
)
return consensus
def get_combined_tomorrow_forecast(self) -> Dict:
"""Get a single combined forecast for 'tomorrow' from all sources."""
fc = self.get_hk_forecast()
return fc.get("consensus", {})
def should_bet_rain_tomorrow(self) -> Optional[float]:
"""Returns model-implied probability of rain tomorrow (0-100)."""
fc = self.get_hk_forecast()
consensus = fc.get("consensus", {})
tomorrow = consensus.get("tomorrow", {})
hko = consensus.get("hko_tomorrow", {})
probs = []
if "precipitation_probability_calibrated" in tomorrow:
probs.append(tomorrow["precipitation_probability_calibrated"])
hko_prob_str = hko.get("forecast_rain_probability", "")
if hko_prob_str:
try:
nums = [int(x.replace("%", "")) for x in hko_prob_str.split("/")]
probs.append(max(nums))
except (ValueError, AttributeError):
pass
if not probs:
return None
return float(np.mean(probs))
def should_bet_temp_above(self, threshold: float = 30.0) -> Optional[float]:
"""Returns model-implied probability that temp exceeds threshold tomorrow."""
fc = self.get_hk_forecast()
consensus = fc.get("consensus", {})
tomorrow = consensus.get("tomorrow", {})
hko = consensus.get("hko_tomorrow", {})
temp_max_raw = tomorrow.get("temp_max_raw", np.nan)
temp_max_cal = tomorrow.get("temp_max_calibrated", np.nan)
# Simple: if calibrated max is above threshold, probability from how far above
if not np.isnan(temp_max_cal):
excess = temp_max_cal - threshold
prob = min(100, max(0, 50 + excess * 20)) # Simple sigmoid-like
return prob
return None
def update_calibration(self, forecast_date: str, observed: Dict):
"""Update calibration based on observed vs predicted.
Called after a prediction window closes with actual weather observations.
Uses exponential moving average of errors for each variable.
observed dict should have keys matching variables, e.g.:
{"temperature_2m_max": 33.5, "precipitation_sum": 2.1}
"""
alpha = 0.1 # EMA smoothing factor
for var in self.bias_model:
if var not in observed:
continue
observed_val = observed[var]
if observed_val is None:
continue
# Current bias → new bias with EMA
current_bias = self.bias_model.get(var, 0.0)
new_bias = current_bias * (1 - alpha) + observed_val * alpha
self.bias_model[var] = new_bias
self.save_calibration()