| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109 |
- '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 };
|