'use strict'; /** * 智能问数 · 统计异常检测工具 * ---------------------------------------------------------------------------- * detect_anomalies:对加性指标的逐日序列做 leave-one-out z-score 异常检测。 * 方法论(模型无权偏离): * · 每天的 z = (当天值 - 其余天均值) / 其余天标准差(LOO,防异常值自己抬高 σ) * · σ 下限 = 均值的 10%(防全同值序列除零误报);样本 <5 天不检测 * · |z| ≥ 3 判 fail(严重异常),≥ 2 判 warn * · 区间含寒暑假窗口(0108-0225 / 0701-0831)时附加 info 说明基线可能失真 */ const metrics = require('../metrics.js'); const { eachDay, ymdDash } = require('./lake.js'); const VACATIONS = [ { name: '寒假', from: '0108', to: '0225' }, { name: '暑假', from: '0701', to: '0831' }, ]; function std(values) { if (values.length < 2) return 0; const mean = values.reduce((s, x) => s + x, 0) / values.length; return Math.sqrt(values.reduce((s, x) => s + (x - mean) ** 2, 0) / (values.length - 1)); } function mean(values) { return values.length ? values.reduce((s, x) => s + x, 0) / values.length : 0; } function anomalySanitize(args, ctx) { const a = args || {}; const metric = ['grossAmt', 'validOrderCnt', 'incomeAmt', 'expUv', 'orderUserCnt'].includes(a.metric) ? a.metric : 'grossAmt'; const { dateRange } = ctx; const from = /^\d{8}$/.test(String(a.from || '')) ? String(a.from) : dateRange.from; const to = /^\d{8}$/.test(String(a.to || '')) ? String(a.to) : dateRange.to; const k = Number(a.k) > 0 ? Number(a.k) : 2; return { metric, from: from < dateRange.from ? dateRange.from : from, to: to > dateRange.to ? dateRange.to : to, k }; } function anomalyHandler(ctx, args) { const { svc, entityKey } = ctx; const { metric, from, to, k } = args; const def = metrics.get(metric) || {}; const rows = svc.store.queryRows(entityKey, { from, to, scope: 'shop' }); const byDate = new Map(); for (const r of rows) { if (!byDate.has(r.date)) byDate.set(r.date, []); byDate.get(r.date).push(r); } const days = eachDay(from, to).filter((d) => byDate.has(d)); if (days.length < 5) { return { summary: `异常检测需要至少 5 天数据(当前 ${days.length} 天),已跳过。`, data: { metric, from, to, anomalies: [], verdicts: [] } }; } const series = days.map((d) => ({ date: d, value: metrics.dayValue(metric, byDate.get(d)) })).filter((s) => s.value != null); const anomalies = []; for (const s of series) { const others = series.filter((x) => x.date !== s.date).map((x) => x.value); const baseMean = mean(others); const sigma = Math.max(std(others), Math.abs(baseMean) * 0.1, 1); // σ 下限防全同值误报 const z = (s.value - baseMean) / sigma; if (Math.abs(z) >= k) { anomalies.push({ date: s.date, value: s.value, baseMean: Math.round(baseMean * 100) / 100, z: +z.toFixed(2), severity: Math.abs(z) >= 3 ? 'fail' : 'warn', }); } } anomalies.sort((a, b) => Math.abs(b.z) - Math.abs(a.z)); const lines = [`${def.name || metric} 异常检测(${ymdDash(from)} ~ ${ymdDash(to)},leave-one-out z-score,阈值 |z|≥${k}):`]; if (!anomalies.length) { lines.push(`未发现统计异常(${series.length} 天均在基线 ±${k}σ 内)。`); } else { for (const a of anomalies.slice(0, 10)) { lines.push(` [${a.severity}] ${ymdDash(a.date)}:${a.value}(基线 ${a.baseMean},z=${a.z})`); } if (anomalies.length > 10) lines.push(` …其余 ${anomalies.length - 10} 天略`); lines.push('异常日建议结合活动/调价/天气等业务事件解读,再下结论。'); } const mmdd = (v) => v.slice(4); const vacations = VACATIONS.filter((v) => { const f = mmdd(from); const t = mmdd(to); return (f >= v.from && f <= v.to) || (t >= v.from && t <= v.to) || (f < v.from && t > v.to); }); if (vacations.length) { lines.push(`注意:区间含${vacations.map((v) => v.name).join('/')}窗口,基线可能失真,异常判定请谨慎采信。`); } const status = anomalies.some((a) => a.severity === 'fail') ? 'fail' : (anomalies.length ? 'warn' : 'ok'); return { summary: lines.join('\n'), data: { metric, from, to, series, anomalies, verdicts: [{ kind: 'anomaly', check: `统计异常 · ${def.name || metric}`, status, detail: anomalies.length ? `${anomalies.length} 天偏离基线(最差 ${ymdDash(anomalies[0].date)} z=${anomalies[0].z})` : `${series.length} 天均在基线 ±${k}σ 内`, }], }, }; } module.exports = { anomalySanitize, anomalyHandler };