训练数据页面图表化:KPI 概览、DEB/概率命中率柱状图、MAE/Brier Score 可视化
This commit is contained in:
@@ -1,13 +1,17 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { RefreshCcw } from "lucide-react";
|
import { RefreshCcw, TrendingUp, TrendingDown, Target, Activity } from "lucide-react";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { opsApi } from "@/lib/ops-api";
|
import { opsApi } from "@/lib/ops-api";
|
||||||
import type { SystemStatusPayload } from "@/types/ops";
|
import type { SystemStatusPayload } from "@/types/ops";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import {
|
||||||
|
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
|
||||||
|
ComposedChart, Line, Legend, Cell, LabelList,
|
||||||
|
} from "recharts";
|
||||||
|
|
||||||
function StatRow({ label, value }: { label: string; value: React.ReactNode }) {
|
function StatRow({ label, value }: { label: string; value: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
@@ -18,6 +22,26 @@ function StatRow({ label, value }: { label: string; value: React.ReactNode }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function KpiCard({ label, value, sub, icon: Icon, color }: {
|
||||||
|
label: string; value: string | number; sub?: string;
|
||||||
|
icon: React.ComponentType<{ className?: string }>; color: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-slate-900/60 border-white/5">
|
||||||
|
<CardContent className="p-4 flex items-center gap-4">
|
||||||
|
<div className={`p-3 rounded-xl ${color}`}>
|
||||||
|
<Icon className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-2xl font-bold text-white">{value}</div>
|
||||||
|
<div className="text-xs text-slate-400">{label}</div>
|
||||||
|
{sub ? <div className="text-[10px] text-slate-500 mt-0.5">{sub}</div> : null}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
interface CityAccuracy {
|
interface CityAccuracy {
|
||||||
city_id: string;
|
city_id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -36,6 +60,37 @@ interface CityAccuracy {
|
|||||||
} | null;
|
} | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const CHART_COLORS = {
|
||||||
|
green: "#22c55e",
|
||||||
|
yellow: "#eab308",
|
||||||
|
red: "#ef4444",
|
||||||
|
blue: "#3b82f6",
|
||||||
|
cyan: "#06b6d4",
|
||||||
|
purple: "#a855f7",
|
||||||
|
slate: "#64748b",
|
||||||
|
emerald: "#10b981",
|
||||||
|
amber: "#f59e0b",
|
||||||
|
rose: "#f43f5e",
|
||||||
|
};
|
||||||
|
|
||||||
|
function hitColor(hitRate: number) {
|
||||||
|
if (hitRate >= 80) return CHART_COLORS.green;
|
||||||
|
if (hitRate >= 60) return CHART_COLORS.yellow;
|
||||||
|
return CHART_COLORS.red;
|
||||||
|
}
|
||||||
|
|
||||||
|
function maeColor(mae: number) {
|
||||||
|
if (mae <= 1.5) return CHART_COLORS.green;
|
||||||
|
if (mae <= 2.5) return CHART_COLORS.yellow;
|
||||||
|
return CHART_COLORS.red;
|
||||||
|
}
|
||||||
|
|
||||||
|
function brierColor(score: number) {
|
||||||
|
if (score <= 0.1) return CHART_COLORS.green;
|
||||||
|
if (score <= 0.25) return CHART_COLORS.yellow;
|
||||||
|
return CHART_COLORS.red;
|
||||||
|
}
|
||||||
|
|
||||||
export function TrainingPageClient() {
|
export function TrainingPageClient() {
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [status, setStatus] = useState<SystemStatusPayload | null>(null);
|
const [status, setStatus] = useState<SystemStatusPayload | null>(null);
|
||||||
@@ -46,16 +101,55 @@ export function TrainingPageClient() {
|
|||||||
try {
|
try {
|
||||||
const [s, accData] = await Promise.all([
|
const [s, accData] = await Promise.all([
|
||||||
opsApi.systemStatus() as Promise<SystemStatusPayload>,
|
opsApi.systemStatus() as Promise<SystemStatusPayload>,
|
||||||
opsApi.trainingAccuracy().catch(() => ({ accuracy: [] }))
|
opsApi.trainingAccuracy().catch(() => ({ accuracy: [] as CityAccuracy[] })),
|
||||||
]);
|
]);
|
||||||
setStatus(s);
|
setStatus(s);
|
||||||
setAccuracy(accData.accuracy);
|
setAccuracy((accData as { accuracy: CityAccuracy[] }).accuracy ?? []);
|
||||||
} catch { /* */ }
|
} catch { /* */ }
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => { void load(); }, []);
|
useEffect(() => { void load(); }, []);
|
||||||
|
|
||||||
|
const kpis = useMemo(() => {
|
||||||
|
if (!accuracy?.length) return null;
|
||||||
|
const debCities = accuracy.filter((c) => c.deb);
|
||||||
|
const avgHit = debCities.reduce((s, c) => s + (c.deb?.hit_rate ?? 0), 0) / debCities.length;
|
||||||
|
const avgMae = debCities.reduce((s, c) => s + (c.deb?.mae ?? 0), 0) / debCities.length;
|
||||||
|
const best = debCities.reduce((a, b) => ((a.deb?.hit_rate ?? 0) > (b.deb?.hit_rate ?? 0) ? a : b));
|
||||||
|
const worst = debCities.reduce((a, b) => ((a.deb?.mae ?? 0) > (b.deb?.mae ?? 0) ? a : b));
|
||||||
|
return { avgHit, avgMae, best, worst };
|
||||||
|
}, [accuracy]);
|
||||||
|
|
||||||
|
const debChartData = useMemo(() => {
|
||||||
|
if (!accuracy?.length) return [];
|
||||||
|
return accuracy
|
||||||
|
.filter((c) => c.deb && c.deb.total_days >= 5)
|
||||||
|
.sort((a, b) => (b.deb?.hit_rate ?? 0) - (a.deb?.hit_rate ?? 0))
|
||||||
|
.map((c) => ({
|
||||||
|
name: c.name,
|
||||||
|
cityId: c.city_id,
|
||||||
|
hitRate: Number((c.deb?.hit_rate ?? 0).toFixed(1)),
|
||||||
|
mae: Number((c.deb?.mae ?? 0).toFixed(1)),
|
||||||
|
days: c.deb?.total_days ?? 0,
|
||||||
|
}));
|
||||||
|
}, [accuracy]);
|
||||||
|
|
||||||
|
const muChartData = useMemo(() => {
|
||||||
|
if (!accuracy?.length) return [];
|
||||||
|
return accuracy
|
||||||
|
.filter((c) => c.mu && c.mu.total_days >= 5 && c.mu.brier_score !== null)
|
||||||
|
.sort((a, b) => ((a.mu?.brier_score ?? 1) - (b.mu?.brier_score ?? 1)))
|
||||||
|
.map((c) => ({
|
||||||
|
name: c.name,
|
||||||
|
cityId: c.city_id,
|
||||||
|
brierScore: Number((c.mu?.brier_score ?? 0).toFixed(4)),
|
||||||
|
hitRate: Number((c.mu?.hit_rate ?? 0).toFixed(1)),
|
||||||
|
mae: Number((c.mu?.mae ?? 0).toFixed(1)),
|
||||||
|
days: c.mu?.total_days ?? 0,
|
||||||
|
}));
|
||||||
|
}, [accuracy]);
|
||||||
|
|
||||||
if (loading) return <div className="text-slate-400 animate-pulse">加载中...</div>;
|
if (loading) return <div className="text-slate-400 animate-pulse">加载中...</div>;
|
||||||
if (!status) return <div className="text-red-400">加载失败</div>;
|
if (!status) return <div className="text-red-400">加载失败</div>;
|
||||||
|
|
||||||
@@ -74,6 +168,7 @@ export function TrainingPageClient() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Data volume KPIs */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader><CardTitle>真值记录</CardTitle></CardHeader>
|
<CardHeader><CardTitle>真值记录</CardTitle></CardHeader>
|
||||||
@@ -101,6 +196,141 @@ export function TrainingPageClient() {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Accuracy KPI row */}
|
||||||
|
{kpis ? (
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
<KpiCard
|
||||||
|
icon={Target} color="bg-cyan-500/20 text-cyan-400"
|
||||||
|
label="DEB 平均命中率" value={`${kpis.avgHit.toFixed(1)}%`}
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
icon={Activity} color="bg-blue-500/20 text-blue-400"
|
||||||
|
label="DEB 平均 MAE" value={`${kpis.avgMae.toFixed(1)}°`}
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
icon={TrendingUp} color="bg-emerald-500/20 text-emerald-400"
|
||||||
|
label="最佳城市" value={kpis.best.name}
|
||||||
|
sub={`命中 ${kpis.best.deb?.hit_rate.toFixed(0)}% · MAE ${kpis.best.deb?.mae.toFixed(1)}°`}
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
icon={TrendingDown} color="bg-rose-500/20 text-rose-400"
|
||||||
|
label="最大偏差" value={kpis.worst.name}
|
||||||
|
sub={`MAE ${kpis.worst.deb?.mae.toFixed(1)}° · ${kpis.worst.deb?.total_days}天`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* DEB Accuracy Charts */}
|
||||||
|
{debChartData.length > 0 ? (
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader><CardTitle>DEB 命中率 by 城市</CardTitle></CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="h-[400px]">
|
||||||
|
<ResponsiveContainer>
|
||||||
|
<BarChart data={debChartData} margin={{ top: 8, right: 8, left: 8, bottom: 80 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.06)" />
|
||||||
|
<XAxis dataKey="name" angle={-45} textAnchor="end" tick={{ fill: "#94a3b8", fontSize: 11 }} interval={0} />
|
||||||
|
<YAxis domain={[0, 100]} tick={{ fill: "#94a3b8", fontSize: 11 }} unit="%" />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{ background: "#1e293b", border: "1px solid rgba(255,255,255,0.1)", borderRadius: 8, color: "#e2e8f0", fontSize: 12 }}
|
||||||
|
formatter={(value: unknown) => [`${Number(value).toFixed(1)}%`, "命中率"]}
|
||||||
|
/>
|
||||||
|
<Bar dataKey="hitRate" radius={[4, 4, 0, 0]} maxBarSize={36}>
|
||||||
|
{debChartData.map((entry, i) => (
|
||||||
|
<Cell key={i} fill={hitColor(entry.hitRate)} fillOpacity={0.85} />
|
||||||
|
))}
|
||||||
|
<LabelList dataKey="hitRate" position="top" style={{ fill: "#94a3b8", fontSize: 10 }} formatter={(v: unknown) => `${Number(v)}%`} />
|
||||||
|
</Bar>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader><CardTitle>DEB MAE by 城市</CardTitle></CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="h-[400px]">
|
||||||
|
<ResponsiveContainer>
|
||||||
|
<ComposedChart data={debChartData} margin={{ top: 8, right: 8, left: 8, bottom: 80 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.06)" />
|
||||||
|
<XAxis dataKey="name" angle={-45} textAnchor="end" tick={{ fill: "#94a3b8", fontSize: 11 }} interval={0} />
|
||||||
|
<YAxis tick={{ fill: "#94a3b8", fontSize: 11 }} unit="°" />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{ background: "#1e293b", border: "1px solid rgba(255,255,255,0.1)", borderRadius: 8, color: "#e2e8f0", fontSize: 12 }}
|
||||||
|
formatter={(value: unknown) => [`${Number(value).toFixed(1)}°`, "MAE"]}
|
||||||
|
/>
|
||||||
|
<Bar dataKey="mae" radius={[4, 4, 0, 0]} maxBarSize={36}>
|
||||||
|
{debChartData.map((entry, i) => (
|
||||||
|
<Cell key={i} fill={maeColor(entry.mae)} fillOpacity={0.85} />
|
||||||
|
))}
|
||||||
|
<LabelList dataKey="mae" position="top" style={{ fill: "#94a3b8", fontSize: 10 }} formatter={(v: unknown) => `${Number(v)}°`} />
|
||||||
|
</Bar>
|
||||||
|
</ComposedChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Mu Probability Charts */}
|
||||||
|
{muChartData.length > 0 ? (
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader><CardTitle>概率 μ Brier Score by 城市</CardTitle></CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="h-[400px]">
|
||||||
|
<ResponsiveContainer>
|
||||||
|
<BarChart data={muChartData} margin={{ top: 8, right: 8, left: 8, bottom: 80 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.06)" />
|
||||||
|
<XAxis dataKey="name" angle={-45} textAnchor="end" tick={{ fill: "#94a3b8", fontSize: 11 }} interval={0} />
|
||||||
|
<YAxis domain={[0, 0.5]} tick={{ fill: "#94a3b8", fontSize: 11 }} />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{ background: "#1e293b", border: "1px solid rgba(255,255,255,0.1)", borderRadius: 8, color: "#e2e8f0", fontSize: 12 }}
|
||||||
|
formatter={(value: unknown) => [Number(value).toFixed(4), "Brier Score"]}
|
||||||
|
/>
|
||||||
|
<Bar dataKey="brierScore" radius={[4, 4, 0, 0]} maxBarSize={36}>
|
||||||
|
{muChartData.map((entry, i) => (
|
||||||
|
<Cell key={i} fill={brierColor(entry.brierScore)} fillOpacity={0.85} />
|
||||||
|
))}
|
||||||
|
<LabelList dataKey="brierScore" position="top" style={{ fill: "#94a3b8", fontSize: 10 }} formatter={(v: unknown) => Number(v).toFixed(3)} />
|
||||||
|
</Bar>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader><CardTitle>概率 μ 命中率 by 城市</CardTitle></CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="h-[400px]">
|
||||||
|
<ResponsiveContainer>
|
||||||
|
<BarChart data={muChartData} margin={{ top: 8, right: 8, left: 8, bottom: 80 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.06)" />
|
||||||
|
<XAxis dataKey="name" angle={-45} textAnchor="end" tick={{ fill: "#94a3b8", fontSize: 11 }} interval={0} />
|
||||||
|
<YAxis domain={[0, 100]} tick={{ fill: "#94a3b8", fontSize: 11 }} unit="%" />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{ background: "#1e293b", border: "1px solid rgba(255,255,255,0.1)", borderRadius: 8, color: "#e2e8f0", fontSize: 12 }}
|
||||||
|
formatter={(value: unknown) => [`${Number(value).toFixed(1)}%`, "命中率"]}
|
||||||
|
/>
|
||||||
|
<Bar dataKey="hitRate" radius={[4, 4, 0, 0]} maxBarSize={36}>
|
||||||
|
{muChartData.map((entry, i) => (
|
||||||
|
<Cell key={i} fill={hitColor(entry.hitRate)} fillOpacity={0.85} />
|
||||||
|
))}
|
||||||
|
<LabelList dataKey="hitRate" position="top" style={{ fill: "#94a3b8", fontSize: 10 }} formatter={(v: unknown) => `${Number(v)}%`} />
|
||||||
|
</Bar>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* City coverage */}
|
||||||
{modelCities ? (
|
{modelCities ? (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<Card>
|
<Card>
|
||||||
@@ -133,9 +363,10 @@ export function TrainingPageClient() {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{/* Detail table */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>模型融合与预测准确率效果 (DEB & 概率引擎)</CardTitle>
|
<CardTitle>模型融合与预测准确率详情</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
@@ -143,13 +374,13 @@ export function TrainingPageClient() {
|
|||||||
<thead className="text-xs uppercase bg-slate-800/50 text-slate-400">
|
<thead className="text-xs uppercase bg-slate-800/50 text-slate-400">
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="col" className="px-4 py-3">城市</th>
|
<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">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 MAE</th>
|
||||||
<th scope="col" className="px-4 py-3 text-center">DEB 天数</th>
|
<th scope="col" className="px-4 py-3 text-center">DEB 天数</th>
|
||||||
<th scope="col" className="px-4 py-3 text-center">概率 μ 结算命中</th>
|
<th scope="col" className="px-4 py-3 text-center">μ 命中</th>
|
||||||
<th scope="col" className="px-4 py-3 text-center">概率 μ MAE</th>
|
<th scope="col" className="px-4 py-3 text-center">μ MAE</th>
|
||||||
<th scope="col" className="px-4 py-3 text-center">Brier Score</th>
|
<th scope="col" className="px-4 py-3 text-center">Brier</th>
|
||||||
<th scope="col" className="px-4 py-3 text-center">概率天数</th>
|
<th scope="col" className="px-4 py-3 text-center">μ 天数</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-white/5">
|
<tbody className="divide-y divide-white/5">
|
||||||
@@ -222,7 +453,7 @@ export function TrainingPageClient() {
|
|||||||
) : (
|
) : (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={8} className="px-4 py-8 text-center text-slate-500">
|
<td colSpan={8} className="px-4 py-8 text-center text-slate-500">
|
||||||
无有效准确率记录,请确认 daily_records.json 中已有历史结算数据。
|
无有效准确率记录
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
@@ -246,4 +477,3 @@ export function TrainingPageClient() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user