feat(prediction): 三层校准体系 + 按钢种分组 + 数据飞轮

1. 按钢种分组 K_cal:cal_coeffs.json 升级为嵌套结构,
   {kcal: {model: {_default, Q235, ...}}, phys: {...}},
   旧平铺格式首次加载时自动迁移。

2. 物理参数自适应:EA_R/K0/N_CONC 按钢种网格拟合
   (7×5×3=105 组合),每次校准追加样本到
   production_samples.jsonl,≥10 条后自动触发拟合。

3. 数据飞轮:新增 POST /retrain 端点,后台子进程跑
   train_models.py --use-real-data 混入实绩重训
   (10× 权重),完成后 ONNX 热重载,无需重启服务。

新增端点:
  GET  /calibration/samples         样本数统计
  GET  /calibration/phys-params     物理参数查询
  POST /calibration/fit-phys/{key}  手动触发物理参数拟合
  POST /retrain                     启动重训
  GET  /retrain/status              重训进度

模型类签名变更:
  TensionModel / QualityPredictionModel 新增 steel_grade 参数
  AcidConsumptionModel 新增 fe_conc_avg 参数

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-06-01 16:13:39 +08:00
parent b461f0d2f8
commit f5c59db92b
3 changed files with 705 additions and 228 deletions

View File

