DEB 接入小时级误差计算,多模型权重基于每日+小时 MAE 融合
新增 compute_hourly_model_errors 聚合逐模型小时 MAE/RMSE 新增 _blend_mae 按样本数加权混合每日/小时误差(24样本=70%小时权重) calculate_dynamic_weights 从 daily_record 读取 hourly_error 参与权重计算 update_daily_record 接受并持久化 hourly_error 字段 Tested: ruff check, pytest 186/186
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
@@ -140,6 +142,83 @@ def _collapse_forecasts_for_deb(current_forecasts):
|
||||
return collapsed
|
||||
|
||||
|
||||
def compute_hourly_model_errors(
|
||||
hourly_forecasts: dict[str, list[float]],
|
||||
hourly_actuals: list[float],
|
||||
) -> dict[str, dict[str, float]]:
|
||||
"""
|
||||
Compute per-model hourly error aggregation for a single day.
|
||||
|
||||
hourly_forecasts: {model_name: [t0, t1, ..., tN]} — N-hour forecast per model
|
||||
hourly_actuals: [t0, t1, ..., tM] — actual hourly temps
|
||||
|
||||
Returns: {model_name: {"mae": float, "rmse": float, "samples": int}}
|
||||
"""
|
||||
if not hourly_actuals or not hourly_forecasts:
|
||||
return {}
|
||||
|
||||
n = min(len(hourly_actuals), min(len(v) for v in hourly_forecasts.values()))
|
||||
if n < 6: # require at least 6 valid hours
|
||||
return {}
|
||||
|
||||
actuals = hourly_actuals[:n]
|
||||
result: dict[str, dict[str, float]] = {}
|
||||
|
||||
for model, preds in hourly_forecasts.items():
|
||||
if not isinstance(preds, (list, tuple)) or len(preds) < n:
|
||||
continue
|
||||
try:
|
||||
valid = preds[:n]
|
||||
except (TypeError, IndexError):
|
||||
continue
|
||||
|
||||
abs_errors = []
|
||||
sq_errors = []
|
||||
for p, a in zip(valid, actuals):
|
||||
try:
|
||||
pv = float(p)
|
||||
av = float(a)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
abs_errors.append(abs(pv - av))
|
||||
sq_errors.append((pv - av) ** 2)
|
||||
|
||||
samples = len(abs_errors)
|
||||
if samples < 6:
|
||||
continue
|
||||
|
||||
mae = sum(abs_errors) / samples
|
||||
rmse = (sum(sq_errors) / samples) ** 0.5
|
||||
result[model] = {"mae": round(mae, 2), "rmse": round(rmse, 2), "samples": samples}
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _blend_mae(daily_mae: float, hourly_error: dict[str, float] | None) -> float:
|
||||
"""
|
||||
Blend daily MAE and hourly MAE into a single error metric.
|
||||
|
||||
hourly_error = {"mae": float, "rmse": float, "samples": int}
|
||||
|
||||
Weight split: daily=0.3, hourly=0.7 when hourly samples >= 24;
|
||||
scales linearly from daily-only (0 samples) to full blend (≥24 samples).
|
||||
"""
|
||||
if not hourly_error or not isinstance(hourly_error, dict):
|
||||
return daily_mae
|
||||
|
||||
h_mae = float(hourly_error.get("mae", daily_mae))
|
||||
h_samples = int(hourly_error.get("samples", 0))
|
||||
|
||||
if h_samples < 6:
|
||||
return daily_mae
|
||||
|
||||
# Blend ratio: the more hourly samples, the more we trust hourly MAE
|
||||
h_weight = min(0.7, h_samples / 24 * 0.7)
|
||||
d_weight = 1.0 - h_weight
|
||||
|
||||
return round(daily_mae * d_weight + h_mae * h_weight, 2)
|
||||
|
||||
|
||||
def load_history(filepath):
|
||||
global _history_cache, _history_mtime
|
||||
mode = get_state_storage_mode()
|
||||
@@ -800,6 +879,7 @@ def update_daily_record(
|
||||
probability_features=None,
|
||||
shadow_probabilities=None,
|
||||
calibration_summary=None,
|
||||
hourly_error=None,
|
||||
):
|
||||
"""
|
||||
保存/更新某城市某天的各个模型预报与最终实测值
|
||||
@@ -809,6 +889,7 @@ def update_daily_record(
|
||||
mu: float, 概率引擎中心值(用于 μ MAE 追踪)
|
||||
probabilities: list[dict], 概率分布快照(用于 Brier Score 校准)
|
||||
例如 [{"value": 25, "probability": 0.8}, {"value": 26, "probability": 0.2}]
|
||||
hourly_error: dict, 逐模型小时级误差聚合 {"ECMWF": {"mae": 1.1, "rmse": 1.4, "samples": 68}, ...}
|
||||
"""
|
||||
project_root = os.path.dirname(
|
||||
os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
@@ -882,6 +963,27 @@ def update_daily_record(
|
||||
merged_forecasts[model_name] = model_value
|
||||
elif model_name not in merged_forecasts:
|
||||
merged_forecasts[model_name] = model_value
|
||||
old_hourly_error = existing.get("hourly_error")
|
||||
# Merge hourly_error: keep existing per-model data and overlay new values
|
||||
merged_hourly_error = dict(old_hourly_error) if isinstance(old_hourly_error, dict) else {}
|
||||
if isinstance(hourly_error, dict):
|
||||
for model, err in hourly_error.items():
|
||||
if isinstance(err, dict) and all(
|
||||
k in err for k in ("mae", "samples")
|
||||
):
|
||||
# Prefer the entry with more samples
|
||||
old_entry = merged_hourly_error.get(model)
|
||||
if (
|
||||
not isinstance(old_entry, dict)
|
||||
or int(err.get("samples", 0)) >= int(old_entry.get("samples", 0))
|
||||
):
|
||||
merged_hourly_error[model] = {
|
||||
"mae": round(float(err["mae"]), 2),
|
||||
"rmse": round(float(err.get("rmse", err["mae"])), 2),
|
||||
"samples": int(err["samples"]),
|
||||
}
|
||||
next_hourly_error = merged_hourly_error if merged_hourly_error else None
|
||||
|
||||
next_mu = round(mu, 2) if mu is not None else None
|
||||
if (
|
||||
old_actual == actual_high
|
||||
@@ -901,6 +1003,7 @@ def update_daily_record(
|
||||
compact_calibration is None
|
||||
or existing.get("probability_calibration") == compact_calibration
|
||||
)
|
||||
and old_hourly_error == next_hourly_error
|
||||
):
|
||||
return
|
||||
|
||||
@@ -925,6 +1028,8 @@ def update_daily_record(
|
||||
existing["shadow_prob_snapshot"] = compact_shadow_probs
|
||||
if compact_calibration is not None:
|
||||
existing["probability_calibration"] = compact_calibration
|
||||
if next_hourly_error is not None:
|
||||
existing["hourly_error"] = next_hourly_error
|
||||
|
||||
if actual_high is not None:
|
||||
try:
|
||||
@@ -1025,6 +1130,7 @@ def calculate_dynamic_weights(city_name, current_forecasts, lookback_days=7, dec
|
||||
record = city_data[date_str]
|
||||
actual = record.get("actual_high")
|
||||
past_forecasts = record.get("forecasts", {})
|
||||
past_hourly_error = record.get("hourly_error")
|
||||
|
||||
if actual is None:
|
||||
continue
|
||||
@@ -1038,7 +1144,15 @@ def calculate_dynamic_weights(city_name, current_forecasts, lookback_days=7, dec
|
||||
av = float(actual)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
errors[model].append((abs(pv - av), decay_weight))
|
||||
daily_error = abs(pv - av)
|
||||
# Blend with hourly error when available
|
||||
h_err = (
|
||||
past_hourly_error.get(model)
|
||||
if isinstance(past_hourly_error, dict)
|
||||
else None
|
||||
)
|
||||
blended_error = _blend_mae(daily_error, h_err)
|
||||
errors[model].append((blended_error, decay_weight))
|
||||
|
||||
days_used += 1
|
||||
if days_used >= lookback_days:
|
||||
|
||||
@@ -89,3 +89,60 @@ def test_deb_weighted_path_uses_deduped_family_values(monkeypatch):
|
||||
assert blended < 30.0
|
||||
assert "ICON-D2" in info
|
||||
assert "家族去重" in info
|
||||
|
||||
|
||||
def test_compute_hourly_model_errors_basic():
|
||||
from src.analysis.deb_algorithm import compute_hourly_model_errors
|
||||
|
||||
hourly_forecasts = {
|
||||
"ECMWF": [15.0, 15.5, 16.0, 16.5, 17.0, 17.5],
|
||||
"GFS": [14.5, 15.0, 15.5, 16.0, 16.5, 17.0],
|
||||
}
|
||||
hourly_actuals = [15.2, 15.8, 16.3, 16.8, 17.4, 18.0]
|
||||
|
||||
result = compute_hourly_model_errors(hourly_forecasts, hourly_actuals)
|
||||
|
||||
assert "ECMWF" in result
|
||||
assert "GFS" in result
|
||||
assert result["ECMWF"]["samples"] == 6
|
||||
assert result["GFS"]["samples"] == 6
|
||||
# ECMWF predicted lower, should have smaller MAE
|
||||
assert result["ECMWF"]["mae"] < result["GFS"]["mae"]
|
||||
assert result["ECMWF"]["rmse"] > 0
|
||||
assert result["GFS"]["rmse"] > 0
|
||||
|
||||
|
||||
def test_compute_hourly_model_errors_too_few_samples():
|
||||
from src.analysis.deb_algorithm import compute_hourly_model_errors
|
||||
|
||||
hourly_forecasts = {"ECMWF": [15.0, 15.5]}
|
||||
hourly_actuals = [15.0, 15.5]
|
||||
|
||||
result = compute_hourly_model_errors(hourly_forecasts, hourly_actuals)
|
||||
assert result == {} # < 6 samples, returns empty
|
||||
|
||||
|
||||
def test_blend_mae_with_hourly():
|
||||
from src.analysis.deb_algorithm import _blend_mae
|
||||
|
||||
# Full 24 hourly samples → 0.7 hourly weight
|
||||
h_err = {"mae": 0.8, "rmse": 1.0, "samples": 24}
|
||||
blended = _blend_mae(1.5, h_err)
|
||||
# 1.5 * 0.3 + 0.8 * 0.7 = 0.45 + 0.56 = 1.01
|
||||
assert abs(blended - 1.01) < 0.01
|
||||
|
||||
# 12 hourly samples → ~0.35 hourly weight
|
||||
h_err2 = {"mae": 1.0, "rmse": 1.2, "samples": 12}
|
||||
blended2 = _blend_mae(2.0, h_err2)
|
||||
# 12/24 * 0.7 = 0.35 hourly weight
|
||||
expected = 2.0 * (1 - 0.35) + 1.0 * 0.35 # 1.30 + 0.35 = 1.65
|
||||
assert abs(blended2 - expected) < 0.01
|
||||
|
||||
# No hourly error → returns daily MAE unchanged
|
||||
blended3 = _blend_mae(2.5, None)
|
||||
assert blended3 == 2.5
|
||||
|
||||
# Too few samples (< 6) → returns daily MAE unchanged
|
||||
h_err4 = {"mae": 0.5, "samples": 3}
|
||||
blended4 = _blend_mae(3.0, h_err4)
|
||||
assert blended4 == 3.0
|
||||
|
||||
Reference in New Issue
Block a user