"""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