对聚合后的指标做去噪处理,核心不是把异常值隐藏起来,而是让后续的平均值、趋势对比和告警阈值建立在更稳定的数据分布上。以按渠道和日期汇总的每日销售额、按用户统计的平均客单价这类聚合结果为例,某一次大促、系统抓取异常或极低流量分组都可能产生明显偏离主体的数值。如果不做处理,整体均值会被拉高,环比增长率的波动也会被放大;如果直接在可视化工具里人工剔除,规则又难以沉淀。用百分位数在SQL中设置动态上下界,可以让清洗逻辑和查询逻辑放在同一条链路里,既透明又可复用。

一、聚合结果为什么还会出现需要剔除的离群值
很多人认为聚合已经天然平滑了数据,但聚合层级不同,离群值的影响依然存在。比如订单表里每条记录是一个订单,按天聚合后的每日销售额是一条聚合记录。如果某一天出现一笔异常的大额退款或者运营误操作,那么这一天的销售总额会明显偏离其他日期。此时再对每日销售额求月均值,这个异常日会把整月平均水平拉高,甚至掩盖真实的下滑趋势。
更典型的是分组维度下的稀疏数据。按渠道统计每日转化率时,某些渠道可能一天只有几十次访问,转化率的波动本来就很大。偶然一次活动页跳转异常带来极高的转化率,就会让该渠道在观察期内出现一个远超正常范围的聚合值。对于这一类指标,平均值和标准差都非常脆弱,而中位数和百分位数则能更稳健地反映主体分布。
去噪的目标不是把所有高值都删掉,而是识别出统计上明显偏离组内主体的记录。常见的做法有两种:一种是根据业务经验设置固定上下限,比如客单价不可能超过10万元;另一种是基于数据本身的分布动态划定边界,例如用P5到P95范围,或者用IQR方法。本文重点讨论后一种,因为它更适合指标数量多、业务规则不易逐一维护的场景。
二、用百分位数划定离群值边界的核心方法
百分位数处理的第一步是确定边界。百分位边界可以直接取第5和第95百分位,保留中间90%的数据。这种方法实现简单,适合偏态不太极端的指标。更常用的稳健方案是IQR规则:先计算第一四分位数Q1和第三四分位数Q3,然后取四分位距IQR等于Q3减Q1,下界为Q1减1.5倍IQR,上界为Q3加1.5倍IQR。超出这个范围的值被视为离群值。
下面这段PostgreSQL代码先按渠道和日期聚合出每日销售额,再按渠道计算Q1和Q3,最后只保留落在IQR边界内的聚合记录。注意离群值过滤发生在聚合结果之上,而不是回到明细订单表。
WITH daily_metrics AS (
SELECT
channel,
order_date,
SUM(amount) AS total_amount
FROM orders
GROUP BY channel, order_date
),
bounds AS (
SELECT
channel,
PERCENTILE_CONT(0.25) WITHIN GROUP (ORDER BY total_amount) AS q1,
PERCENTILE_CONT(0.75) WITHIN GROUP (ORDER BY total_amount) AS q3
FROM daily_metrics
GROUP BY channel
)
SELECT
d.channel,
d.order_date,
d.total_amount,
b.q1,
b.q3,
b.q3 + 1.5 * (b.q3 - b.q1) AS upper_bound,
b.q1 - 1.5 * (b.q3 - b.q1) AS lower_bound
FROM daily_metrics d
JOIN bounds b ON d.channel = b.channel
WHERE d.total_amount BETWEEN b.q1 - 1.5 * (b.q3 - b.q1)
AND b.q3 + 1.5 * (b.q3 - b.q1);
如果不使用四分位距,也可以直接按相对排名过滤。PERCENT_RANK函数返回0到1之间的相对排名,0表示组内最小值,1表示组内最大值。下面这段SQL保留每个渠道内部排名在5%到95%之间的聚合值,适合没有专门百分位聚合函数的数据库。
WITH ranked AS (
SELECT
channel,
order_date,
total_amount,
PERCENT_RANK() OVER (
PARTITION BY channel
ORDER BY total_amount
) AS pct_rank
FROM daily_metrics
)
SELECT
channel,
order_date,
total_amount
FROM ranked
WHERE pct_rank BETWEEN 0.05 AND 0.95;
两种方法各有适用场景。IQR规则对单侧极端值更敏感,能够同时识别高值和低值;P5到P95的区间更直观,但数据量较少时,排名边界可能会把正常值也切成离群点。实际项目中可以先做出分布图,再决定采用哪一套边界。
三、不同数据库下的百分位函数与兼容写法
标准SQL提供了PERCENTILE_CONT和PERCENTILE_DISC两个有序聚合函数,PostgreSQL、SQL Server、Oracle都支持。它们的区别在于当目标百分位落在两个值之间时,PERCENTILE_CONT会返回插值结果,PERCENTILE_DISC则返回实际存在的数据值。对于边界划定来说,通常使用连续插值更平滑。
MySQL 8.0之前的版本没有内置百分位聚合函数,MySQL 8.0虽然引入了窗口函数,但仍然缺少PERCENTILE_CONT。这时可以用NTILE(100)把数据分成100个桶,再取第5和第95个桶的边界。下面是一个MySQL兼容写法:
-- MySQL 8.0 中用 NTILE 近似百分位边界
WITH tiled AS (
SELECT
channel,
order_date,
total_amount,
NTILE(100) OVER (
PARTITION BY channel
ORDER BY total_amount
) AS tile
FROM daily_metrics
),
bounds AS (
SELECT
channel,
MAX(CASE WHEN tile = 5 THEN total_amount END) AS p05,
MAX(CASE WHEN tile = 95 THEN total_amount END) AS p95
FROM tiled
GROUP BY channel
)
SELECT
t.channel,
t.order_date,
t.total_amount
FROM tiled t
JOIN bounds b ON t.channel = b.channel
WHERE t.total_amount BETWEEN b.p05 AND b.p95;
SQL Server的写法与PostgreSQL略有不同。SQL Server允许在SELECT中使用PERCENTILE_CONT搭配WITHIN GROUP和OVER子句,但不可以同时使用GROUP BY,因此需要借助DISTINCT或子查询去重。Oracle除了标准百分位函数外,还提供APPROX_PERCENTILE用于大数据量下的近似计算。如果是BigQuery,可以直接使用APPROX_QUANTILES函数,一次计算多个分位值。
四、落地时容易忽略的四个细节
第一个细节是去噪层级。对聚合结果去噪不等于对明细去噪。如果某个渠道只有两天数据,其中一天异常,这个渠道的整组指标可能都会因为样本太少被划进离群范围。此时需要判断是否应该保留该渠道,而不是机械删除。通常建议先在明细层做基础过滤,再进行聚合层百分位去噪。
第二个细节是边界要不要固定。如果每天重新计算动态边界,阈值会随着数据变化而漂移,不利于监控和复盘。更稳定的是用一段历史窗口,比如过去90天的聚合数据计算出上下界,存到参数表中,再应用到当天数据。
-- 将历史 90 天数据计算出的分位边界固化成参数表
CREATE TABLE metric_bounds (
metric_name VARCHAR(64) PRIMARY KEY,
lower_bound NUMERIC(14,4),
upper_bound NUMERIC(14,4)
);
INSERT INTO metric_bounds (metric_name, lower_bound, upper_bound)
SELECT
'daily_channel_amount',
PERCENTILE_CONT(0.05) WITHIN GROUP (ORDER BY total_amount),
PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY total_amount)
FROM daily_metrics
WHERE order_date BETWEEN CURRENT_DATE - INTERVAL '90 days' AND CURRENT_DATE;
第三个细节是性能。百分位计算通常依赖排序,数据量大时会消耗较多临时空间和CPU。可以限制计算窗口,只对最近三个月的数据做分位统计;也可以在排序列和分组列上建立合适索引。对于超大表,优先考虑近似百分位函数,而不是精确计算。
第四个细节是业务验证。去噪之后需要抽样检查被剔除的聚合记录,确认它们真的是噪声而不是业务新趋势。如果某类高值开始频繁出现,说明原有边界可能已经过时,这时应当调整分位范围或拆分新的维度,而不是继续用旧阈值过滤。