feat: 预测性成本智能MVP — KPI趋势预测引擎(线性回归/移动平均)+API+前端Tab+pytest覆盖
This commit is contained in:
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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分
|
||||
- linear:R²>=0.7 +1;R²<0.3 -1
|
||||
- moving_average:CV<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
|
||||
Reference in New Issue
Block a user