4. 开发第一个自定义 Expectation:校验列中所有值均为正数

好,我们正式开始动手写代码。

前面几章我们把 Great Expectations 的架构、插件体系、以及 Expectation 的生命周期都捋了一遍。说实话,理论讲再多,不如亲手写一个来得实在。我个人习惯是,学一个新框架,先照着文档抄一个最小的例子跑通,然后再去理解它为什么这么设计。

这一章我们就来干这件事——写一个自定义 Expectation,功能很简单:校验某一列的所有值是否都大于 0。你别看它简单,麻雀虽小五脏俱全。写完这个,后面那些复杂的、带自定义 Metrics 的、甚至需要跑 Spark 的 Expectation,你都能举一反三。

4.1 先想清楚我们要做什么

在敲键盘之前,我们先明确一下目标。

这个 Expectation 叫 expect_column_values_to_be_positive。它的输入是一列数据,输出是一个 Expectation Validation Result。如果列里所有值都大于 0,就通过;但凡有一个 ≤ 0 的,就不通过。

你可能会问:「Great Expectations 不是已经有 expect_column_values_to_be_between 了吗?把下限设成 0 不就行了?」

嗯,理论上可以。但我在项目中遇到过几次这种情况:业务方要求「金额必须为正数」,但数据里偶尔会出现 0 或者负数。如果用 expect_column_values_to_be_between,你得手动把下限设成 0.000001,还得处理边界问题。更关键的是,语义不清晰。你写一个 expect_column_values_to_be_positive,任何人一看名字就知道你在检查什么。这就是自定义 Expectation 的价值——把业务规则显式化。

核心原则:自定义 Expectation 不是为了炫技,而是为了把领域知识编码成可复用、可共享的数据质量规则。

4.2 项目结构准备

Great Expectations 的自定义插件有固定的目录结构。我建议你按照官方推荐的方式来组织代码,这样后续升级框架版本时不容易出问题。

在你的 Great Expectations 项目根目录下,创建如下结构:

great_expectations/
├── plugins/
│   └── expectations/
│       └── expect_column_values_to_be_positive.py
└── ... 其他目录

plugins/expectations/ 这个路径是 Great Expectations 默认会扫描的插件目录。你把自定义 Expectation 放进去,框架启动时就会自动加载。

小技巧:如果你用的是 Great Expectations Cloud 或者部署在容器里,记得把 plugins 目录挂载进去。我曾经因为忘记挂载,排查了半天为什么自定义 Expectation 找不到……

4.3 编写 Expectation 类

现在打开 expect_column_values_to_be_positive.py,开始写代码。

一个自定义 Expectation 的核心,是继承 ExpectColumnValuesToBeBetween 或者更通用的 ColumnMapExpectation。我们这里用 ColumnMapExpectation,因为它天然适合「对每一行做判断」的场景。

from great_expectations.expectations.expectation import ColumnMapExpectation
from great_expectations.expectations.metrics import column_condition_partial

class ExpectColumnValuesToBePositive(ColumnMapExpectation):
    """Expect all values in a column to be positive (greater than 0)."""

    # 1. 定义 metric 的计算逻辑
    @column_condition_partial(engine="pandas")
    def _pandas(cls, column, **kwargs):
        return column > 0

    # 2. 定义期望的元数据
    map_metric = "_pandas"
    success_keys = tuple()
    default_kwarg_values = {}

    # 3. 定义库信息(用于文档和 UI 展示)
    library_metadata = {
        "maturity": "experimental",
        "tags": ["custom", "positive-values"],
        "contributors": ["your_name"],
    }

代码不长,但有几个关键点我得强调一下。

4.3.1 @column_condition_partial 装饰器

这个装饰器告诉 Great Expectations:我这个 Expectation 的 Metric 计算逻辑,是对每一列数据应用一个条件判断。你传入一个 Pandas Series,返回一个布尔 Series。框架会自动帮你处理聚合、统计、生成结果对象这些事情。

如果你需要支持 Spark 或者 SQLAlchemy,可以再加一个 @column_condition_partial(engine="spark") 或者 @column_condition_partial(engine="sqlite")。不过我们这一章先只做 Pandas,把流程跑通再说。

4.3.2 map_metric 和 success_keys

map_metric 指向你定义的 Metric 方法名。这里我们写的是 "_pandas",所以 map_metric = "_pandas"。

success_keys 是一个元组,用来声明这个 Expectation 有哪些参数会影响「成功」的判断。我们这个例子没有任何额外参数,所以是空元组。如果你要写一个 expect_column_values_to_be_positive_with_threshold,允许用户指定「允许有多少比例的非正数」,那 success_keys 里就要加上 "mostly"。

注意:success_keys 和 default_kwarg_values 必须匹配。如果你在 success_keys 里声明了 "mostly",但 default_kwarg_values 里没有给默认值,框架会报错。我刚开始写的时候踩过这个坑,排查了半天。

4.4 注册并测试

代码写完了,怎么让它生效?