@@ -1,12 +1,14 @@
"""
本地训练脚本 — 生成合成数据、训练 MLP、导出 ONNX
运行方式(在 backend/ 目录下):
python train_models.py
python train_models.py # 纯合成数据
python train_models.py --use-real-data # 混入生产实绩10× 权重)
依赖(仅本地训练用,不进 Docker
pip install torch onnx onnxruntime scikit-learn numpy
"""
import sys, json, time
import sys, json, time, argparse
from pathlib import Path
import numpy as np
@@ -16,7 +18,10 @@ import torch.optim as optim
from torch.utils.data import DataLoader, TensorDataset
sys.path.insert(0, str(Path(__file__).parent))
from app.services.prediction import AcidSpeedModel, TensionModel, QualityPredictionModel
from app.services.prediction import (
AcidSpeedModel, TensionModel, QualityPredictionModel,
_SAMPLE_FILE, get_sample_stats,
)
PT_DIR = Path(__file__).parent / "app" / "services" / "pt_models"
PT_DIR.mkdir(parents=True, exist_ok=True)
@@ -32,6 +37,8 @@ TENSION_ZONES = [
"rinse", "leveler", "s2_roller", "outlet",
]
REAL_SAMPLE_WEIGHT = 10 # 每条真实样本复制次数(等效权重)
# ─── 网络结构 ───────────────────────────────────────────────────────────────
@@ -95,6 +102,74 @@ def export_onnx(model: nn.Module, in_dim: int, path: Path):
print(f"{path.name} ({path.stat().st_size//1024} KB)")
# ─── 读取生产实绩样本 ────────────────────────────────────────────────────────
def load_real_samples(model_name: str):
"""
从 production_samples.jsonl 读取指定模型的实绩样本,
返回 (X_real, y_real) numpy 数组,或 (None, None)。
"""
if not _SAMPLE_FILE.exists():
return None, None
Xs, ys = [], []
with open(_SAMPLE_FILE) as f:
for line in f:
try:
r = json.loads(line)
if r.get("model") != model_name:
continue
inp = r.get("inputs")
if not inp:
continue
if model_name == "acid_speed":
spd = r.get("actual_speed")
if spd is None: continue
Xs.append(inp[:14])
ys.append([spd])
elif model_name == "tension":
kn = r.get("actual_kn")
if kn is None: continue
zone = r.get("zone")
if zone not in TENSION_ZONES: continue
# 单区段样本:只校准该区段,其他用模型预测填充
m = TensionModel(inp[0], inp[1], inp[2], inp[3])
res = m.calculate()
tensions = [res["zones"][z]["tension_kN"] for z in TENSION_ZONES]
tensions[TENSION_ZONES.index(zone)] = kn
Xs.append(inp[:4])
ys.append(tensions)
elif model_name == "quality":
ag = r.get("actual_grade")
if ag is None: continue
grade_map = {"A1": 95.0, "A2": 85.0, "B1": 75.0, "B2": 65.0, "C": 50.0}
target_pi = grade_map.get(ag, 75.0)
Xs.append(inp[:6])
ys.append([target_pi, target_pi]) # pi ≈ surface as proxy
except Exception:
continue
if not Xs:
return None, None
print(f" 实绩样本: {model_name} = {len(Xs)} 条 (将按 {REAL_SAMPLE_WEIGHT}× 权重混入)")
return np.array(Xs, np.float32), np.array(ys, np.float32)
def mix_with_real(X_syn: np.ndarray, y_syn: np.ndarray,
X_real, y_real) -> tuple:
"""将真实样本重复 REAL_SAMPLE_WEIGHT 次后拼接到合成数据尾部。"""
if X_real is None or len(X_real) == 0:
return X_syn, y_syn
X_r = np.tile(X_real, (REAL_SAMPLE_WEIGHT, 1))
y_r = np.tile(y_real, (REAL_SAMPLE_WEIGHT, 1))
return np.concatenate([X_syn, X_r], axis=0), np.concatenate([y_syn, y_r], axis=0)
# ─── 1. 酸洗速度模型 ────────────────────────────────────────────────────────
# 输入(14): thickness, scale_weight, conc×6, temp×6
# 输出(1): max_speed
@@ -104,31 +179,27 @@ def gen_acid_speed(n: int):
Xs, ys = [], []
skip = 0
while len(Xs) < n:
t = rng.uniform(0.5, 8.0)
sw = rng.uniform(4.0, 18.0)
t = rng.uniform(0.5, 8.0)
sw = rng.uniform(4.0, 18.0)
conc = rng.uniform(60, 240, 6).tolist()
temp = rng.uniform(52, 87, 6).tolist()
tpi = rng.uniform(88, 97)
try:
m = AcidSpeedModel(
thickness=t, width=1000.0, steel_grade="Q235",
acid_conc_list=conc, acid_temp_list=temp,
scale_weight=sw, target_pi=tpi,
)
m = AcidSpeedModel(thickness=t, width=1000.0, steel_grade="Q235",
acid_conc_list=conc, acid_temp_list=temp,
scale_weight=sw, target_pi=tpi)
spd = float(m.calculate()["max_speed"])
except Exception:
skip += 1
continue
# 模拟真实工况偏差±6% 相对噪声 + 钢种系数扰动
steel_factor = rng.choice([0.92, 0.96, 1.00, 1.03, 1.06])
noise = rng.normal(1.0, 0.06)
spd_n = float(np.clip(spd * noise * steel_factor, 20, 180))
Xs.append([t, sw] + conc + temp)
ys.append([spd_n])
print(f" acid_speed: {len(Xs)} samples (skipped {skip})")
print(f" 合成样本: acid_speed = {len(Xs)} (skipped {skip})")
return np.array(Xs, np.float32), np.array(ys, np.float32)
@@ -142,21 +213,18 @@ def gen_tension(n: int):
while len(Xs) < n:
t = rng.uniform(0.5, 8.0)
w = rng.uniform(600, 1600)
ys_ = rng.uniform(150, 600)
tc = rng.uniform(0.15, 0.35)
ys_= rng.uniform(150, 600)
tc = rng.uniform(0.15, 0.35)
m = TensionModel(thickness=t, width=w, yield_strength=ys_, tension_coef=tc)
res = m.calculate()
tensions = [res["zones"][z]["tension_kN"] for z in TENSION_ZONES]
# 各区段独立噪声(实测张力传感器精度约 ±4%
noise = rng.normal(1.0, 0.04, 10)
tensions_n = [float(np.clip(v * noise[i], 0.1, 9999)) for i, v in enumerate(tensions)]
Xs.append([t, w, ys_, tc])
ys.append(tensions_n)
print(f" tension: {len(Xs)} samples")
print(f" 合成样本: tension = {len(Xs)} ")
return np.array(Xs, np.float32), np.array(ys, np.float32)
@@ -175,35 +243,34 @@ def gen_quality(n: int):
sw = rng.uniform(4, 18)
fe = rng.uniform(20, 130)
m = QualityPredictionModel(
thickness=t, avg_speed=spd,
acid_conc_avg=conc, acid_temp_avg=temp,
scale_weight=sw, fe_conc_avg=fe,
)
m = QualityPredictionModel(thickness=t, avg_speed=spd,
acid_conc_avg=conc, acid_temp_avg=temp,
scale_weight=sw, fe_conc_avg=fe)
res = m.calculate()
pi = res["pi_score"]
suf = res["surface_score"]
# ±6% 噪声模拟质检测量不确定度
pi_n = float(np.clip(pi * rng.normal(1.0, 0.06), 0, 100))
suf_n = float(np.clip(suf * rng.normal(1.0, 0.06), 0, 100))
pi_n = float(np.clip(res["pi_score"] * rng.normal(1.0, 0.06), 0, 100))
suf_n = float(np.clip(res["surface_score"] * rng.normal(1.0, 0.06), 0, 100))
Xs.append([t, spd, conc, temp, sw, fe])
ys.append([pi_n, suf_n])
print(f" quality: {len(Xs)} samples")
print(f" 合成样本: quality = {len(Xs)} ")
return np.array(Xs, np.float32), np.array(ys, np.float32)
# ─── 主流程 ─────────────────────────────────────────────────────────────────
def main():
def main(use_real_data: bool = False):
scalers: dict = {}
t0 = time.time()
if use_real_data:
stats = get_sample_stats()
print(f"\n生产实绩样本统计: {stats}")
# ── 酸洗速度 ──
print("\n[1/3] 酸洗速度模型")
X, y = gen_acid_speed(N)
if use_real_data:
X, y = mix_with_real(X, y, *load_real_samples("acid_speed"))
Xn, Xm, Xs = z_scale(X)
yn, ym, ys_ = z_scale(y)
model = MLP(14, 1, hidden=(128, 64, 32))
@@ -218,6 +285,8 @@ def main():
# ── 张力 ──
print("\n[2/3] 张力模型")
X, y = gen_tension(N)
if use_real_data:
X, y = mix_with_real(X, y, *load_real_samples("tension"))
Xn, Xm, Xs = z_scale(X)
yn, ym, ys_ = z_scale(y)
model = MLP(4, 10, hidden=(64, 64, 32))
@@ -233,6 +302,8 @@ def main():
# ── 质量 ──
print("\n[3/3] 质量预测模型")
X, y = gen_quality(N)
if use_real_data:
X, y = mix_with_real(X, y, *load_real_samples("quality"))
Xn, Xm, Xs = z_scale(X)
yn, ym, ys_ = z_scale(y)
model = MLP(6, 2, hidden=(64, 32))
@@ -244,7 +315,6 @@ def main():
"y_mean": ym.tolist(), "y_std": ys_.tolist(),
}
# ── 保存 scaler 参数 ──
scaler_path = PT_DIR / "scalers.json"
with open(scaler_path, "w") as f:
json.dump(scalers, f, indent=2)
@@ -253,4 +323,8 @@ def main():
if __name__ == "__main__":
main()
parser = argparse.ArgumentParser()
parser.add_argument("--use-real-data", action="store_true",
help="将 production_samples.jsonl 中的实绩混入训练集")
args = parser.parse_args()
main(use_real_data=args.use_real_data)