feat: 预测性成本智能MVP — KPI趋势预测引擎(线性回归/移动平均)+API+前端Tab+pytest覆盖

This commit is contained in:
Hermes CI Fix
2026-08-25 00:10:46 +08:00
parent fb9eba38a8
commit 13aa153875
6 changed files with 863 additions and 1 deletions
+50
View File
@@ -714,3 +714,53 @@ def api_growth_quality(request: Request, data: dict):
}
except Exception as e:
raise HTTPException(400, f"增长质量诊断失败: {str(e)}")
# ── KPI趋势预测(预测性成本智能 MVP) ────────────────────────────
from app.utils.kpi_forecast_engine import ( # noqa: E402
MODELS, forecast_kpi, forecast_finance_kpis,
)
@router.get("/kpi-forecast")
def api_kpi_forecast(
kpi_code: str,
periods: int = 3,
model: str = "linear",
entity_id: int = Depends(get_entity_id),
db: Session = Depends(get_db),
):
"""单个财务KPI预测 — 线性回归/移动平均,多租户隔离(entity_id 权限校验)"""
if periods < 0 or periods > 24:
raise HTTPException(400, "periods 必须在 0~24 之间")
if model not in MODELS:
raise HTTPException(400, f"不支持的模型: {model},可选: {'/'.join(MODELS)}")
result = forecast_kpi(entity_id, kpi_code, db, periods=periods, model=model)
if result is None:
raise HTTPException(
404,
f"KPI {kpi_code} 在企业 entity_id={entity_id} 下不存在,或历史数据不足(至少2条)",
)
return result
@router.get("/kpi-forecast/finance")
def api_kpi_forecast_finance(
periods: int = 3,
model: str = "linear",
entity_id: int = Depends(get_entity_id),
db: Session = Depends(get_db),
):
"""批量预测该企业全部财务维度KPI(历史≥3条),按可预测性排序"""
if periods < 0 or periods > 24:
raise HTTPException(400, "periods 必须在 0~24 之间")
if model not in MODELS:
raise HTTPException(400, f"不支持的模型: {model},可选: {'/'.join(MODELS)}")
results = forecast_finance_kpis(entity_id, db, periods=periods, model=model)
return {
"entity_id": entity_id,
"model": model,
"periods": periods,
"total": len(results),
"data": results,
}
+295
View File
@@ -0,0 +1,295 @@
"""KPI预测引擎 — 基于历史KPI值做趋势预测(预测性成本智能 MVP)
模型(MVP原则:简单可用,不上深度学习):
- linear 线性回归(最小二乘 y = a + b·x),输出95%预测区间
- moving_average 简单移动平均(默认窗口3期),输出均值±波动区间
置信度诚实标注:基于历史数据量 + 拟合优度(R² / 波动率CV)综合打分,
数据不足时明确给出 low,不做虚假高置信。
复用 cash_forecast_engine.get_entity_kpi_history 取历史数据(不重复写查询)。
"""
import logging
import math
from typing import Optional
from sqlalchemy.orm import Session
from app.utils.cash_forecast_engine import get_entity_kpi_history, find_kpi
logger = logging.getLogger("cma.kpi_forecast")
MODELS = ("linear", "moving_average")
DEFAULT_PERIODS = 3
TREND_THRESHOLD_PCT = 3.0 # |趋势百分比| ≥ 3% 判定为有明确趋势方向
# 置信度档位
CONF_LEVELS = {3: "high", 2: "medium", 1: "low"}
# 中文映射(供 summary 使用)
TREND_CN = {"up": "上升", "down": "下降", "flat": "基本平稳"}
CONF_CN = {"high": "", "medium": "", "low": ""}
def next_period(period: str, steps: int = 1) -> str:
"""期数递增:"2026-05" + 1 → "2026-06";解析失败时退化为 period+N"""
try:
y, m = str(period).split("-")
total = int(y) * 12 + (int(m) - 1) + steps
return f"{total // 12:04d}-{total % 12 + 1:02d}"
except Exception:
return f"{period}+{steps}"
def _t_crit(n: int) -> float:
"""95%双尾学生t临界值近似(小样本查表取保守值,大样本趋近1.96)"""
table = {
2: 12.71, 3: 4.30, 4: 3.18, 5: 2.78, 6: 2.57, 7: 2.45,
8: 2.31, 9: 2.26, 10: 2.23, 12: 2.18, 15: 2.13,
20: 2.09, 30: 2.04, 60: 2.00,
}
for k in sorted(table):
if n <= k:
return table[k]
return 1.96
def _std(values: list) -> float:
"""样本标准差(n>=2),n==1 返回0"""
n = len(values)
if n < 2:
return 0.0
mean = sum(values) / n
return math.sqrt(sum((v - mean) ** 2 for v in values) / (n - 1))
def _rel_trend_pct(values: list) -> float:
"""趋势百分比 = 线性回归斜率 / |均值| × 100(与 cash_forecast_engine.calc_trend 同口径)"""
n = len(values)
if n < 2:
return 0.0
xbar = (n - 1) / 2.0
ybar = sum(values) / n
sxx = sum((i - xbar) ** 2 for i in range(n))
if sxx == 0:
return 0.0
slope = sum((i - xbar) * (values[i] - ybar) for i in range(n)) / sxx
return slope / max(abs(ybar), 1.0) * 100
def judge_trend(trend_pct: float, threshold: float = TREND_THRESHOLD_PCT) -> str:
"""趋势方向判定:up / down / flat"""
if trend_pct > threshold:
return "up"
if trend_pct < -threshold:
return "down"
return "flat"
def _compute_r2(values: list, pred_fn) -> float:
"""拟合优度 R²(0~1),数据无波动时视为完全拟合"""
ybar = sum(values) / len(values)
ss_tot = sum((v - ybar) ** 2 for v in values)
if ss_tot == 0:
return 1.0
ss_res = sum((v - pred_fn(i)) ** 2 for i, v in enumerate(values))
return max(0.0, 1.0 - ss_res / ss_tot)
def compute_confidence(n: int, model: str, r2: Optional[float] = None,
cv: Optional[float] = None) -> str:
"""置信度诚实标注:数据量基数 + 拟合优度修正
- 数据量:n>=12 → 3分;n>=6 → 2分;否则 1分
- linearR²>=0.7 +1R²<0.3 -1
- moving_averageCV<0.3 +1(低波动更可信);CV>0.6 -1
"""
score = 3 if n >= 12 else (2 if n >= 6 else 1)
# 拟合度修正仅在样本量足够时生效:
# n<4 时 R² 无统计意义(2点直线必然R²=1.0),CV 也噪声大,不做上调,避免虚假高置信
if n >= 4:
if model == "linear" and r2 is not None:
if r2 >= 0.7:
score += 1
elif r2 < 0.3:
score -= 1
elif model == "moving_average" and cv is not None:
if cv < 0.3:
score += 1
elif cv > 0.6:
score -= 1
score = max(1, min(3, score))
return CONF_LEVELS[score]
def linear_forecast(values: list, periods: int = 3) -> dict:
"""线性回归预测 — 返回未来periods期预测值 + 95%预测区间 + 拟合统计量"""
n = len(values)
x = list(range(n))
xbar = (n - 1) / 2.0
ybar = sum(values) / n
sxx = sum((i - xbar) ** 2 for i in x)
slope = sum((i - xbar) * (values[i] - ybar) for i in x) / sxx if sxx else 0.0
intercept = ybar - slope * xbar
def pred(i: int) -> float:
return intercept + slope * i
# 残差标准误(n>=3 用 n-2 自由度;n==2 用样本标准差近似)
if n >= 3:
resid = [values[i] - pred(i) for i in x]
se = math.sqrt(sum(r * r for r in resid) / (n - 2))
else:
se = _std(values)
if se == 0:
se = max(abs(ybar) * 0.05, 1e-9) # 完全拟合时给最小带,避免零宽区间
t_crit = _t_crit(n)
forecast = []
for k in range(periods):
x0 = n + k
predicted = pred(x0)
se_pred = se * math.sqrt(1.0 + 1.0 / n + (x0 - xbar) ** 2 / max(sxx, 1e-9)) * t_crit
band = max(se_pred, abs(predicted) * 0.02)
forecast.append({
"predicted": round(predicted, 2),
"lower": round(predicted - band, 2),
"upper": round(predicted + band, 2),
})
r2 = _compute_r2(values, pred)
trend_pct = slope / max(abs(ybar), 1.0) * 100
return {
"forecast": forecast,
"slope": slope,
"intercept": intercept,
"r2": round(r2, 3),
"trend_pct": round(trend_pct, 2),
"se": round(se, 4),
}
def moving_average_forecast(values: list, periods: int = 3, window: int = 3) -> dict:
"""简单移动平均预测 — 未来各期预测值 = 最近window期均值;区间=均值±1.96×波动"""
n = len(values)
w = max(1, min(window, n))
base = sum(values[-w:]) / w
std = _std(values)
if std == 0:
std = max(abs(base) * 0.05, 1e-9)
band = max(1.96 * std, abs(base) * 0.02)
forecast = [{
"predicted": round(base, 2),
"lower": round(base - band, 2),
"upper": round(base + band, 2),
} for _ in range(periods)]
cv = std / abs(base) if base else 0.0
trend_pct = _rel_trend_pct(values)
return {
"forecast": forecast,
"window": w,
"mean": round(base, 2),
"std": round(std, 4),
"cv": round(cv, 3),
"trend_pct": round(trend_pct, 2),
}
def build_summary(kpi_name: str, unit: str, trend: str, next_target: Optional[float],
periods: int, n_history: int, confidence: str, model: str) -> str:
"""中文一句话解读"""
trend_cn = TREND_CN.get(trend, trend)
conf_cn = CONF_CN.get(confidence, confidence)
unit_txt = unit or ""
if periods <= 0:
return f"基于{n_history}期历史数据,{kpi_name}当前趋势{trend_cn}(模型:{model},置信度:{conf_cn}),未请求未来期数预测"
target_txt = f"{next_target:,.2f}{unit_txt}" if next_target is not None else ""
return (
f"基于{n_history}期历史数据,{kpi_name}未来{periods}期预计{trend_cn}"
f"下一期预测值约{target_txt}(模型:{model},置信度:{conf_cn}"
)
def forecast_kpi(entity_id: int, kpi_code: str, db: Session,
periods: int = DEFAULT_PERIODS, model: str = "linear") -> Optional[dict]:
"""单个KPI预测(多租户隔离:历史数据通过 entity_id 维度查询)
返回 None 表示 KPI 不存在或历史数据不足(<2条)。
"""
if model not in MODELS:
model = "linear"
history = get_entity_kpi_history(entity_id, kpi_code, db, limit_months=120)
if not history:
return None
hist_asc = list(reversed(history)) # 按 period 升序
values = [float(v.actual_value) for v in hist_asc if v.actual_value is not None]
if len(values) < 2:
return None
kpi_def = find_kpi(db, entity_id, [kpi_code])
kpi_name = str(kpi_def.kpi_name) if kpi_def else kpi_code
unit = str(kpi_def.unit or "") if kpi_def else ""
if model == "moving_average":
res = moving_average_forecast(values, periods)
confidence = compute_confidence(len(values), model, cv=res["cv"])
else:
res = linear_forecast(values, periods)
confidence = compute_confidence(len(values), model, r2=res["r2"])
trend = judge_trend(res["trend_pct"])
# 未来期数(基于最近一期 period 递增)
last_period = hist_asc[-1].period
forecast = []
for k in range(periods):
fp = res["forecast"][k]
forecast.append({
"period": next_period(last_period, k + 1),
"predicted": fp["predicted"],
"lower": fp["lower"],
"upper": fp["upper"],
})
next_target = forecast[0]["predicted"] if forecast else None
summary = build_summary(kpi_name, unit, trend, next_target, periods,
len(values), confidence, model)
return {
"entity_id": entity_id,
"kpi": {"code": kpi_code, "name": kpi_name, "unit": unit},
"model": model,
"periods": periods,
"trend": trend,
"trend_pct": res["trend_pct"],
"confidence": confidence,
"history_count": len(values),
"history": [{"period": v.period, "value": round(float(v.actual_value), 2)} for v in hist_asc],
"forecast": forecast,
"next_target": next_target,
"summary": summary,
}
def forecast_finance_kpis(entity_id: int, db: Session,
periods: int = DEFAULT_PERIODS, model: str = "linear",
min_history: int = 3) -> list:
"""批量预测该企业全部财务维度KPI(历史≥min_history条),按可预测性排序"""
from app.models import KPIDefinition
kpis = db.query(KPIDefinition).filter(
KPIDefinition.entity_id == entity_id,
KPIDefinition.dimension == "finance",
KPIDefinition.status == "active",
).all()
results = []
for kpi in kpis:
r = forecast_kpi(entity_id, str(kpi.kpi_code), db, periods=periods, model=model)
if r and r["history_count"] >= min_history:
results.append(r)
# 可预测性排序:置信度(high=3/medium=2/low=1) 优先,其次历史数据量
score = {"high": 3, "medium": 2, "low": 1}
results.sort(key=lambda r: (score.get(r["confidence"], 0), r["history_count"]), reverse=True)
return results