first commit

This commit is contained in:
2025-09-24 11:35:48 +08:00
commit 9b77ae5cbe
33 changed files with 135301 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
uv run pendulum_transformer.py --csv data/448.csv data/618.csv data/548.csv \
--epochs 10 --batch 64 --eval_all\
--s_in 120 --s_out 30 --stride 5 \
--ckpt ckpt/pendulum_tf_base.pt
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+116
View File
@@ -0,0 +1,116 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
几何与坐标变换工具:
- 基于最小二乘的圆拟合
- 像素坐标 <-> 单位圆 (sin, cos) 变换
与训练脚本的定义保持一致:
theta = atan2(x - cx, cy - y)
sin = sin(theta) = (x - cx) / r
cos = cos(theta) = (cy - y) / r = -(y - cy) / r
因此,反变换:
x = cx + r * sin
y = cy - r * cos
"""
from __future__ import annotations
import math
import numpy as np
from typing import Tuple
def fit_circle_lstsq(x: np.ndarray, y: np.ndarray) -> Tuple[float, float, float]:
"""最小二乘拟合圆:解 x^2 + y^2 = 2 a x + 2 b y + c
返回 (cx, cy, r)
"""
x = np.asarray(x, dtype=np.float64)
y = np.asarray(y, dtype=np.float64)
A = np.c_[2 * x, 2 * y, np.ones_like(x)]
b = x * x + y * y
sol, *_ = np.linalg.lstsq(A, b, rcond=None)
a, b_, c = sol
cx, cy = a, b_
r = math.sqrt(max(cx * cx + cy * cy + c, 0.0))
return float(cx), float(cy), float(r)
def _estimate_center_radius(seq_xy: np.ndarray) -> Tuple[float, float, float]:
"""对单条序列估计 (cx, cy, r)。
- 当样本点 >=3 时使用最小二乘拟合
- 否则使用均值点作为中心、均距为半径;若半径过小,则回退为 1.0
"""
seq_xy = np.asarray(seq_xy, dtype=np.float64)
S = seq_xy.shape[0]
if S >= 3:
cx, cy, r = fit_circle_lstsq(seq_xy[:, 0], seq_xy[:, 1])
else:
cx, cy = np.mean(seq_xy, axis=0)
d = np.linalg.norm(seq_xy - np.array([cx, cy], dtype=np.float64), axis=1)
r = float(np.median(d) if d.size else 1.0)
r = float(max(r, 1e-6))
return float(cx), float(cy), r
def pixels_to_unit_sincos(seq_xy: np.ndarray) -> Tuple[np.ndarray, Tuple[float, float, float]]:
"""将单条像素轨迹 [S,2] 转为单位 (sin,cos) [S,2],并返回 (cx,cy,r)。"""
seq_xy = np.asarray(seq_xy, dtype=np.float64)
cx, cy, r = _estimate_center_radius(seq_xy)
x = seq_xy[:, 0]
y = seq_xy[:, 1]
# 与训练一致的角度定义
theta = np.arctan2(x - cx, cy - y)
sc = np.stack([np.sin(theta), np.cos(theta)], axis=-1).astype(np.float32)
return sc, (float(cx), float(cy), float(r))
def batch_pixels_to_unit_sincos(batch_xy: np.ndarray) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
"""批量像素 -> 单位 (sin,cos)。
参数:
batch_xy: [B,S,2] 像素坐标
返回:
sc: [B,S,2] 单位 (sin,cos)
centers: [B,2] (cx,cy)
radii: [B] r
"""
batch_xy = np.asarray(batch_xy, dtype=np.float64)
B, S, _ = batch_xy.shape
sc = np.empty((B, S, 2), dtype=np.float32)
centers = np.empty((B, 2), dtype=np.float32)
radii = np.empty((B,), dtype=np.float32)
for i in range(B):
seq = batch_xy[i]
sc_i, (cx, cy, r) = pixels_to_unit_sincos(seq)
sc[i] = sc_i
centers[i] = (cx, cy)
radii[i] = r
return sc, centers, radii
def unit_sincos_to_pixels(sc: np.ndarray, centers: np.ndarray, radii: np.ndarray) -> np.ndarray:
"""单位 (sin,cos) -> 像素坐标。
支持广播:
sc: [B,S,2]
centers: [B,2]
radii: [B] or [B,1]
返回:
pix: [B,S,2]
"""
sc = np.asarray(sc, dtype=np.float64)
centers = np.asarray(centers, dtype=np.float64)
radii = np.asarray(radii, dtype=np.float64).reshape(-1, 1, 1) # [B,1,1]
sin = sc[..., 0]
cos = sc[..., 1]
cx = centers[:, 0].reshape(-1, 1)
cy = centers[:, 1].reshape(-1, 1)
x = cx + radii[:, 0, 0:1] * sin # 广播 [B,1] * [B,S]
y = cy - radii[:, 0, 0:1] * cos
pix = np.stack([x, y], axis=-1)
return pix.astype(np.float32)
+479
View File
@@ -0,0 +1,479 @@
# pendulum_transformer.py
import argparse, math, os
import numpy as np
import pandas as pd
import torch
import torch.nn as nn
from torch.utils.data import Dataset, DataLoader
import matplotlib.pyplot as plt
# ============ 工具函数 ============
def moving_average(a, w=5):
if w <= 1: return a
pad = np.pad(a, (w//2, w-1-w//2), mode='edge')
k = np.ones(w)/w
return np.convolve(pad, k, mode='valid')
def fit_circle_lstsq(x, y):
# 解: x^2 + y^2 = 2 a x + 2 b y + c
A = np.c_[2*x, 2*y, np.ones_like(x)]
b = x**2 + y**2
sol, *_ = np.linalg.lstsq(A, b, rcond=None)
a, b_, c = sol
cx, cy = a, b_
r = math.sqrt(max(cx*cx + cy*cy + c, 0.0))
return cx, cy, r
def wrap_to_pi(a):
return (a + np.pi) % (2*np.pi) - np.pi
def build_windows(arr, s_in=120, s_out=30, stride=10):
# arr: [T, F]
T, F = arr.shape
X, Y, t_idx = [], [], []
for t in range(0, T - s_in - s_out + 1, stride):
X.append(arr[t:t+s_in])
Y.append(arr[t+s_in:t+s_in+s_out])
t_idx.append(t)
return np.array(X), np.array(Y), np.array(t_idx) # [N, S, F], [N, S, F], [N]
def sincos_to_angle(s, c):
return np.arctan2(s, c)
def angle_rmse(pred_sc, true_sc):
# pred_sc, true_sc: [..., 2] -> (sin, cos)
ps, pc = pred_sc[...,0], pred_sc[...,1]
ts, tc = true_sc[...,0], true_sc[...,1]
pa = np.arctan2(ps, pc)
ta = np.arctan2(ts, tc)
diff = wrap_to_pi(pa - ta)
return np.sqrt(np.mean(diff**2))
def split_even_indices(n, ratios=(0.7, 0.15, 0.15), block=50, seed=123):
"""将 [0..n-1] 均匀地分成三部分(train/val/test)"""
if n <= 0:
return np.array([], dtype=int), np.array([], dtype=int), np.array([], dtype=int)
probs = np.array(ratios, dtype=np.float64)
s = probs.sum()
probs = probs / (s if s > 0 else 1.0)
rng = np.random.default_rng(seed)
idxs = np.arange(n)
tr, va, te = [], [], []
for start in range(0, n, block):
end = min(start + block, n)
m = end - start
target = probs * m
base = np.floor(target).astype(int)
rem = m - int(base.sum())
if rem > 0:
frac = target - base
order = np.argsort(-(frac + 1e-9 * rng.random(frac.shape)))
for k in order[:rem]:
base[k] += 1
local = idxs[start:end].copy()
rng.shuffle(local)
a, b, c = int(base[0]), int(base[1]), int(base[2])
tr.extend(local[:a]); va.extend(local[a:a+b]); te.extend(local[a+b:a+b+c])
return np.array(tr, dtype=int), np.array(va, dtype=int), np.array(te, dtype=int)
# ============ 数据集 ============
class SeqDataset(Dataset):
def __init__(self, X, Y, mu=None, std=None):
self.X = X.astype(np.float32)
self.Y = Y.astype(np.float32)
if mu is None or std is None:
mu = self.X.reshape(-1, self.X.shape[-1]).mean(0, keepdims=True)
std = self.X.reshape(-1, self.X.shape[-1]).std(0, keepdims=True) + 1e-8
self.mu = mu.astype(np.float32)
self.std = std.astype(np.float32)
def __len__(self): return self.X.shape[0]
def __getitem__(self, i):
x = (self.X[i] - self.mu) / self.std
y = (self.Y[i] - self.mu) / self.std
return torch.from_numpy(x), torch.from_numpy(y)
# ============ 位置编码 ============
class PositionalEncoding(nn.Module):
def __init__(self, d_model, max_len=4096):
super().__init__()
pe = torch.zeros(max_len, d_model)
pos = torch.arange(0, max_len, dtype=torch.float32).unsqueeze(1)
div = torch.exp(torch.arange(0, d_model, 2, dtype=torch.float32) * (-math.log(10000.0) / d_model))
pe[:, 0::2] = torch.sin(pos * div)
pe[:, 1::2] = torch.cos(pos * div)
self.register_buffer('pe', pe.unsqueeze(0)) # [1, L, D]
def forward(self, x):
return x + self.pe[:, :x.size(1), :]
# ============ 极简 Transformer ============
class TimeSeriesTransformer(nn.Module):
def __init__(self, in_dim=2, d_model=256, nhead=4, enc_layers=3, dec_layers=3, d_ff=512, dropout=0.1, out_dim=2):
super().__init__()
self.in_proj = nn.Linear(in_dim, d_model)
self.out_proj = nn.Linear(d_model, out_dim)
self.tgt_proj = nn.Linear(out_dim, d_model)
self.pos_enc = PositionalEncoding(d_model)
self.tf = nn.Transformer(
d_model=d_model, nhead=nhead,
num_encoder_layers=enc_layers, num_decoder_layers=dec_layers,
dim_feedforward=d_ff, dropout=dropout, batch_first=True
)
def forward(self, src, tgt_in, tgt_mask=None):
src = self.pos_enc(self.in_proj(src))
tgt = self.pos_enc(self.tgt_proj(tgt_in))
out = self.tf(src, tgt, tgt_mask=tgt_mask) # [B, S_out, D]
return self.out_proj(out)
# ============ 主流程 ============
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--csv", type=str, nargs='+', required=True, help="CSV files")
ap.add_argument("--split", type=float, nargs=3, default=[0.7, 0.15, 0.15])
ap.add_argument("--s_in", type=int, default=180)
ap.add_argument("--s_out", type=int, default=30)
ap.add_argument("--stride", type=int, default=10)
ap.add_argument("--epochs", type=int, default=10)
ap.add_argument("--batch", type=int, default=64)
ap.add_argument("--lr", type=float, default=1e-3)
ap.add_argument("--ckpt", type=str, default="ckpt/pendulum_tf_base.pt")
ap.add_argument("--eval_only", action="store_true")
# 新增:评估相关
ap.add_argument("--eval_all", action="store_true", help="Evaluate on the entire test set")
ap.add_argument("--eval_samples", type=int, default=6, help="How many random samples to plot from test set")
ap.add_argument("--seed", type=int, default=123, help="Random seed for eval sampling")
args = ap.parse_args()
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
# 规范化路径
def _norm(p: str) -> str:
return os.path.abspath(os.path.expanduser(p))
csv_list = sorted(set(_norm(p) for p in (args.csv or [])))
if not csv_list:
raise ValueError("No CSV files provided.")
ratios = np.array(args.split, dtype=np.float64)
s = ratios.sum()
ratios = ratios / (s if s > 0 else 1.0)
print(f"[Split] auto per-CSV even mode: files={len(csv_list)} | ratios train/val/test = {ratios.tolist()}")
Xtr_list, Ytr_list, Xva_list, Yva_list, Xte_list, Yte_list = [], [], [], [], [], []
for i, csv_path in enumerate(csv_list):
df = pd.read_csv(csv_path)
x = df['x'].to_numpy(dtype=np.float64)
y = df['y'].to_numpy(dtype=np.float64)
# 圆拟合 => 悬点
cx, cy, r = fit_circle_lstsq(x, y)
print(f"[{i+1}/{len(csv_list)}] {os.path.basename(csv_path)} pivot=({cx:.2f},{cy:.2f}), r~{r:.2f}")
# 像素 -> 角度;竖直向下为 0
theta = np.arctan2(x - cx, cy - y)
theta = np.unwrap(theta)
sin_t, cos_t = np.sin(theta), np.cos(theta)
feats = np.stack([sin_t, cos_t], axis=1) # [T, 2]
# 滑窗
Xi, Yi, _ = build_windows(feats, s_in=args.s_in, s_out=args.s_out, stride=args.stride)
if Xi.size == 0:
continue
tr_idx, va_idx, te_idx = split_even_indices(Xi.shape[0], ratios)
if tr_idx.size: Xtr_list.append(Xi[tr_idx]); Ytr_list.append(Yi[tr_idx])
if va_idx.size: Xva_list.append(Xi[va_idx]); Yva_list.append(Yi[va_idx])
if te_idx.size: Xte_list.append(Xi[te_idx]); Yte_list.append(Yi[te_idx])
def _concat(lst, steps):
if not lst:
return np.zeros((0, steps, 2), dtype=np.float32)
return np.concatenate(lst, axis=0).astype(np.float32)
Xtr = _concat(Xtr_list, args.s_in)
Ytr = _concat(Ytr_list, args.s_out)
Xva = _concat(Xva_list, args.s_in)
Yva = _concat(Yva_list, args.s_out)
Xte = _concat(Xte_list, args.s_in)
Yte = _concat(Yte_list, args.s_out)
if Xtr.shape[0] == 0: raise ValueError("No training samples produced.")
if Xva.shape[0] == 0: raise ValueError("No validation samples produced.")
if Xte.shape[0] == 0: raise ValueError("No test samples produced.")
# 标准化参数(仅基于训练集)
base = Xtr
mu = base.reshape(-1, base.shape[-1]).mean(0, keepdims=True).astype(np.float32)
std = base.reshape(-1, base.shape[-1]).std(0, keepdims=True).astype(np.float32) + 1e-8
dtr = SeqDataset(Xtr, Ytr, mu, std)
dva = SeqDataset(Xva, Yva, mu, std)
dte = SeqDataset(Xte, Yte, mu, std)
nw = 2
Ltr = DataLoader(dtr, batch_size=args.batch, shuffle=True, drop_last=True, pin_memory=True, num_workers=nw)
Lva = DataLoader(dva, batch_size=args.batch, shuffle=False, pin_memory=True, num_workers=nw)
Lte = DataLoader(dte, batch_size=args.batch, shuffle=False, pin_memory=True, num_workers=nw)
cfg = dict(d_model=256, nhead=4, enc_layers=3, dec_layers=3, d_ff=512)
net = TimeSeriesTransformer(in_dim=2, out_dim=2, **cfg).to(device)
mse = nn.MSELoss()
opt = torch.optim.AdamW(net.parameters(), lr=args.lr, weight_decay=1e-4)
# ---- warmup + cosine 调度(按 step)----
total_steps = max(1, args.epochs * len(Ltr))
warmup = min(500, max(1, total_steps // 10))
def lr_lambda(step):
if step < warmup:
return float(step + 1) / float(warmup)
progress = (step - warmup) / max(1, total_steps - warmup)
cosine = 0.5 * (1.0 + math.cos(math.pi * progress))
min_factor = 0.01
return min_factor + (1 - min_factor) * cosine
scheduler = torch.optim.lr_scheduler.LambdaLR(opt, lr_lambda=lr_lambda)
# === 标准化 <-> 原始空间 转换 & 单位化 ===
mu_t = torch.from_numpy(mu).to(device) # [1, 2]
std_t = torch.from_numpy(std).to(device) # [1, 2]
def to_raw(z): return z * std_t + mu_t
def to_std(z): return (z - mu_t) / std_t
def unitize(z, eps=1e-6):
n = torch.clamp(torch.linalg.norm(z, dim=-1, keepdim=True), min=eps)
return z / n
# === 预测一个 batch 的自回归函数(返回 raw 空间预测) ===
def rollout_raw(xb_std, steps):
# xb_std: [B, S_in, 2] 标准化空间
tgt_tokens = xb_std[:, -1:, :2].clone() # 起始 token(标准化空间)
for _ in range(steps):
mask = nn.Transformer.generate_square_subsequent_mask(tgt_tokens.size(1)).to(device)
out = net(xb_std, tgt_tokens, tgt_mask=mask) # 标准化
next_std = out[:, -1:, :]
next_raw = to_raw(next_std) # -> 原始
next_raw = unitize(next_raw) # 单位圆约束
next_std = to_std(next_raw) # -> 标准化(与训练分布一致)
tgt_tokens = torch.cat([tgt_tokens, next_std], dim=1)
pred_std = tgt_tokens[:, 1:, :] # [B, steps, 2] 标准化
return to_raw(pred_std) # -> 原始
# === 改动一:闭环 rollout 损失所需的角度/方向损失 ===
def cosine_ang_loss(pred_sc_raw, true_sc_raw, eps=1e-6):
"""
pred_sc_raw/true_sc_raw: [B,S,2],分别是 RAW 空间下的 (sin,cos)
计算 1 - cos(Δθ) 的平均值,忽略向量长度,只关注方向(角度)差。
"""
pu = pred_sc_raw / torch.clamp(torch.linalg.norm(pred_sc_raw, dim=-1, keepdim=True), min=eps)
tu = true_sc_raw / torch.clamp(torch.linalg.norm(true_sc_raw, dim=-1, keepdim=True), min=eps)
return (1.0 - (pu * tu).sum(dim=-1)).mean()
# === 训练 or 加载 ===
os.makedirs(os.path.dirname(args.ckpt) or ".", exist_ok=True)
if args.eval_only:
if not os.path.exists(args.ckpt):
raise FileNotFoundError(f"Checkpoint not found: {args.ckpt}. 请先训练一次或指定正确路径。")
# 关键修复:显式关闭 weights_only 的安全反序列化限制(你信任本地 ckpt)
payload = torch.load(args.ckpt, map_location=device, weights_only=False)
# 若 ckpt 中包含 cfg,用 ckpt 的配置重建网络,避免结构不一致
ckpt_cfg = payload.get("cfg", None)
if ckpt_cfg is not None:
# 如果与当前构造的不同,则重建
if ckpt_cfg != cfg:
net = TimeSeriesTransformer(in_dim=2, out_dim=2, **ckpt_cfg).to(device)
net.load_state_dict(payload["model"], strict=True)
mu = payload["mu"]; std = payload["std"]
mu_t = torch.from_numpy(mu).to(device); std_t = torch.from_numpy(std).to(device)
print(f"Loaded checkpoint from {args.ckpt}")
else:
# Sanity:原始空间下 ||yb|| 应 ~1
with torch.no_grad():
xb0, yb0 = next(iter(Ltr))
m_norm_raw = torch.linalg.norm(to_raw(yb0.to(device)), dim=-1).mean().item()
print(f"[Sanity] mean ||yb|| in RAW space ~= {m_norm_raw:.4f}")
best_val = float("inf")
global_step = 0
for ep in range(1, args.epochs+1):
net.train()
total = 0.0
total_tf = 0.0
total_roll = 0.0
# ρ:闭环损失权重(线性升到 1.0;如需更稳可改为升到 0.5)
rho = min(1.0, ep / max(1, args.epochs // 2))
for xb, yb in Ltr:
xb = xb.to(device, non_blocking=True); yb = yb.to(device, non_blocking=True)
B, S, F = yb.shape
# ---------- (A) Teacher Forcing 分支(与原来一致) ----------
x_last = xb[:, -1:, :F]
tgt_in = torch.cat([x_last, yb[:, :-1, :]], dim=1)
mask = nn.Transformer.generate_square_subsequent_mask(S).to(device)
pred_tf = net(xb, tgt_in, tgt_mask=mask) # 标准化空间
pred_tf_u = unitize(to_raw(pred_tf)) # 原始空间单位圆(只对方向约束)
yb_u = unitize(to_raw(yb))
loss_tf = mse(pred_tf_u, yb_u)
# ---------- (B) 闭环 rollout 分支(改动一的核心) ----------
with torch.no_grad():
y_true_raw = to_raw(yb) # RAW,用于对齐真值方向
y_pred_raw = rollout_raw(xb, steps=S) # RAW,模型自回归滚动预测
loss_roll = cosine_ang_loss(y_pred_raw, y_true_raw)
# ---------- 合成总损失 ----------
loss = (1.0 - rho) * loss_tf + rho * loss_roll
opt.zero_grad(set_to_none=True)
loss.backward()
torch.nn.utils.clip_grad_norm_(net.parameters(), 1.0)
opt.step()
scheduler.step()
total += loss.item() * xb.size(0)
total_tf += loss_tf.item() * xb.size(0)
total_roll += loss_roll.item() * xb.size(0)
global_step += 1
tr_loss = total / len(dtr)
tr_tf = total_tf / len(dtr)
tr_rl = total_roll / len(dtr)
# 验证:保持原先的 TF-式度量(也可扩展加闭环验证,这里先不改动)
net.eval()
with torch.no_grad():
total_v = 0.0
for xb, yb in Lva:
xb = xb.to(device, non_blocking=True); yb = yb.to(device, non_blocking=True)
x_last = xb[:, -1:, :2]
tgt_in = torch.cat([x_last, yb[:, :-1, :]], dim=1)
mask = nn.Transformer.generate_square_subsequent_mask(yb.size(1)).to(device)
pred = net(xb, tgt_in, tgt_mask=mask)
pred_u = unitize(to_raw(pred))
yb_u = unitize(to_raw(yb))
total_v += mse(pred_u, yb_u).item() * xb.size(0)
va_loss = total_v / len(dva)
cur_lr = scheduler.get_last_lr()[0]
print(f"[Epoch {ep:02d}] ρ={rho:.2f} | train TOTAL={tr_loss:.6f} (TF={tr_tf:.6f}, ROLL={tr_rl:.6f}) | val MSE(u_raw)={va_loss:.6f} | lr={cur_lr:.6e}")
if not np.isnan(va_loss) and va_loss < best_val:
best_val = va_loss
payload = {"model": net.state_dict(), "mu": mu, "std": std, "cfg": cfg}
torch.save(payload, args.ckpt)
print(f"Saved checkpoint -> {args.ckpt}")
# ===== 评估 =====
net.eval()
rng = np.random.default_rng(args.seed)
with torch.no_grad():
# --- 1) 全量评估(可选) ---
if args.eval_all:
steps = args.s_out
sum_sq = 0.0
n_total = 0
per_step_sq = np.zeros(steps, dtype=np.float64)
total_samples = 0
for xb, yb in Lte:
xb = xb.to(device, non_blocking=True); yb = yb.to(device, non_blocking=True)
y_pred_raw = rollout_raw(xb, steps) # [B, steps, 2] raw
y_true_raw = to_raw(yb) # [B, steps, 2] raw
y_pred_np = y_pred_raw.cpu().numpy()
y_true_np = y_true_raw.cpu().numpy()
# 单位圆 -> 角度误差
n1 = np.clip(np.linalg.norm(y_pred_np, axis=-1, keepdims=True), 1e-6, None)
n2 = np.clip(np.linalg.norm(y_true_np, axis=-1, keepdims=True), 1e-6, None)
y_pred_u = y_pred_np / n1
y_true_u = y_true_np / n2
pa = np.arctan2(y_pred_u[...,0], y_pred_u[...,1]) # 注意顺序 (sin,cos)
ta = np.arctan2(y_true_u[...,0], y_true_u[...,1])
diff = wrap_to_pi(pa - ta) # [B, steps]
sum_sq += np.sum(diff**2)
per_step_sq += np.sum(diff**2, axis=0)
n_total += diff.size
total_samples += diff.shape[0]
overall_rmse = math.sqrt(sum_sq / n_total)
per_step_rmse = np.sqrt(per_step_sq / max(1, total_samples))
print(f"[Test:ALL] overall angle RMSE = {overall_rmse:.6f} rad ({overall_rmse*180/np.pi:.3f} deg)")
# 保存每步 RMSE 曲线
ts = np.arange(1, steps+1)
plt.figure(figsize=(6,4))
plt.plot(ts, per_step_rmse * 180/np.pi, marker='o')
plt.xlabel("horizon (step)"); plt.ylabel("RMSE (deg)")
plt.title("Per-step angle RMSE on test set")
plt.grid(True, linestyle="--", alpha=0.5)
plt.tight_layout()
plt.savefig("rmse_per_step.png", dpi=150)
print("Saved plot -> rmse_per_step.png")
# --- 2) 随机抽样 K 个样本可视化 ---
X_list, Y_list = [], []
for xb, yb in Lte:
X_list.append(xb); Y_list.append(yb)
X_all = torch.cat(X_list, dim=0).to(device, non_blocking=True)
Y_all = torch.cat(Y_list, dim=0).to(device, non_blocking=True)
N = X_all.size(0)
K = min(max(0, args.eval_samples), N)
if K > 0:
idxs = rng.choice(N, size=K, replace=False)
steps = Y_all.size(1)
y_pred_raw_all = rollout_raw(X_all[idxs], steps) # [K, steps, 2]
y_true_raw_all = to_raw(Y_all[idxs]) # [K, steps, 2]
def unit_np(z):
n = np.linalg.norm(z, axis=-1, keepdims=True)
n = np.clip(n, 1e-6, None)
return z / n
y_pred_np = y_pred_raw_all.cpu().numpy()
y_true_np = y_true_raw_all.cpu().numpy()
y_pred_u = unit_np(y_pred_np)
y_true_u = unit_np(y_true_np)
for j in range(K):
ya = np.arctan2(y_true_u[j,:,0], y_true_u[j,:,1])
pa = np.arctan2(y_pred_u[j,:,0], y_pred_u[j,:,1])
pa_aligned = pa + 2*np.pi * np.round((ya - pa)/(2*np.pi))
plt.figure(figsize=(8,4))
plt.plot(np.arange(steps), ya, label="true θ")
plt.plot(np.arange(steps), pa_aligned, '--', label="pred θ (aligned)")
plt.xlabel("future step"); plt.ylabel("angle (rad)")
plt.title(f"{steps}-step forecast (test sample {j})")
plt.legend(); plt.tight_layout()
out_png = f"pred_plot_sample_{j}.png"
plt.savefig(out_png, dpi=150)
plt.close()
print(f"Saved plot -> {out_png}")
# 如果没开 --eval_all,也输出一个“单 batch”对齐的整体 RMSE 以免落差太大
if not args.eval_all:
steps = args.s_out
xb, yb = X_all[:args.batch], Y_all[:args.batch]
y_pred_raw = rollout_raw(xb, steps)
y_true_raw = to_raw(yb)
y_pred_np = y_pred_raw.cpu().numpy()
y_true_np = y_true_raw.cpu().numpy()
n1 = np.clip(np.linalg.norm(y_pred_np, axis=-1, keepdims=True), 1e-6, None)
n2 = np.clip(np.linalg.norm(y_true_np, axis=-1, keepdims=True), 1e-6, None)
y_pred_u = y_pred_np / n1; y_true_u = y_true_np / n2
pa = np.arctan2(y_pred_u[...,0], y_pred_u[...,1])
ta = np.arctan2(y_true_u[...,0], y_true_u[...,1])
diff = wrap_to_pi(pa - ta)
rmse = math.sqrt(np.mean(diff**2))
print(f"[Test:Mini] angle RMSE = {rmse:.6f} rad ({rmse*180/np.pi:.3f} deg)")
if __name__ == "__main__":
main()
+238
View File
@@ -0,0 +1,238 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
启动一个仅 CUDA 的 HTTP 推理服务,输入/输出均为像素坐标。
内部将像素轨迹拟合圆并转换为单位 (sin,cos) 以适配训练空间,
并把预测结果再还原为像素坐标返回。
POST /predict
body:
{
"sequences": [ # 批量,每个序列为 [S_in, 2] 像素 [x,y]
[[x, y], [x, y], ...],
...
],
"steps": 30, # 预测步数 (>0)
"centers": [[cx,cy], ...], # 可选 [B,2],若已标定则可传入
"radii": [r, ...] # 可选 [B]
}
返回:
{
"pred_xy": [[[x,y], ...], ...], # [B, steps, 2] 像素
"device": "cuda",
"centers": [[cx,cy], ...], # 用于本次变换的参数
"radii": [r, ...]
}
运行示例:
uv run serve_pendulum.py --ckpt ckpt/pendulum_tf_base.pt --host 0.0.0.0 --port 8000
"""
import argparse
import math
import os
from typing import List, Optional
import numpy as np
import torch
import torch.nn as nn
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel, Field
import uvicorn
# 兼容直接脚本运行与包内相对导入
try:
from .geom_utils import batch_pixels_to_unit_sincos, unit_sincos_to_pixels # type: ignore
except Exception: # noqa
from geom_utils import batch_pixels_to_unit_sincos, unit_sincos_to_pixels
# ----------------- 与训练一致的模块 -----------------
class PositionalEncoding(nn.Module):
def __init__(self, d_model: int, max_len: int = 4096):
super().__init__()
pe = torch.zeros(max_len, d_model)
pos = torch.arange(0, max_len, dtype=torch.float32).unsqueeze(1)
div = torch.exp(torch.arange(0, d_model, 2, dtype=torch.float32) * (-math.log(10000.0) / d_model))
pe[:, 0::2] = torch.sin(pos * div)
pe[:, 1::2] = torch.cos(pos * div)
self.register_buffer("pe", pe.unsqueeze(0)) # [1, L, D]
def forward(self, x):
return x + self.pe[:, : x.size(1), :]
class TimeSeriesTransformer(nn.Module):
def __init__(self, in_dim=2, d_model=256, nhead=4, enc_layers=3, dec_layers=3, d_ff=512, dropout=0.1, out_dim=2):
super().__init__()
self.in_proj = nn.Linear(in_dim, d_model)
self.out_proj = nn.Linear(d_model, out_dim)
self.tgt_proj = nn.Linear(out_dim, d_model)
self.pos_enc = PositionalEncoding(d_model)
self.tf = nn.Transformer(
d_model=d_model,
nhead=nhead,
num_encoder_layers=enc_layers,
num_decoder_layers=dec_layers,
dim_feedforward=d_ff,
dropout=dropout,
batch_first=True,
)
def forward(self, src, tgt_in, tgt_mask=None):
src = self.pos_enc(self.in_proj(src))
tgt = self.pos_enc(self.tgt_proj(tgt_in))
out = self.tf(src, tgt, tgt_mask=tgt_mask) # [B, S_out, D]
return self.out_proj(out)
# ----------------- 推理引擎(仅 CUDA) -----------------
class InferenceEngine:
def __init__(self, ckpt_path: str):
if not torch.cuda.is_available():
raise RuntimeError("CUDA 不可用:该服务仅支持 CUDA。")
self.device = torch.device("cuda")
if not os.path.exists(ckpt_path):
raise FileNotFoundError(f"Checkpoint not found: {ckpt_path}")
# 显式关闭 weights_only 的安全限制(信任本地 ckpt)
payload = torch.load(ckpt_path, map_location=self.device, weights_only=False)
default_cfg = dict(d_model=256, nhead=4, enc_layers=3, dec_layers=3, d_ff=512)
ckpt_cfg = payload.get("cfg", default_cfg)
self.net = TimeSeriesTransformer(in_dim=2, out_dim=2, **ckpt_cfg).to(self.device)
self.net.load_state_dict(payload["model"], strict=True)
self.net.eval()
torch.set_grad_enabled(False)
# 标准化参数
self.mu = payload["mu"] # numpy
self.std = payload["std"]
self.mu_t = torch.from_numpy(self.mu).to(self.device)
self.std_t = torch.from_numpy(self.std).to(self.device)
@staticmethod
def _unitize_t(t: torch.Tensor, eps: float = 1e-6) -> torch.Tensor:
n = torch.clamp(torch.linalg.norm(t, dim=-1, keepdim=True), min=eps)
return t / n
def _to_raw(self, z: torch.Tensor) -> torch.Tensor:
return z * self.std_t + self.mu_t
def _to_std(self, z: torch.Tensor) -> torch.Tensor:
return (z - self.mu_t) / self.std_t
def rollout(self, xb_raw: torch.Tensor, steps: int) -> torch.Tensor:
"""
xb_raw: [B, S_in, 2],raw 空间(即训练时用的 (sin, cos) 或等价的 [x,y] 单位向量)
返回:raw 空间预测 [B, steps, 2]
"""
xb_raw = self._unitize_t(xb_raw) # 规整到单位圆
xb_std = self._to_std(xb_raw) # -> 标准化
tgt_tokens = xb_std[:, -1:, :2].clone() # 起始 token
for _ in range(steps):
mask = nn.Transformer.generate_square_subsequent_mask(tgt_tokens.size(1)).to(self.device)
out = self.net(xb_std, tgt_tokens, tgt_mask=mask) # 标准化空间
next_std = out[:, -1:, :]
next_raw = self._to_raw(next_std) # -> raw
next_raw = self._unitize_t(next_raw) # 单位圆约束
next_std = self._to_std(next_raw) # -> 标准化
tgt_tokens = torch.cat([tgt_tokens, next_std], dim=1)
pred_std = tgt_tokens[:, 1:, :]
pred_raw = self._to_raw(pred_std)
pred_raw = self._unitize_t(pred_raw) # 返回前再规整
return pred_raw
# ----------------- HTTP 层(仅 /predict) -----------------
class PredictRequest(BaseModel):
sequences: List[List[List[float]]] = Field(
..., description="批量序列:每个为 [S_in,2] 的像素 [x,y] 轨迹(来自图像跟踪)"
)
steps: int = Field(..., gt=0, description="预测步数 (>0)")
# 可选:若客户端已标定可传入,以避免重复拟合
centers: Optional[List[List[float]]] = Field(
default=None, description="可选 [B,2],每条序列的圆心 (cx,cy)"
)
radii: Optional[List[float]] = Field(
default=None, description="可选 [B],每条序列的半径 r"
)
class PredictResponse(BaseModel):
pred_xy: List[List[List[float]]] # 未来像素坐标 [B, steps, 2]
device: str
centers: Optional[List[List[float]]] = None
radii: Optional[List[float]] = None
app = FastAPI(title="Pendulum Transformer Inference (CUDA Only)", version="1.0.0")
# CORS: 允许任意来源
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=False,
allow_methods=["*"],
allow_headers=["*"],
)
ENGINE: InferenceEngine | None = None # 运行时注入
@app.post("/predict", response_model=PredictResponse)
def predict(req: PredictRequest):
if ENGINE is None:
raise HTTPException(500, "Engine not initialized")
try:
pix = np.array(req.sequences, dtype=np.float32) # [B, S_in, 2]
except Exception as e:
raise HTTPException(400, f"Invalid 'sequences': {e}")
if pix.ndim != 3 or pix.shape[-1] != 2:
raise HTTPException(400, f"'sequences' must be [B, S_in, 2], got {pix.shape}")
if req.steps <= 0:
raise HTTPException(400, "'steps' must be > 0")
if pix.shape[1] < 1:
raise HTTPException(400, "Each sequence must have S_in >= 1")
# 1) 像素 -> 单位 (sin,cos),并获取每条序列的 (cx,cy,r)
if req.centers is not None and req.radii is not None:
# 使用来自拍端的标定参数
centers = np.array(req.centers, dtype=np.float32) # [B,2]
radii = np.array(req.radii, dtype=np.float32) # [B]
if centers.shape[0] != pix.shape[0] or centers.shape[1] != 2 or radii.shape[0] != pix.shape[0]:
raise HTTPException(400, "centers 应为 [B,2] 且 radii 为 [B]")
radii = np.clip(radii, 1e-6, None)
# 根据训练时定义:sin=(x-cx)/r, cos=(cy-y)/r
sin = (pix[..., 0] - centers[:, 0:1]) / radii[:, None]
cos = (centers[:, 1:2] - pix[..., 1]) / radii[:, None]
sc = np.stack([sin, cos], axis=-1).astype(np.float32) # [B,S,2]
else:
sc, centers, radii = batch_pixels_to_unit_sincos(pix)
# 2) 送入引擎:期望输入为 raw (即单位向量的 sin,cos)
x = torch.from_numpy(sc).to(ENGINE.device)
pred_sc = ENGINE.rollout(x, steps=req.steps) # [B, steps, 2] 单位 (sin,cos)
pred_sc_np = pred_sc.detach().cpu().numpy()
# 3) 单位 (sin,cos) -> 像素
pred_pix = unit_sincos_to_pixels(pred_sc_np, centers, radii) # [B, steps, 2]
return PredictResponse(
pred_xy=pred_pix.tolist(), device="cuda",
centers=centers.tolist(), radii=radii.tolist()
)
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--ckpt", type=str, required=True, help="Path to checkpoint (.pt)")
parser.add_argument("--host", type=str, default="0.0.0.0")
parser.add_argument("--port", type=int, default=8000)
args = parser.parse_args()
global ENGINE
ENGINE = InferenceEngine(args.ckpt)
print(f"[serve] Loaded ckpt from {args.ckpt} on device=cuda")
uvicorn.run(app, host=args.host, port=args.port, log_level="info")
if __name__ == "__main__":
main()