Add DEB city trust tiers
This commit is contained in:
@@ -56,10 +56,20 @@ type DebSummaryPayload = {
|
||||
versions?: Record<string, DebVersionSummary>;
|
||||
};
|
||||
|
||||
type DebRecentStrategy = {
|
||||
recent_7d?: DebWindowSummary;
|
||||
recent_14d?: DebWindowSummary;
|
||||
trust_tier?: "high" | "medium" | "low" | "insufficient" | string;
|
||||
recommendation?: "primary" | "supporting" | "context_only" | "insufficient" | string;
|
||||
bias_direction?: "under" | "over" | "neutral" | "unknown" | string;
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
type TrainingCity = {
|
||||
city_id: string;
|
||||
name: string;
|
||||
deb?: MetricPayload;
|
||||
deb_recent?: DebRecentStrategy | null;
|
||||
mu?: MetricPayload;
|
||||
};
|
||||
|
||||
@@ -87,6 +97,27 @@ function barColor(hr: number) {
|
||||
return "#dc2626";
|
||||
}
|
||||
|
||||
function trustBadgeClass(tier?: string) {
|
||||
if (tier === "high") return "border-emerald-200 bg-emerald-50 text-emerald-700";
|
||||
if (tier === "medium") return "border-amber-200 bg-amber-50 text-amber-700";
|
||||
if (tier === "low") return "border-rose-200 bg-rose-50 text-rose-700";
|
||||
return "border-slate-200 bg-slate-50 text-slate-500";
|
||||
}
|
||||
|
||||
function trustLabel(tier: string | undefined, isEn: boolean) {
|
||||
if (tier === "high") return isEn ? "High" : "高";
|
||||
if (tier === "medium") return isEn ? "Medium" : "中";
|
||||
if (tier === "low") return isEn ? "Low" : "低";
|
||||
return isEn ? "Thin" : "少";
|
||||
}
|
||||
|
||||
function recommendationLabel(value: string | undefined, isEn: boolean) {
|
||||
if (value === "primary") return isEn ? "Primary" : "主用";
|
||||
if (value === "supporting") return isEn ? "Support" : "辅助";
|
||||
if (value === "context_only") return isEn ? "Context" : "参考";
|
||||
return isEn ? "Insufficient" : "样本少";
|
||||
}
|
||||
|
||||
const TRAINING_CACHE_KEY = "polyweather_training_accuracy_v1";
|
||||
const TRAINING_CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||
|
||||
@@ -322,12 +353,15 @@ export function TrainingDashboard({ isEn }: { isEn: boolean }) {
|
||||
)}
|
||||
|
||||
{/* ── Combined Table ── */}
|
||||
<div className="rounded-lg border border-slate-200 bg-white overflow-hidden">
|
||||
<table className="w-full text-[12px] border-collapse">
|
||||
<div className="overflow-x-auto rounded-lg border border-slate-200 bg-white">
|
||||
<table className="min-w-[960px] w-full text-[12px] border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-200 bg-[#f8f9fa] text-left">
|
||||
<th className="w-10 px-3 py-2 text-center text-[11px] font-black text-slate-400">#</th>
|
||||
<th className="px-3 py-2 text-[11px] font-black uppercase text-slate-500">{isEn ? "City" : "城市"}</th>
|
||||
<th className="px-2 py-2 text-left text-[11px] font-black uppercase text-slate-500">{isEn ? "DEB Trust" : "DEB 信任"}</th>
|
||||
<th className="px-2 py-2 text-right text-[11px] font-black uppercase text-slate-500">{isEn ? "7d" : "7天"}</th>
|
||||
<th className="px-2 py-2 text-right text-[11px] font-black uppercase text-slate-500">{isEn ? "14d" : "14天"}</th>
|
||||
<th className="px-2 py-2 text-right text-[11px] font-black uppercase text-slate-500">{isEn ? "DEB Hit" : "DEB 命中"}</th>
|
||||
<th className="px-2 py-2 text-right text-[11px] font-black uppercase text-slate-500">{isEn ? "DEB Error" : "DEB 误差"}</th>
|
||||
<th className="px-2 py-2 text-right text-[11px] font-black uppercase text-slate-500">{isEn ? "μ Hit" : "μ 命中"}</th>
|
||||
@@ -338,12 +372,12 @@ export function TrainingDashboard({ isEn }: { isEn: boolean }) {
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{debSorted.length || muSorted.length ? (
|
||||
(() => {
|
||||
const cities = new Map<string, { deb?: MetricPayload; mu?: MetricPayload; name: string }>();
|
||||
for (const c of debSorted) cities.set(c.city_id, { deb: c.deb, mu: c.mu, name: c.name });
|
||||
const cities = new Map<string, { deb?: MetricPayload; debRecent?: DebRecentStrategy | null; mu?: MetricPayload; name: string }>();
|
||||
for (const c of debSorted) cities.set(c.city_id, { deb: c.deb, debRecent: c.deb_recent, mu: c.mu, name: c.name });
|
||||
for (const c of muSorted) {
|
||||
const existing = cities.get(c.city_id);
|
||||
if (existing) existing.mu = c.mu;
|
||||
else cities.set(c.city_id, { deb: c.deb, mu: c.mu, name: c.name });
|
||||
else cities.set(c.city_id, { deb: c.deb, debRecent: c.deb_recent, mu: c.mu, name: c.name });
|
||||
}
|
||||
const merged = [...cities.entries()]
|
||||
.sort((a, b) => {
|
||||
@@ -352,14 +386,30 @@ export function TrainingDashboard({ isEn }: { isEn: boolean }) {
|
||||
return bMax - aMax;
|
||||
})
|
||||
.slice(0, 30);
|
||||
return merged.map(([cityId, { deb, mu, name }], i) => {
|
||||
return merged.map(([cityId, { deb, debRecent, mu, name }], i) => {
|
||||
const debHit = deb?.hit_rate ?? 0;
|
||||
const muHit = mu?.hit_rate ?? 0;
|
||||
const brier = mu?.brier_score;
|
||||
const recent7 = debRecent?.recent_7d?.hit_rate;
|
||||
const recent14 = debRecent?.recent_14d?.hit_rate;
|
||||
return (
|
||||
<tr key={cityId} className="hover:bg-slate-50 transition-colors">
|
||||
<td className="px-3 py-2 text-center text-[12px] font-mono text-slate-400">{i + 1}</td>
|
||||
<td className="px-3 py-2 font-semibold text-slate-800 capitalize">{name}</td>
|
||||
<td className="px-2 py-2">
|
||||
{debRecent ? (
|
||||
<span
|
||||
className={`inline-flex min-w-[4rem] items-center justify-center rounded border px-2 py-0.5 text-[10px] font-black uppercase ${trustBadgeClass(debRecent.trust_tier)}`}
|
||||
title={debRecent.reason}
|
||||
>
|
||||
{trustLabel(debRecent.trust_tier, isEn)} · {recommendationLabel(debRecent.recommendation, isEn)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-slate-400">--</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-2 py-2 text-right font-mono text-slate-600">{recent7 == null ? "--" : `${recent7.toFixed(0)}%`}</td>
|
||||
<td className="px-2 py-2 text-right font-mono text-slate-600">{recent14 == null ? "--" : `${recent14.toFixed(0)}%`}</td>
|
||||
<td className="px-2 py-2 text-right font-mono font-bold" style={{ color: barColor(debHit) }}>
|
||||
{deb ? `${debHit.toFixed(0)}%` : "--"}
|
||||
</td>
|
||||
@@ -375,7 +425,7 @@ export function TrainingDashboard({ isEn }: { isEn: boolean }) {
|
||||
})()
|
||||
) : (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-4 py-12 text-center text-slate-400">
|
||||
<td colSpan={10} className="px-4 py-12 text-center text-slate-400">
|
||||
{data === null ? (isEn ? "Loading..." : "加载中...") : (isEn ? "No training data" : "暂无训练数据")}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -56,6 +56,22 @@ interface CityAccuracy {
|
||||
total_days: number;
|
||||
details_str: string;
|
||||
} | null;
|
||||
deb_recent?: {
|
||||
recent_7d?: {
|
||||
hit_rate?: number | null;
|
||||
samples?: number;
|
||||
mae?: number | null;
|
||||
};
|
||||
recent_14d?: {
|
||||
hit_rate?: number | null;
|
||||
samples?: number;
|
||||
mae?: number | null;
|
||||
};
|
||||
trust_tier?: string;
|
||||
recommendation?: string;
|
||||
bias_direction?: string;
|
||||
reason?: string;
|
||||
} | null;
|
||||
mu?: {
|
||||
mae: number;
|
||||
hit_rate: number;
|
||||
@@ -87,6 +103,31 @@ interface DebSummary {
|
||||
};
|
||||
}
|
||||
|
||||
function debTrustBadgeClass(tier?: string) {
|
||||
if (tier === "high") return "bg-emerald-500/15 text-emerald-300 border-emerald-500/30";
|
||||
if (tier === "medium") return "bg-amber-500/15 text-amber-300 border-amber-500/30";
|
||||
if (tier === "low") return "bg-rose-500/15 text-rose-300 border-rose-500/30";
|
||||
return "bg-slate-500/15 text-slate-300 border-slate-500/30";
|
||||
}
|
||||
|
||||
function debTrustLabel(tier?: string) {
|
||||
if (tier === "high") return "高可信";
|
||||
if (tier === "medium") return "中可信";
|
||||
if (tier === "low") return "低可信";
|
||||
return "样本少";
|
||||
}
|
||||
|
||||
function debRecommendationLabel(recommendation?: string) {
|
||||
if (recommendation === "primary") return "主用";
|
||||
if (recommendation === "supporting") return "辅助";
|
||||
if (recommendation === "context_only") return "仅参考";
|
||||
return "不足";
|
||||
}
|
||||
|
||||
function formatPct(value: number | null | undefined) {
|
||||
return value == null ? "—" : `${value.toFixed(0)}%`;
|
||||
}
|
||||
|
||||
export function TrainingPageClient() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [status, setStatus] = useState<SystemStatusPayload | null>(null);
|
||||
@@ -279,6 +320,8 @@ export function TrainingPageClient() {
|
||||
<thead className="text-xs uppercase bg-slate-800/50 text-slate-400">
|
||||
<tr>
|
||||
<th scope="col" className="px-4 py-3">城市</th>
|
||||
<th scope="col" className="px-4 py-3 text-center">DEB 策略</th>
|
||||
<th scope="col" className="px-4 py-3 text-center">近 7 / 14 天</th>
|
||||
<th scope="col" className="px-4 py-3 text-center">DEB 命中</th>
|
||||
<th scope="col" className="px-4 py-3 text-center">DEB MAE</th>
|
||||
<th scope="col" className="px-4 py-3 text-center">DEB 天数</th>
|
||||
@@ -296,6 +339,29 @@ export function TrainingPageClient() {
|
||||
{row.name}
|
||||
<span className="text-xs text-slate-500 block font-mono">{row.city_id}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
{row.deb_recent ? (
|
||||
<span
|
||||
title={row.deb_recent.reason}
|
||||
className={`inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-semibold ${debTrustBadgeClass(row.deb_recent.trust_tier)}`}
|
||||
>
|
||||
{debTrustLabel(row.deb_recent.trust_tier)} · {debRecommendationLabel(row.deb_recent.recommendation)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-slate-600">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center font-mono text-xs text-slate-300">
|
||||
{row.deb_recent ? (
|
||||
<span>
|
||||
{formatPct(row.deb_recent.recent_7d?.hit_rate)}
|
||||
<span className="mx-1 text-slate-600">/</span>
|
||||
{formatPct(row.deb_recent.recent_14d?.hit_rate)}
|
||||
</span>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
{row.deb ? (
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs font-semibold ${
|
||||
@@ -357,7 +423,7 @@ export function TrainingPageClient() {
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td colSpan={8} className="px-4 py-8 text-center text-slate-500">
|
||||
<td colSpan={10} className="px-4 py-8 text-center text-slate-500">
|
||||
无有效准确率记录
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -157,6 +157,32 @@ export const opsApi = {
|
||||
hits?: number;
|
||||
details_str: string;
|
||||
} | null;
|
||||
deb_recent?: {
|
||||
recent_7d?: {
|
||||
start_date?: string | null;
|
||||
end_date?: string | null;
|
||||
samples?: number;
|
||||
hits?: number;
|
||||
hit_rate?: number | null;
|
||||
mae?: number | null;
|
||||
bias?: number | null;
|
||||
city_count?: number;
|
||||
};
|
||||
recent_14d?: {
|
||||
start_date?: string | null;
|
||||
end_date?: string | null;
|
||||
samples?: number;
|
||||
hits?: number;
|
||||
hit_rate?: number | null;
|
||||
mae?: number | null;
|
||||
bias?: number | null;
|
||||
city_count?: number;
|
||||
};
|
||||
trust_tier?: string;
|
||||
recommendation?: string;
|
||||
bias_direction?: string;
|
||||
reason?: string;
|
||||
} | null;
|
||||
mu?: {
|
||||
mae: number;
|
||||
hit_rate: number;
|
||||
|
||||
@@ -39,6 +39,56 @@ def test_training_accuracy_payload_includes_recent_deb_summary():
|
||||
assert "deb_v2_bucket_calibrated" in payload["deb_summary"]["versions"]
|
||||
|
||||
|
||||
def test_training_accuracy_payload_includes_city_deb_trust_strategy():
|
||||
history = {
|
||||
"alpha": {
|
||||
"2026-06-01": {"actual_high": 20.0, "deb_prediction": 20.1},
|
||||
"2026-06-02": {"actual_high": 21.0, "deb_prediction": 21.1},
|
||||
"2026-06-03": {"actual_high": 22.0, "deb_prediction": 22.2},
|
||||
"2026-06-04": {"actual_high": 23.0, "deb_prediction": 23.0},
|
||||
"2026-06-05": {"actual_high": 24.0, "deb_prediction": 24.1},
|
||||
},
|
||||
"beta": {
|
||||
"2026-06-01": {"actual_high": 30.0, "deb_prediction": 28.0},
|
||||
"2026-06-02": {"actual_high": 31.0, "deb_prediction": 29.0},
|
||||
"2026-06-03": {"actual_high": 32.0, "deb_prediction": 30.0},
|
||||
"2026-06-04": {"actual_high": 33.0, "deb_prediction": 31.0},
|
||||
"2026-06-05": {"actual_high": 34.0, "deb_prediction": 32.0},
|
||||
},
|
||||
"gamma": {
|
||||
"2026-06-05": {"actual_high": 25.0, "deb_prediction": 25.1},
|
||||
},
|
||||
}
|
||||
registry = {
|
||||
"alpha": {"name": "Alpha"},
|
||||
"beta": {"name": "Beta"},
|
||||
"gamma": {"name": "Gamma"},
|
||||
}
|
||||
|
||||
payload = _build_training_accuracy_payload(
|
||||
history,
|
||||
registry,
|
||||
today_str="2026-06-07",
|
||||
)
|
||||
|
||||
rows = {row["city_id"]: row for row in payload["accuracy"]}
|
||||
|
||||
assert rows["alpha"]["deb_recent"]["recent_7d"]["samples"] == 5
|
||||
assert rows["alpha"]["deb_recent"]["recent_7d"]["hit_rate"] == 100.0
|
||||
assert rows["alpha"]["deb_recent"]["trust_tier"] == "high"
|
||||
assert rows["alpha"]["deb_recent"]["recommendation"] == "primary"
|
||||
assert rows["alpha"]["deb_recent"]["bias_direction"] == "neutral"
|
||||
|
||||
assert rows["beta"]["deb_recent"]["recent_14d"]["samples"] == 5
|
||||
assert rows["beta"]["deb_recent"]["trust_tier"] == "low"
|
||||
assert rows["beta"]["deb_recent"]["recommendation"] == "context_only"
|
||||
assert rows["beta"]["deb_recent"]["bias_direction"] == "under"
|
||||
assert "recent_14d" in rows["beta"]["deb_recent"]["reason"]
|
||||
|
||||
assert rows["gamma"]["deb_recent"]["trust_tier"] == "insufficient"
|
||||
assert rows["gamma"]["deb_recent"]["recommendation"] == "insufficient"
|
||||
|
||||
|
||||
def test_training_accuracy_payload_caps_version_backtest_samples(monkeypatch):
|
||||
start = date(2025, 1, 1)
|
||||
history = {
|
||||
|
||||
+80
-2
@@ -2280,8 +2280,12 @@ def _evaluate_deb_records(
|
||||
}
|
||||
|
||||
|
||||
def _build_city_deb_accuracy(city_id: str, city_rows: Dict[str, Dict[str, Any]], today_str: str) -> Optional[Dict[str, Any]]:
|
||||
rows = []
|
||||
def _build_city_deb_rows(
|
||||
city_id: str,
|
||||
city_rows: Dict[str, Dict[str, Any]],
|
||||
today_str: str,
|
||||
) -> List[Dict[str, Any]]:
|
||||
rows: List[Dict[str, Any]] = []
|
||||
for target_date, record in sorted((city_rows or {}).items()):
|
||||
if target_date >= today_str or not isinstance(record, dict):
|
||||
continue
|
||||
@@ -2293,6 +2297,78 @@ def _build_city_deb_accuracy(city_id: str, city_rows: Dict[str, Dict[str, Any]],
|
||||
"deb_prediction": record.get("deb_prediction"),
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def _deb_recent_bias_direction(bias: Optional[float]) -> str:
|
||||
if bias is None:
|
||||
return "unknown"
|
||||
if bias <= -0.5:
|
||||
return "under"
|
||||
if bias >= 0.5:
|
||||
return "over"
|
||||
return "neutral"
|
||||
|
||||
|
||||
def _build_deb_recent_trust_strategy(
|
||||
recent_7d: Dict[str, Any],
|
||||
recent_14d: Dict[str, Any],
|
||||
) -> Dict[str, Any]:
|
||||
window_key = "recent_14d" if int(recent_14d.get("samples") or 0) >= int(recent_7d.get("samples") or 0) else "recent_7d"
|
||||
metrics = recent_14d if window_key == "recent_14d" else recent_7d
|
||||
samples = int(metrics.get("samples") or 0)
|
||||
hit_rate = _sf(metrics.get("hit_rate"))
|
||||
mae = _sf(metrics.get("mae"))
|
||||
bias = _sf(metrics.get("bias"))
|
||||
|
||||
if samples < 3 or hit_rate is None or mae is None:
|
||||
trust_tier = "insufficient"
|
||||
recommendation = "insufficient"
|
||||
reason = f"{window_key}: only {samples} settled DEB samples."
|
||||
elif hit_rate >= 67.0 and mae <= 1.25:
|
||||
trust_tier = "high"
|
||||
recommendation = "primary"
|
||||
reason = f"{window_key}: {hit_rate:.1f}% hit rate, MAE {mae:.2f}°."
|
||||
elif hit_rate >= 34.0 and mae <= 1.75:
|
||||
trust_tier = "medium"
|
||||
recommendation = "supporting"
|
||||
reason = f"{window_key}: {hit_rate:.1f}% hit rate, MAE {mae:.2f}°."
|
||||
else:
|
||||
trust_tier = "low"
|
||||
recommendation = "context_only"
|
||||
reason = f"{window_key}: {hit_rate:.1f}% hit rate, MAE {mae:.2f}°."
|
||||
|
||||
return {
|
||||
"trust_tier": trust_tier,
|
||||
"recommendation": recommendation,
|
||||
"bias_direction": _deb_recent_bias_direction(bias),
|
||||
"reason": reason,
|
||||
}
|
||||
|
||||
|
||||
def _build_city_deb_recent_strategy(
|
||||
city_id: str,
|
||||
city_rows: Dict[str, Dict[str, Any]],
|
||||
today_str: str,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
today_date = datetime.strptime(today_str, "%Y-%m-%d").date()
|
||||
recent_7_start = (today_date - timedelta(days=7)).isoformat()
|
||||
recent_14_start = (today_date - timedelta(days=14)).isoformat()
|
||||
end_date = (today_date - timedelta(days=1)).isoformat()
|
||||
rows = _build_city_deb_rows(city_id, city_rows, today_str)
|
||||
if not rows:
|
||||
return None
|
||||
recent_7d = _evaluate_deb_records(rows, start_date=recent_7_start, end_date=end_date)
|
||||
recent_14d = _evaluate_deb_records(rows, start_date=recent_14_start, end_date=end_date)
|
||||
return {
|
||||
"recent_7d": recent_7d,
|
||||
"recent_14d": recent_14d,
|
||||
**_build_deb_recent_trust_strategy(recent_7d, recent_14d),
|
||||
}
|
||||
|
||||
|
||||
def _build_city_deb_accuracy(city_id: str, city_rows: Dict[str, Dict[str, Any]], today_str: str) -> Optional[Dict[str, Any]]:
|
||||
rows = _build_city_deb_rows(city_id, city_rows, today_str)
|
||||
metrics = _evaluate_deb_records(rows)
|
||||
if not metrics["samples"]:
|
||||
return None
|
||||
@@ -2433,6 +2509,7 @@ def _build_training_accuracy_payload(
|
||||
for city_id, info in (city_registry or {}).items():
|
||||
city_rows = history.get(city_id) or history.get(str(city_id).strip().lower()) or {}
|
||||
deb_payload = _build_city_deb_accuracy(city_id, city_rows, today)
|
||||
deb_recent = _build_city_deb_recent_strategy(city_id, city_rows, today) if deb_payload else None
|
||||
mu_payload = _build_city_mu_accuracy(city_id, city_rows, today)
|
||||
if deb_payload or mu_payload:
|
||||
accuracy_data.append(
|
||||
@@ -2440,6 +2517,7 @@ def _build_training_accuracy_payload(
|
||||
"city_id": city_id,
|
||||
"name": (info or {}).get("name") or city_id,
|
||||
"deb": deb_payload,
|
||||
"deb_recent": deb_recent,
|
||||
"mu": mu_payload,
|
||||
}
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user