做Power BI的人基本都遇过这类时刻:数据清洗已经在Power Query里做完了,下一步想快速知道当前这张表里各数值字段的均值、中位数、标准差大概长什么样,有没有异常高或异常低的离群值。多数人的第一反应是把数据复制回Excel算一遍,或者直接去报表页拖几个DAX度量值,但其实这一步完全可以在Power Query里顺路做完——M语言里有一整套描述性统计的现成函数,配合一个小自定义函数,就能把当前数据的描述性统计直接生成一张可刷新的结果表,不用来回切工具。
这篇文章我就按这个思路,把Power Query做描述性统计分析的函数清单、M代码和踩坑经验完整讲一遍。适合刚接触M语言的新手照着抄,也适合已经在做数据质量校验的读者,看看怎么把统计摘要下沉到建模之前,给后续工作省掉一堆手工活。
1. 先想明白:描述性统计放进Power Query做,到底是图什么
1.1 Power Query统计的本质:在数据管道里提前固化结果
很多人一听Power Query做统计分析,第一反应是"这不该是DAX的活吗"。其实两者解决的问题完全不同。DAX做的是交互式分析,计算发生在报表引擎里,切片器一切、钻取一打,数值立刻跟着筛选上下文变化;而Power Query做统计发生在数据加载之前,计算的对象是"当前这一步的数据"——也就是经过清洗、转换之后、即将进入数据模型的这张表。
这个区别带来一个很实际的优势:统计结果是跟着刷新任务自动重算的。你不需要每次数据更新之后重新复制粘贴、重新拉公式,只要原始数据一变,Power Query刷新跑一遍,描述性统计表自动就出来了。对需要定期核查数据质量的场景来说,这种"可刷新"比"会交互"值钱得多。
我在实际项目里最常用的场景有三个:一是数据质量检查,看每个字段的空值数量、极值、均值有没有超出业务常识;二是给上游建模提供参考,比如决定某个字段要不要进模型、要不要做标准化处理;三是生成固定的统计摘要表,直接丢给业务方做汇报素材。这三类需求都对交互性没有要求,但对稳定性和可追溯性有要求,Power Query刚好合适。
1.2 什么场景别用PQ,及时转向DAX
反过来说,如果你需要做的是"用户点一个切片器,报表里的均值跟着变"这种动态分析,那就别在Power Query里硬做。Power Query计算的是全局静态结果,它没有报表筛选上下文的概念,切片器不可能触发它重新计算。这种情况下老老实实写DAX度量值,比如用AVERAGE、MEDIAN这类聚合函数,交给报表引擎去处理。
另外,如果统计结果的精度需要由当前行上下文决定,比如"每个产品类别里高于本类别均值的产品",这也是DAX的优势场景。Power Query适合做的是"整表视图"和"分组汇总视图"这两种静态描述,理解了这条边界,后面选型就不会纠结。
2. M语言统计函数速查:计算口径、参数和注意事项
2.1 常用统计函数及其口径
M语言做描述性统计,核心都是围绕List(列表)类型转的。你先把一列数据用Table.Column取出来变成一个List,剩下的统计工作就是挑函数的问题。下面这张表是我日常真正用到的函数,每个都标注了计算口径:
| 函数 | 作用 | 计算口径与注意事项 |
|---|---|---|
| List.Average | 算术平均值 | 自动忽略null,但会对文本报错 |
| List.Median | 中位数 | 忽略null;偶数个数值时取中间两数平均 |
| List.StdDev | 样本标准差 | 除以n-1,对应Excel的STDEV.S |
| List.StdevP | 总体标准差 | 除以n,对应Excel的STDEV.P |
| List.Variance | 样本方差 | 标准差的平方,口径与上一致 |
| List.VarianceP | 总体方差 | 除以n,口径注意区分 |
| List.Sum | 求和 | 忽略null |
| List.Max / List.Min | 最大值 / 最小值 | 空List返回null |
| List.Count | 元素个数 | 包含null在内 |
| List.Quantile | 分位数 | 第二参数填0到1之间的小数,可指定四分位模式 |
标准差这东西看似简单,实际最容易闹口径纠纷。List.StdDev算的是样本标准差,也就是除以n-1,这是Excel里STDEV.S的算法,也是大多数统计软件默认的估计方式。但如果你手上的数据本身就是全量总体,比如整年全国订单都在这张表里,那应该用List.StdevP,除以n。选错了结果会差一截,后面的误判也随之而来。
List.Quantile也有个容易被忽略的点:第二参数填0.25得到的是第一四分位数,填0.5得到的是中位数,填0.75是第三四分位数,填0.9就得到第90百分位。部分版本里还支持通过options参数指定四分位模式,用来对齐Excel的QUARTILE.INC和QUARTILE.EXC:
List.Quantile(有效列表, 0.25, [ QuartileMode = QuartileMode.Inclusive ])如果你的编辑器版本不支持这个options参数,直接用两个参数的基础写法也不会出错,只是计算细节可能和Excel的旧函数略有差异,心里有数就行。
2.2 容易被忽略的Table.Profile:一张表的统计量自动全集
单列统计可以用上面这些List函数,但如果你想把整张表的数值列一口气全部过一遍,最省事的是Table.Profile。这个函数名字很多人不熟悉,但它做的事情恰好就是"当前数据的描述性统计分析"——输入一张表,输出一张新的统计表,每一行对应原表的一个字段,每一列对应一个统计量。
let 当前数据 = 清洗后的表, 统计结果 = Table.Profile(当前数据) in 统计结果Table.Profile输出的表里通常包含这些列(不同版本字段名稍有差异):Count(计数)、NullCount(空值计数)、EmptyCount(空字符串计数)、Min(最小值)、Max(最大值)、Median(中位数)、Average(平均值)、StdDev(标准差),新一点的构建版本还会带上Sum、Mode、ErrorCount等。一次性就能看到整张表的"体检报告",非常适合在建模前做数据全貌摸底。
需要注意,Table.Profile会把所有字段都纳入统计,包括文本和日期字段。文本字段的Min、Max按字典序排,日期字段按时间序排,这些信息看个大概没问题,真正要做数值专项统计时,还是回到List函数逐个字段处理更精确。
2.3 众数没有现成函数,用Table.Group补一个
描述性统计六件套里,平均值、中位数、极值、方差、标准差M语言都有现成函数,唯独众数是个例外。M语言标准库没有提供List.Mode之类的直接函数,需要自己做分组计数再取最大值。我一般这么写:
let 源 = List.RemoveNulls(Table.Column(当前数据, "产品类别")), 临时表 = Table.FromList(源, Splitter.SplitByNothing(), {"值"}), 分组 = Table.Group(临时表, "值", {{"次数", each Table.RowCount(_), Int64.Type}}), 排序 = Table.Sort(分组, {{"次数", Order.Descending}}), 众数 = try 排序[值]{0} otherwise null in 众数思路不复杂:先把目标列转成列表,再转成临时表,按值分组统计次数,降序排序后取第一行。用try包一层是为了防止全表空值导致取不到第一行时直接报错。多模态的情况如果有,你可以把排序后的结果表整体输出,而不是只取第一行。
3. 从"当前数据"到统计结果表:一套可以直接抄的M代码
3.1 UI层面的快捷入口
在写代码之前,先提一下编辑器的快捷按钮。选中一列数值,切到"转换"选项卡,在"统计信息"组里能看到平均值、中位数、最小值、最大值、计数、非重复计数、求和、标准差这几个按钮。点一下,当前列就会被替换成对应的统计值,M代码也会自动生成。
这里有两个实操细节要提醒。第一,这种按钮默认是"替换"操作,会把你选中的原始列干掉,如果你只是想把统计值放到新列里,需要先复制原列,或者看你的Power BI版本里"添加列"选项卡是否也有对应的统计信息按钮——有的版本已经补上这个入口,用那个就不会破坏原列。第二,按钮生成的代码非常简单,说白了就是调用一次List函数,但换成你自己写的时候,建议先手动把null清理掉,再做统计,这样结果更可控。
3.2 单列统计模板
先给一个可以直接抄的单列统计模板。这里"当前数据"我用的占位名称,实际写的时候换成你自己查询里最后一步的名字就行:
let 当前数据 = 清洗完成后的表, 数值列 = Table.Column(当前数据, "销售额"), 有效列表 = List.RemoveNulls(数值列), 记录总数 = List.Count(数值列), 有效记录数 = List.Count(有效列表), 空值数量 = 记录总数 - 有效记录数, 平均值 = List.Average(有效列表), 中位数 = List.Median(有效列表), 样本标准差 = List.StdDev(有效列表), 总体标准差 = List.StdevP(有效列表), 最小值 = List.Min(有效列表), 最大值 = List.Max(有效列表), 下四分位 = List.Quantile(有效列表, 0.25), 上四分位 = List.Quantile(有效列表, 0.75), 统计结果 = [ 字段名 = "销售额", 记录数 = 记录总数, 有效数 = 有效记录数, 空值数 = 空值数量, 平均值 = 平均值, 中位数 = 中位数, 样本标准差 = 样本标准差, 总体标准差 = 总体标准差, 最小值 = 最小值, 最大值 = 最大值, 下四分位 = 下四分位, 上四分位 = 上四分位 ] in Table.FromRecords({统计结果})这里有几个细节值得说。先取Table.Column得到整列列表,再通过List.RemoveNulls生成一个剔除空值的有效列表,后面的平均值、中位数、标准差全部基于有效列表去算,这样才能保证多个统计量之间的可比较性。记录总数和有效记录数放在一起,顺手就得到了空值数——这四个数本身就是数据质量检查里最基础的指标。
3.3 批量统计所有数值列
单列模板只能处理一个字段,实际工作中一张表动辄十几个数值列,一个个复制粘贴太蠢了。正确做法是写一个自定义函数,再用List.Transform批量跑一遍。先定义一个函数查询,假设这个查询叫"fn描述统计":
(输入表 as table, 字段 as text) as record => let 原始列表 = Table.Column(输入表, 字段), 有效列表 = List.RemoveNulls(原始列表), 结果 = [ 字段名 = 字段, 记录数 = List.Count(原始列表), 有效数 = List.Count(有效列表), 空值数 = List.Count(原始列表) - List.Count(有效列表), 平均值 = try List.Average(有效列表) otherwise null, 中位数 = try List.Median(有效列表) otherwise null, 标准差 = try List.StdDev(有效列表) otherwise null, 最小值 = try List.Min(有效列表) otherwise null, 最大值 = try List.Max(有效列表) otherwise null ] in 结果然后在一个新查询里调用它,自动识别表里的全部数值字段:
let 当前数据 = 清洗完成后的表, 模式表 = Table.Schema(当前数据), 数值字段 = Table.SelectRows(模式表, each [Kind] = "number")[Name], 统计列表 = List.Transform(数值字段, each fn描述统计(当前数据, _)), 结果表 = Table.FromRecords(统计列表) in 结果表Table.Schema这个函数返回的是数据表的列结构信息,其中Kind这一列记录了每列的数据类型类别,"number"就是数值类型。用这种方式筛选字段,比手工维护一个字段名单要聪明得多——源表结构一旦增加新字段,刷新后自动就纳入统计了。函数里每个统计项都用try包一下,是因为某些列虽然在Schema里显示是number,实际却可能混入出错的值,try能保证单列出错时不影响整批任务。
3.4 分组统计的写法
单列批量统计回答的是"整张表的数值长什么样",但业务问题经常是"每个类别分别长什么样",这时候用Table.Group按业务维度分组,再在组内做描述性统计:
let 当前数据 = 清洗完成后的表, 分组结果 = Table.Group(当前数据, "产品类别", { {"记录数", each Table.RowCount(_), Int64.Type}, {"销售额均值", each List.Average(Table.Column(_, "销售额")), type number}, {"销售额中位数", each List.Median(Table.Column(_, "销售额")), type number}, {"销售额标准差", each List.StdDev(Table.Column(_, "销售额")), type number}, {"销售额合计", each List.Sum(Table.Column(_, "销售额")), type number}, {"销售额最小值", each List.Min(Table.Column(_, "销售额")), type number}, {"销售额最大值", each List.Max(Table.Column(_, "销售额")), type number} }) in 分组结果Table.Group的每一组里,下划线_代表当前分组形成的小表,再配合Table.Column取指定列,就可以继续使用之前那套List函数。这种写法生成的表天然就是一张"分组描述性统计表",每一行是一个产品类别,每一列是一个统计口径,导入模型后直接可以用。
分组统计时有个容易踩的坑:分组依据列本身的类型如果发生了变化,比如文本列混入了数值,分组结果会莫名其妙多出几个组。解决思路是在分组之前,先对分组依据列做一次Table.TransformColumnTypes,把类型彻底锁死再分。
4. "当前数据"到底动态在哪:刷新、参数与相对时间窗口
4.1 刷新机制:统计结果为什么能自动更新
标题里的"当前数据",我理解有两层含义:一是查询管道中当前这一步的表,二是每次刷新时最新的数据。Power Query的这个特性正好把两层都覆盖到了——查询里所有步骤都是延迟执行的,每次刷新都会从源头重新读取数据,一路执行到统计步骤,所以统计表永远基于最新数据重算,这是它天然的优势。
理解这个机制后,你就能放心地把统计查询独立建出来,让它依赖某个清洗好的基础查询。基础查询一改,统计查询刷新后自动跟随;源数据一更新,整条链跟着变动。这里的关键是保持查询之间的引用关系清晰,不要在统计查询里复制粘贴基础数据,而是通过查询名称直接引用。
4.2 参数化切换统计范围
光有自动更新还不够,很多场景需要"同一个统计模板,切换不同的统计范围"。Power Query里的参数正好干这个。在"主页"选项卡里打开"管理参数",新建一个文本类型的参数,比如叫"统计维度",值填"华东区",然后在统计查询里引用它:
let 当前数据 = Table.SelectRows(清洗完成后的表, each [区域] = 统计维度), 统计结果 = Table.Profile(当前数据) in 统计结果这样当你需要切换统计区域时,不用去改查询步骤,只改参数值再刷新即可。参数还可以用于切换统计字段、切换分组依据、切换时间范围,本质上就是把变化的部分从代码里剥离出来,集中到一个可维护的入口。
4.3 相对时间窗口的落地写法
"当前数据"还有个高频场景是统计最近一段时间的数据,比如最近7天、最近30天的订单统计。M语言里用DateTime.LocalNow()取当前时间,再用Date.AddDays往前推窗口,写法如下:
let 当前日期 = DateTime.Date(DateTime.LocalNow()), 窗口起点 = Date.AddDays(当前日期, -30), 当前数据 = Table.SelectRows(明细表, each [订单日期] >= 窗口起点 and [订单日期] <= 当前日期), 统计结果 = Table.Profile(当前数据) in 统计结果这里有个易错点:如果订单日期列是datetime类型,而窗口起点是date类型,比较运算会因为类型不匹配报错或漏数据。稳妥做法是先统一格式——把日期列用Table.TransformColumnTypes改成type date,再参与比较。另外,DateTime.LocalNow()在每次刷新时都会取新值,所以这个窗口会随时间自动滚动,正好符合"当前数据"的语义。
5. 统计结果出现异常时,我实际踩过的坑和处理方法
5.1 看着像数字,其实列的类型是文本
这是我在实际项目里遇到最多的坑。某些数据源导出的文件里,数值列因为混入了一个空字符串或者千分位逗号,整列被自动识别成文本,表面上看每个单元格都像数字,但List.Average一执行就直接报错。解决方法是统计之前先把类型强制转换:
当前数据 = Table.TransformColumnTypes(清洗完成后的表, {{"销售额", type number}})会在转换过程中把文本转不了的单元格标记成错误值,所以转换后用Table.Profile看一眼ErrorCount,或者统计时对List加try保护,就能把这类脏数据暴露出来。
5.2 空值、0值和错误值对统计结果的三重影响
空值、0值、错误值,这三者在描述性统计里的待遇完全不同。M语言里List.Average会自动忽略null,但不会忽略0——一个"没有记录"和一个"数值是0"在业务上是两回事,在均值计算里却是完全不同的两种结果。如果你不先做List.RemoveNulls,空值倒是能跳过,但只要混入几个真正的0值,均值就会被明显拉低。
我的处理习惯是:先统计空值数量,再决定是剔除还是补充。如果是数据采集漏了,空值应该剔除;如果业务口径里缺失等于0,那就要人为把null替换成0再统计。这个决策必须前置,否则后面所有统计量都建立在错误的假设上。错误值又是另一回事,它们既不会像null一样被忽略,也不会像数字一样参与计算,而是直接让整个List操作报错。所以凡是从不可靠数据源取数,统计前的try保护都值得写。
5.3 样本标准差还是总体标准差:口径导致的结论偏差
标准差口径选错了,结果相差可能超过10%,这在业务分析里足以改变结论。举个例子说明:某指标样本标准差假设是15,用总体标准差算出来可能是14.2,看似差别不大,但当你用"均值正负一个标准差"来判断异常范围时,边界就会偏移,某些边缘值就被误划进了异常区。
实操中我的判断规则很简单:数据是抽样得到的就用List.StdDev,数据是全量记录就用List.StdevP。如果拿不准,把两个都算出来放在统计表里,让下游自己选,这比拍脑袋定一个口径更负责任。
5.4 大数据量下别反复取列
Table.Column这个操作会把整列数据实体化成列表,一次两次没事,但如果你在一个循环或者大表的分组逻辑里反复调用它,性能会快速恶化。我碰到过一张七八百万行的表,分组统计时在Table.Group的聚合函数里写了五六个Table.Column调用,结果刷新时间从几十秒暴涨到十分钟以上。
优化思路有两条:一是如果能用Table.Profile一次性出全量概要,就不要每个字段单独跑统计;二是如果确实需要对多个字段分别做List函数计算,先用Table.Buffer或者List.Buffer把数据缓存起来,避免反复从源表取列。另外,分组统计里能合并的聚合尽量在同一个Table.Group里完成,一次遍历输出多个统计量,而不是拆成多次Table.Group再合并。
6. 选型参考:Power Query、DAX和Excel的分工
6.1 三者的边界对比
用一张表说清楚我平时怎么在Power Query、DAX和Excel之间做选择:
| 维度 | Power Query | DAX | Excel |
|---|---|---|---|
| 计算时机 | 数据刷新时一次性完成 | 报表交互时即时计算 | 手动触发或刷新 |
| 交互性 | 无,静态结果 | 强,跟随切片器与筛选上下文 | 弱,需手工切换 |
| 适合场景 | 数据质量检查、固定摘要、建模前探索 | 报表可视化、动态聚合、钻取分析 | 快速临时分析、一次性探索 |
| 结果可复用性 | 高,可作为查询被其他查询引用 | 高,可在多张报表页复用度量值 | 低,容易复制到别处就断了引用 |
| 维护成本 | 中,函数封装好后批量复用 | 中,度量值多了需要整理口径 | 高,容易手工改错 |
说实话,这三者并不是替代关系。我现在的工作流里,Power Query负责的是"进入模型之前的一切体检":数据质量、描述性统计、异常值识别都在这个阶段解决;DAX负责的是"进入模型之后的一切动态分析":均值、同比、分组对比跟随用户交互实时变化;Excel则只留在数据量小、一次性探索的场景里用。
6.2 我的实操习惯
最后分享一点个人经验。我在拿到一张新表的第一个动作,永远不是急着建关系、写度量值,而是先在Power Query里跑一遍Table.Profile,把字段个数、空值情况、均值、标准差、极值全部摊开看一遍。这个习惯帮我挡掉了大量后面要做返工的脏数据问题。
另一个习惯是给统计查询写好命名和注释。描述性统计表通常会被其他查询引用,过两个月再打开文件时,如果不记得每个字段的计算口径,又得从头排查。我会在查询里加一个注释步骤,写明统计窗口、标准差口径、空值的处理方式,这比任何文档都管用。