Great Expectations 会自动扫描 plugins/expectations/ 目录下的所有 Python 文件,并加载其中继承自 Expectation 的类。你只需要重启你的 Great Expectations 进程(或者重新加载 Notebook 内核),就可以在 expect_* 的自动补全里看到它了。

我们来写一个简单的测试:

import pandas as pd
from great_expectations.dataset import PandasDataset

df = pd.DataFrame({"amount": [100, 200, 300, -50, 0]})
ds = PandasDataset(df)

result = ds.expect_column_values_to_be_positive("amount")
print(result)

输出应该是:

{
  "success": False,
  "result": {
    "element_count": 5,
    "unexpected_count": 2,
    "unexpected_percent": 40.0,
    "unexpected_list": [-50, 0]
  }
}

看到 success: False,说明我们的 Expectation 生效了。它准确地识别出了 -50 和 0 这两个非正数。

调试技巧:如果你发现自定义 Expectation 没有被加载,先检查一下 great_expectations --version 和你的 Python 环境。有时候是因为虚拟环境没切对,插件目录没在正确的项目下。我习惯在项目根目录下运行 python -c "from great_expectations.expectations import Expectation; print(Expectation.__subclasses__())" 来确认自定义类是否被注册成功。

4.5 理解背后的执行流程

光会写还不够,你得知道它跑起来的时候内部发生了什么。我画了一张图,帮你理清这个流程。

自定义 Expectation 执行流程 用户调用 Expectation 框架解析 Expectation 配置 执行 Metric(_pandas) 生成 Expectation Validation Result 返回 success / result 关键点 • @column_condition_partial 自动处理聚合与统计 • 开发者只需关注条件逻辑

你看,整个流程其实很清晰。开发者只需要关注「条件逻辑」那一块——也就是 column > 0 这行代码。剩下的解析、聚合、结果生成,框架全帮你干了。这就是 Great Expectations 的设计哲学:把复杂留给自己,把简单留给用户。

4.6 进阶:添加参数支持

上面那个例子是最简版本。实际项目中,你很可能需要支持 mostly 参数——允许一定比例的数据不满足条件。比如「允许 5% 的金额为 0 或负数」,这在业务上很常见。

我们来升级一下:

from great_expectations.expectations.expectation import ColumnMapExpectation
from great_expectations.expectations.metrics import column_condition_partial

class ExpectColumnValuesToBePositive(ColumnMapExpectation):
    @column_condition_partial(engine="pandas")
    def _pandas(cls, column, **kwargs):
        return column > 0

    map_metric = "_pandas"
    
    # 添加 mostly 参数支持
    success_keys = ("mostly",)
    default_kwarg_values = {
        "mostly": 1.0,  # 默认必须全部为正数
    }

    library_metadata = {
        "maturity": "experimental",
        "tags": ["custom", "positive-values"],
        "contributors": ["your_name"],
    }

改动很小,就两处:

  • success_keys 加上了 "mostly"
  • default_kwarg_values 给了默认值 1.0(100% 必须通过)

现在你可以这样调用:

result = ds.expect_column_values_to_be_positive("amount", mostly=0.95)

如果 100 行数据里有 3 行是负数,mostly=0.95 意味着最多允许 5% 的异常,3% 在范围内,所以 success 会是 True。

经验之谈:我在做金融风控数据质量监控时,经常用 mostly 参数来设置「容忍度」。因为生产环境的数据永远不可能 100% 干净,设置一个合理的容忍度,可以避免因为零星脏数据导致整个 pipeline 报警中断。

4.7 常见问题与避坑

写自定义 Expectation 的过程中,有几个坑我帮你提前踩了。

问题 原因 解决方案
自定义 Expectation 找不到 插件目录不在框架扫描路径中 检查 plugins/expectations/ 是否存在,重启进程
Metric 返回类型不对 @column_condition_partial 期望返回布尔 Series 确保你的条件表达式返回的是 pd.Series(bool)
success_keys 与 default_kwarg_values 不匹配 框架在合并参数时发现缺失默认值 检查两个字段的 key 是否完全一致
升级框架版本后报错 API 有变动,比如 column_condition_partial 的导入路径变了 查看官方 Changelog,更新导入路径

我曾经在升级 Great Expectations 0.13 到 0.14 时,发现 column_condition_partial 的导入路径变了,导致所有自定义 Expectation 全部失效。那次教训让我养成了一个习惯:每次升级框架前,先在测试环境跑一遍所有自定义 Expectation 的单元测试。

4.8 小结

这一章我们走完了自定义 Expectation 的完整流程:从项目结构、代码编写、注册测试,到理解执行流程和添加参数支持。你写出来的 expect_column_values_to_be_positive 虽然简单,但它包含了自定义 Expectation 的所有核心要素。

说白了,你只要掌握了这个套路,后面无论是写复杂的统计型 Expectation,还是对接 Spark 引擎,本质上都是在做同样的事情——定义 Metric 逻辑,声明参数,然后让框架帮你处理剩下的。