anomaly.js 4.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109
  1. 'use strict';
  2. /**
  3. * 智能问数 · 统计异常检测工具
  4. * ----------------------------------------------------------------------------
  5. * detect_anomalies:对加性指标的逐日序列做 leave-one-out z-score 异常检测。
  6. * 方法论(模型无权偏离):
  7. * · 每天的 z = (当天值 - 其余天均值) / 其余天标准差(LOO,防异常值自己抬高 σ)
  8. * · σ 下限 = 均值的 10%(防全同值序列除零误报);样本 <5 天不检测
  9. * · |z| ≥ 3 判 fail(严重异常),≥ 2 判 warn
  10. * · 区间含寒暑假窗口(0108-0225 / 0701-0831)时附加 info 说明基线可能失真
  11. */
  12. const metrics = require('../metrics.js');
  13. const { eachDay, ymdDash } = require('./lake.js');
  14. const VACATIONS = [
  15. { name: '寒假', from: '0108', to: '0225' },
  16. { name: '暑假', from: '0701', to: '0831' },
  17. ];
  18. function std(values) {
  19. if (values.length < 2) return 0;
  20. const mean = values.reduce((s, x) => s + x, 0) / values.length;
  21. return Math.sqrt(values.reduce((s, x) => s + (x - mean) ** 2, 0) / (values.length - 1));
  22. }
  23. function mean(values) {
  24. return values.length ? values.reduce((s, x) => s + x, 0) / values.length : 0;
  25. }
  26. function anomalySanitize(args, ctx) {
  27. const a = args || {};
  28. const metric = ['grossAmt', 'validOrderCnt', 'incomeAmt', 'expUv', 'orderUserCnt'].includes(a.metric) ? a.metric : 'grossAmt';
  29. const { dateRange } = ctx;
  30. const from = /^\d{8}$/.test(String(a.from || '')) ? String(a.from) : dateRange.from;
  31. const to = /^\d{8}$/.test(String(a.to || '')) ? String(a.to) : dateRange.to;
  32. const k = Number(a.k) > 0 ? Number(a.k) : 2;
  33. return { metric, from: from < dateRange.from ? dateRange.from : from, to: to > dateRange.to ? dateRange.to : to, k };
  34. }
  35. function anomalyHandler(ctx, args) {
  36. const { svc, entityKey } = ctx;
  37. const { metric, from, to, k } = args;
  38. const def = metrics.get(metric) || {};
  39. const rows = svc.store.queryRows(entityKey, { from, to, scope: 'shop' });
  40. const byDate = new Map();
  41. for (const r of rows) {
  42. if (!byDate.has(r.date)) byDate.set(r.date, []);
  43. byDate.get(r.date).push(r);
  44. }
  45. const days = eachDay(from, to).filter((d) => byDate.has(d));
  46. if (days.length < 5) {
  47. return { summary: `异常检测需要至少 5 天数据(当前 ${days.length} 天),已跳过。`, data: { metric, from, to, anomalies: [], verdicts: [] } };
  48. }
  49. const series = days.map((d) => ({ date: d, value: metrics.dayValue(metric, byDate.get(d)) })).filter((s) => s.value != null);
  50. const anomalies = [];
  51. for (const s of series) {
  52. const others = series.filter((x) => x.date !== s.date).map((x) => x.value);
  53. const baseMean = mean(others);
  54. const sigma = Math.max(std(others), Math.abs(baseMean) * 0.1, 1); // σ 下限防全同值误报
  55. const z = (s.value - baseMean) / sigma;
  56. if (Math.abs(z) >= k) {
  57. anomalies.push({
  58. date: s.date, value: s.value, baseMean: Math.round(baseMean * 100) / 100,
  59. z: +z.toFixed(2), severity: Math.abs(z) >= 3 ? 'fail' : 'warn',
  60. });
  61. }
  62. }
  63. anomalies.sort((a, b) => Math.abs(b.z) - Math.abs(a.z));
  64. const lines = [`${def.name || metric} 异常检测(${ymdDash(from)} ~ ${ymdDash(to)},leave-one-out z-score,阈值 |z|≥${k}):`];
  65. if (!anomalies.length) {
  66. lines.push(`未发现统计异常(${series.length} 天均在基线 ±${k}σ 内)。`);
  67. } else {
  68. for (const a of anomalies.slice(0, 10)) {
  69. lines.push(` [${a.severity}] ${ymdDash(a.date)}:${a.value}(基线 ${a.baseMean},z=${a.z})`);
  70. }
  71. if (anomalies.length > 10) lines.push(` …其余 ${anomalies.length - 10} 天略`);
  72. lines.push('异常日建议结合活动/调价/天气等业务事件解读,再下结论。');
  73. }
  74. const mmdd = (v) => v.slice(4);
  75. const vacations = VACATIONS.filter((v) => {
  76. const f = mmdd(from);
  77. const t = mmdd(to);
  78. return (f >= v.from && f <= v.to) || (t >= v.from && t <= v.to) || (f < v.from && t > v.to);
  79. });
  80. if (vacations.length) {
  81. lines.push(`注意:区间含${vacations.map((v) => v.name).join('/')}窗口,基线可能失真,异常判定请谨慎采信。`);
  82. }
  83. const status = anomalies.some((a) => a.severity === 'fail') ? 'fail' : (anomalies.length ? 'warn' : 'ok');
  84. return {
  85. summary: lines.join('\n'),
  86. data: {
  87. metric, from, to, series, anomalies,
  88. verdicts: [{
  89. kind: 'anomaly', check: `统计异常 · ${def.name || metric}`,
  90. status,
  91. detail: anomalies.length
  92. ? `${anomalies.length} 天偏离基线(最差 ${ymdDash(anomalies[0].date)} z=${anomalies[0].z})`
  93. : `${series.length} 天均在基线 ±${k}σ 内`,
  94. }],
  95. },
  96. };
  97. }
  98. module.exports = { anomalySanitize, anomalyHandler };