AI 预测可视化平台 | 异常检测标记系统:前端如何高亮预测异常点
# 引言 偏差分解把"不准"拆成了工单,但工单不会自己排进队列——总得有人先发现它。计划员每天面对成百上千个 SKU、每个 SKU 上百个时间点,**靠肉眼扫线图找异常,漏报率几乎是百分之百**。异常标记系统要解决的,正是这个"发现"环节:用统一的判定规则把可疑点筛出来,用分层视觉编码让最严重的点一眼可见,再给每个点挂上状态机和协作入口。 本文拆解异常的四类来源、多层级高亮策略、状态流转与持久化设计,并讨论前端该算到什么程度——哪些该自己做,哪些必须交给后端。 --- # 一、先分清:异常是"预测外"还是"预测内" 这是设计异常系统第一个要踩的坑。很多团队把所有偏差大的点都叫"异常",结果把节前促销这种**模型本来就该预测到**的点也算进去了——这类点偏差大,但它不异常,它是模型表现差。 异常至少应分四类,判定逻辑完全不同: |类型|判定逻辑|语义|建议色| | --- | --- | --- | --- | |区间外|`actual upper`|落在预测区间之外|红| |残差偏置|`|residual|` 显著高于该 SKU 残差分布| |趋势突变|相邻点斜率超过历史分位|行为模式突变|黄| |数据质量|缺值、负值、突降为零|源数据有问题|灰|  **解析**:这是一条**优先级链,不是并列判断**。区间外最严重、最明确,单独成一类;残差离群要和历史自身比较,避免把天生波动大的 SKU 误杀;趋势突变捕捉的是"模式变了",即便单点不超界也值得关注;数据质量问题则独立于模型存在。**四类用不同颜色编码,是整套系统可读性的根基**——计划员扫一眼颜色就知道要处理什么性质的问题。 --- # 二、数据契约:让异常"带状态"地流动 异常不是一次性计算结果,它是一个**有生命周期的对象**。今天标红的点,可能被标注为"已核实、是促销",也可能被驳回"数据有误待修复"。所以后端下发的字段必须同时承载判定结果与人工状态。 ```javascript { "sku": "SKU-1002331", "series": [ { "ds": "2026-11-11", "actual": 1520, "yhat": 1370, "yhatLower": 1180, "yhatUpper": 1470, "residual": 150, "severity": 3, "codes": ["out_of_band", "residual_outlier"], "status": "open", "annotator": null, "annotatedAt": null, "comment": null } ], "thresholds": { "residualP95": 138, "slopeP95": 0.42, "intervalLevel": 0.8 } } ``` **解析**:`codes` 是数组而非单值,因为一个点上**可以同时命中多类异常**(如既区间外又残差离群),用单字符串就无法表达。`severity` 用 1-3 三级,是视觉编码的输入,也便于列表页排序。`status` 是状态机的核心字段,取值只能是 `open / confirmed / dismissed / resolved` 之一,前端据此控制交互可用性。`thresholds` 必须下发并展示,否则计划员会质疑"凭什么算异常"——**阈值可见是异常系统获得信任的前提**。 --- # 三、分级视觉编码:三级信号叠加 高亮不是把所有异常点都涂红。红色用滥了就等于零,这是视觉告警的通病。正确的做法是用**形状 + 描边 + 填充**三层信号叠加,让最严重的点"穿透"整张图。  **解析**:三级编码遵循"**越严重,越不可忽略**"的原则。一级只加描边,不抢注意力;二级填充,扫图时能被发现;三级实心加发光,并同步推送到异常清单置顶。**形状和颜色同时编码,能让色弱用户也可读**——这是无障碍合规的基本要求,不能用颜色做唯一通道。 ```javascript const SEVERITY = { 0: { fill: '#9CA3AF', size: 3, stroke: 'none', glow: false }, 1: { fill: '#FBBF24', size: 5, stroke: '#B45309', glow: false }, 2: { fill: '#FB923C', size: 6, stroke: '#C2410C', glow: false }, 3: { fill: '#EF4444', size: 7, stroke: '#991B1B', glow: true }, } export function AnomalyMarkers({ series, onSelect }) { const anomalies = series.filter(d => d.severity > 0) return ( {anomalies.map(d => { const style = SEVERITY[d.severity] const cx = dateToX(d.ds) const cy = valueToY(d.actual) return ( onSelect(d)} tabIndex={0} onKeyDown={e => e.key === 'Enter' && onSelect(d)}> {style.glow && ( )} ) })} ) } ``` **解析**:异常点单独成一层、渲染在实际线**之上**,保证不被曲线遮挡。`SEVERITY` 用查表替代条件判断,新增等级只改配置不改逻辑。**三级加 SVG **`animate`** 脉冲**,是"需要立即处理"的最强信号,且脉冲是 CSS/SVG 原生实现、零 JS 开销。`tabIndex` 和键盘事件让点可用 Tab 聚焦、Enter 选中,是键盘可达性的最低要求。 --- # 四、阈值配置:让业务自己定"什么叫异常" 阈值写死在前端,是异常系统最常见的失败方式。不同品类、不同波动水平的 SKU,异常标准完全不同,必须支持用户调整并即时回看效果。  **解析**:阈值调整是高频试探动作,必须**即时回算、零网络往返**。只有用户点"保存"时才发请求,这是交互流畅与数据安全的基本平衡。 ```javascript import { useMemo, useState } from 'react' export function ThresholdPanel({ base, onChange }) { const [draft, setDraft] = useState(base) // 本地草稿与已提交值分离,避免误提交 const dirty = JSON.stringify(draft) !== JSON.stringify(base) return ( setDraft({ ...draft, residualP95: v })} /> setDraft({ ...draft, slopeP95: v })} /> onChange(draft)} disabled={!dirty} className="px-3 py-1 bg-indigo-600 text-white rounded disabled:opacity-40"> 应用到本 SKU setDraft(base)} disabled={!dirty} className="px-3 py-1 border rounded">重置 ) } // 纯函数:按当前阈值重算整段序列的异常码 export function detectAnomalies(series, t) { return series.map((d, i) => { if (d.actual == null) return { ...d, severity: 0, codes: [] } const codes = [] if (d.actual > d.yhatUpper || d.actual t.slopeP95) codes.push('trend_shift') } if (Math.abs(d.residual) > t.residualP95) codes.push('residual_outlier') const severity = codes.includes('out_of_band') ? 3 : codes.length >= 2 ? 2 : codes.length === 1 ? 1 : 0 return { ...d, codes, severity } }) } ``` **解析**:`draft` 与 `base` 比对控制"保存"按钮的可点状态,防止空提交,这是表单交互的基本功。`detectAnomalies` 是纯函数,输入序列和阈值、输出带标记的新序列——**纯函数意味着可测试、可复现**,单元测试直接覆盖边界情况(如首点无前驱、实际值为空、残差恰等于阈值)。`severity` 的合并规则体现优先级:区间外一律最高级,多重命中升一级,单类命中为一级。 --- # 五、状态机:从发现到闭环 异常标记不是终点。一个点被标红之后,必须走完"核实—决策—回传"的完整链路,否则系统只会越积越多的红点,最后被计划员无视。  **解析**:`open` 是所有新异常的初始态,只有三种出口,且**每种出口都对应一个后续动作**。这保证了红点不会烂在系统里。`confirmed` 直接对接反馈队列(本系列最后一篇详述);`dismissed` 看似是"忽略",实际在积累规则例外数据,长期看是规则优化的重要样本;`resolved` 则触发重新预测,形成即时反馈。 ```javascript const TRANSITIONS = { open: ['confirmed', 'dismissed', 'resolved'], confirmed: [], dismissed: [], resolved: [], } export function AnnotationPanel({ point, onUpdate }) { const allowed = TRANSITIONS[point.status] || [] const submit = (next, comment) => { if (!allowed.includes(next)) return onUpdate({ ...point, status: next, comment, annotator: currentUser.id, annotatedAt: new Date().toISOString(), }) } return ( {point.ds} 严重度 {point.severity} {point.codes.map(c => • {CODE_LABEL[c]})} onUpdate({ ...point, draftComment: e.target.value })} /> {allowed.map(s => ( submit(s, point.draftComment)} disabled={!point.draftComment?.trim()} className="px-3 py-1 text-sm border rounded disabled:opacity-40"> {STATUS_LABEL[s]} ))} ) } ``` **解析**:`TRANSITIONS` 把状态流转规则**集中在一张表里**,新增状态只改配置,这是状态机模式的核心价值。`allowed` 决定按钮是否渲染,比"渲染了再禁用"更干净——**不该有的动作就不该出现**。提交按钮在说明为空时禁用,因为标注无说明等于无效数据,后端也无法据此学习。 --- # 六、异常清单:从发现到排期 图表上的标记解决"看见",清单解决"处理"。两者必须同步——点击清单行,图表跳转到对应点;点击图上点,清单自动高亮该行。  **解析**:清单默认只显示 `open` 且按严重度降序,是"今天先处理最严重的"这个工作流的直接映射。已 dismissed 的点可展开查看,但不占主列表,避免干扰。虚拟滚动在这里是刚需——一个品类可能有上千个异常点。 ```javascript import { useVirtualizer } from '@tanstack/react-virtual' export function AnomalyList({ items, selected, onSelect }) { const parentRef = useRef(null) const virtualizer = useVirtualizer({ count: items.length, getScrollElement: () => parentRef.current, estimateSize: () => 56, overscan: 8, }) return ( {virtualizer.getVirtualItems().map(vi => { const d = items[vi.index] return ( onSelect(d)}> {d.ds} {d.codes.map(c => CODE_LABEL[c]).join('、')} {(d.actual - d.yhat > 0 ? '+' : '') + (d.actual - d.yhat)} ) })} ) } ``` **解析**:`react-virtual` 只渲染可视区域内的行,`overscan: 8` 让滚动时有缓冲不露白。**行高用固定 56px 并 **`estimateSize`** 声明**,是虚拟滚动流畅的前提——行高不固定会导致滚动条抖动。每行保留颜色点、日期、异常类型、偏差值四段信息,一眼可扫。 --- # 七、性能:前端判定 vs 后端判定 阈值调整的即时回算是前端判定的唯一合理场景,除此之外都应交给后端: |场景|判定位置|原因| | --- | --- | --- | |阈值试探|前端纯函数|需零延迟反馈| |首屏展示|后端下发|避免重复计算| |多 SKU 批量|后端预计算|数据量大| |规则更新|后端|保持一致性| **解析**:1 万点以内前端判定是毫秒级,完全可承受;超过 5 万点就接近一帧预算,**应改为 Worker 或把阈值回算也交给后端**。经验法则是:判定逻辑只在前端保留一份用于交互预览,正式结果一律以后端为准,避免规则分叉。 --- # 结语 异常标记系统的价值不在于"画红点",而在于把模型的不确定性和人工经验接成一条可追踪的链路。本文给出了从判定到闭环的完整设计:四类异常各有判定逻辑与视觉编码,三级严重度用形状、描边、填充和脉冲叠加保证可读与无障碍,阈值可试探调整但规则只保留一份权威源,状态机确保每颗红点都有出口。 阅读本文的收获: - 区间外、残差离群、趋势突变、数据质量必须分开标记并用不同颜色区分; - 颜色不能是唯一通道,需叠加形状编码以兼顾色弱用户; - 阈值调整必须在前端即时回算,但正式结果始终以后端为准,避免规则分叉; - 每个异常点都要走完 confirmed / dismissed / resolved 三种出口,否则红点会堆积到被无视。