打造 ML/AI 系统的内部开发者平台(IDP)——监控与可解释性

116 阅读37分钟

本章涵盖

  • 为 ML 应用搭建监控与日志
  • 使用 Alertmanager 路由告警
  • 使用 Loki 存储日志,实现可扩展的日志聚合与查询
  • 识别数据漂移(data drift)
  • 使用模型可解释性理解 ML 模型如何做决策

把模型推到生产只是第一步——要让它长期稳定可靠地工作,需要健壮的监控能力,并理解模型行为。本章将探索如何为 ML 系统实现全面监控,并洞察其决策过程(图 11.1)。

image.png

图 11.1 心智地图:我们现在聚焦于模型监控(8)

我们会从两个关键角度来解决监控问题。首先,我们会搭建基础的运维监控,确保服务满足性能与可靠性要求。
然后,我们会实现 ML 特有的监控,用于检测数据漂移并跟踪模型行为。模型监控可以拆成两大部分:

  • 基础监控
  • 数据漂移监控

基础监控指确保已部署服务的运转效率。我们的模型服务最终会与组织内其他服务集成,并必须满足所需的服务等级协议(SLAs)。常见 SLA 指标包括可用性(uptime)、吞吐(throughput)、响应延迟(response latency)与响应质量(response quality)。大多数生产环境服务都会有固定的错误预算(error budget,即可接受的不可靠程度),因此维持服务稳定性并对不可预见问题快速响应至关重要。

我们还需要搭建数据漂移监控,确保进入系统的数据及其与目标变量之间的关系,仍与模型训练时观察到的一致。快速识别数据漂移的原因,有助于保证 ML 服务质量满足预期,并指导我们改进特征工程、更新训练数据,或对模型进行再训练以持续提升性能。

我们将使用 BentoML 内置仪表盘做基础监控,演示如何为 BentoML 服务添加自定义指标,以及如何收集日志以便调试事故。此外,我们会使用 deepchecks 监控数据漂移,确保模型在生产环境中仍然有效且可靠。

最后,我们还会看看模型可解释性。可解释性让我们能够定位哪些特征或输入可能导致模型行为变化,这对于维护 ML 系统的信任与可追责性至关重要。把可解释性整合进监控与维护策略后,我们可以更有效地发现问题,并向干系人解释模型决策背后的原因。

11.1 监控(Monitoring)

没有监控的应用不能算生产就绪。监控通过提前发现性能异常、故障或失败来减少宕机与服务中断。它让我们能通过观察资源利用率与响应时间等重要指标,快速识别并处理事故,从而改善用户体验、保证应用稳定性并维持 SLA。一个有效的监控方案应同时跟踪性能与业务指标,并向能采取行动的人发出告警,以便及时修复故障。

告警之所以关键,是因为它能对应用、系统或基础设施中的关键事故或异常行为提供实时通知。它确保诸如性能退化、服务中断等潜在问题被快速识别,从而可以迅速介入并把停机时间降到最低。在接下来的小节里,我们将为目标检测与电影推荐项目搭建监控与告警。

11.1.1 基础监控(Basic monitoring)

对以 API 端点形式部署的服务而言,基础监控包含两类指标:

  • 资源利用率(Resource utilization)
  • 请求追踪指标(Request tracking metrics)

我们的应用通常会在资源受限的条件下部署,因为内存与 CPU 不能无限增大——这些上限通常在部署时就会指定。应用最多能扩容到多少个 pod 也存在上限。监控这些资源对于确保应用在生产中保持性能至关重要;它还能帮助我们更有效、更优化地分配资源。

追踪请求指标(例如响应延迟、失败的状态码数量(非 200))可以帮助我们识别慢响应或应用错误等问题,使我们能优化应用、防止宕机,并确保其满足性能预期与 SLA。通过监控这些指标,我们可以在问题升级前主动处理。

由于两个应用都通过 BentoML 提供服务,我们将使用 BentoML 提供的预构建 Grafana 仪表盘来监控这些指标。我们在第 3 章已经安装了 Prometheus 与 Grafana,因此这里我们会在 Grafana 中创建一个 dashboard,用于可视化 BentoML 应用的基础监控指标。

BentoML 部署已经自带 /metrics 端点(上一章已展示)。这些预定义指标对基础监控来说完全够用。我们需要确保 Prometheus 正在抓取(scrape)这些指标。

为验证这一点,我们可以打开 Prometheus UI,进入 Service Discovery。访问 Prometheus UI 时,使用 kubectl port-forwardprometheus-server 服务的 80 端口映射到宿主机的 9090:

kubectl port-forward svc/prometheus-server -n prometheus 9090:80 -n prometheus

然后在浏览器访问 http://localhost:9090,点击 Status 选项卡,再点击 Service Discovery。在 Service Discovery 中,搜索 BentoML 时,你会看到类似图 11.2 的界面。列表中应该有一个 podMonitor 对象正在监控 yatai-deployment

image.png

图 11.2 在 Prometheus Service Discovery 中搜索 BentoML

如果由于某些原因看不到,我们可以通过执行下面的 kubectl apply 来创建 PodMonitor:

kubectl apply -f \
https://raw.githubusercontent.com/bentoml/yatai/main/scripts/\
monitoring/bentodeployment-podmonitor.yaml.

几分钟后再查看 Prometheus UI,应该就能看到 PodMonitor。PodMonitor 是 Prometheus 常用的自定义资源,用于监控集群中的特定 pods。它通过指定监控哪些 pods、抓取哪些端口与端点、以及抓取频率,来定义 Prometheus 应如何从 pods 抓取指标。PodMonitor 简化了在动态变化的 pod 环境中发现并收集指标的过程,确保 Prometheus 能实时捕获 pod 内应用的健康、性能与行为。在我们的场景里,这个 PodMonitor 会监控所有 BentoML deployments。

设置好 PodMonitor 后,我们可以在 Prometheus UI 的 Graph 标签页里搜索 bento,验证 Prometheus 服务器中确实存在 BentoML 指标(图 11.3),这些指标来自 /metrics 端点。

image.png

图 11.3 验证 Prometheus 是否在抓取 BentoML 指标

现在指标可用了,我们就能在 Grafana dashboard 中把它们可视化。通常构建 Grafana dashboard 的方式是写几条 Prometheus Query Language(PromQL)查询,并选择对应的可视化方式。由于我们使用的是 BentoML,这里可以直接复用其预构建 dashboard 来满足基础监控需求。要下载该 dashboard,我们先把 dashboard.json 下载到本地路径 /tmp/bentodeployment-dashboard.json

curl -L \ https://raw.githubusercontent.com/bentoml/yatai/main/\ 
scripts/monitoring/bentodeployment-dashboard.json \ 
-o /tmp/bentodeployment-dashboard.json

然后通过 kubectl port-forward 把 Grafana 的 service 80 端口映射到本地 8001:

kubectl port-forward svc/grafana -n grafana 8001:80

接着访问 Grafana UI,点击 Dashboard 选项卡。我们需要导入刚下载的 JSON:选择 New > Import,把 bentodeployment-dashboard.json 的 JSON 内容复制进去,然后点击 Load(图 11.4)。

image.png

图 11.4 在 Grafana 中导入 BentoML dashboard

之后你会在 Dashboards 标签页下看到 BentoML Deployment dashboard。这个 dashboard 包含基础监控所需的所有指标:进行中的请求数、每秒请求数(RPS)、各端点成功率、CPU 与内存使用情况——也就是我们基础监控所需的一切。你甚至可以从下拉框里选择具体的 BentoDeployment(图 11.5)。

image.png

图 11.5 BentoML 基础监控 dashboard

这个 dashboard 足以满足已部署服务的基础监控。但如果我们希望为应用添加一些自定义指标呢?默认指标可能无法捕获你应用行为中的特定细节。自定义指标允许我们追踪业务重要的应用逻辑,比如交易成功率或某类行为的监控。

对目标检测用例而言,我们可能想知道预测对象为 id_card 的次数,以及置信度分数的分布。这些指标对告警与报表都很有用。下一节我们将为 BentoML deployments 启用自定义指标。

11.1.2 自定义指标(Custom metrics)

在目标检测项目中,我们有两个端点:/invocation/render/invocation 返回目标的 bounding box、类别与置信度。我们应该追踪置信度分数,并识别是否存在某些时间段置信度低于预设阈值。这可能表示输入图片质量较差,或模型性能正在退化。要创建自定义指标,我们需要先安装 prometheus-client

pip install prometheus-client

接着,我们为置信度定义一个 Prometheus 的 Histogram 指标。正如第 3 章讨论的,Prometheus Histogram 用于收集并统计观测值(例如请求耗时或大小),并根据预定义的“桶”(buckets)把它们按数值范围分组。它能帮助我们观察数据分布随时间的变化,洞察特定事件发生的频率与幅度,这对性能监控与延迟分析很有用。我们的置信度 bucket 按 0 到 1 的十分位分段。我们通过指定指标名、文档说明与 bucket 列表来定义 Histogram:

confidence_histogram = Histogram(
    name="confidence_score",
    documentation="The confidence score of the prediction",
    buckets=(
      0.1,0.2,0.3,0.4,0.5,0.6,0.7,0.8,0.9,1
    ),
)

metrics.py 中定义该指标后,我们需要调用它内置的 observe 方法,并把置信度作为参数。我们通过加入新指标来修改 service.pyinference 函数:

    @bentoml.Runnable.method(batchable=False)
    def inference(self, input_img):
        results = self.model(input_img)[0]
        response = json.loads(results[0].tojson())
        confidence_histogram.observe(response[0]["confidence"])
        return response

现在用下面命令重新启动服务:

bentoml serve service.py --reload

通过 /invocation 上传一张图片后,我们就可以访问 http://localhost:3000 验证指标是否存在。你会看到我们列出的所有 buckets 及每个 bucket 的计数(le 表示 less than or equal,小于等于)(图 11.6)。

image.png

图 11.6 自定义指标会显示在 /metrics 端点

自定义指标已经能被 Prometheus 抓取后,我们就能在 Grafana 中绘图。我们可以新建一个 Grafana dashboard,并把图表类型设为 Gauge。若要基于 histogram 数据计算 90 分位的置信度分数,可以用下面的 PromQL(目标检测场景):

histogram_quantile(0.9, sum(rate(confidence_score_bucket[5m])) by (le))

Grafana 会得到类似图 11.7 的图表,显示最近 5 分钟模型预测的置信度很高。

image.png

图 11.7 在 Grafana 中用 Gauge 可视化置信度自定义指标

类似地,在电影推荐项目中,我们可能想统计请求中用户或服务提供 ranked_movies 的次数。为此,我们会在 metrics.py 里定义一个 Prometheus 的 Counter 指标,并在 ranked_movies 不为 None 时递增。Counter 表示一个只能增加(或在重启时归零)的累积值:

ranked_movie_present_counter = Counter(
    name="ranked_movie_present_counter",
    documentation=(
        "The number of times ranked movies is "
        "present in the request"),
)
ranked_movie_absent_counter = Counter(
    name="ranked_movie_present_counter",
    documentation=(
        "The number of times ranked movies is "
        "absent in the request"),
)

我们可以在 Grafana 中把它画成时序折线图(图 11.8)。

image.png

图 11.8 在 Grafana 中用折线图可视化 ranked movie counter 自定义指标

持续追踪这些指标,团队就能监控系统健康与趋势,并尽早发现异常。但仅靠指标往往缺少理解单个事件细节所需的颗粒度。下一节我们讨论日志。

11.1.3 日志(Logging)

日志和指标一样重要,因为它能提供指标无法捕捉的、富含上下文的状态与行为信息。指标提供可度量的洞察(如 CPU 使用率、请求数),日志则提供定性细节,例如具体报错信息、堆栈追踪或异常事件。日志能帮助定位错误根因、调试复杂场景,并追踪导致故障的事件序列。指标与日志结合,能给出系统健康的完整画像,使监控更有效、更可执行。

对于我们的 BentoML 应用,我们可以使用 BentoML logger——它本质上就是普通的 Python logger——来记录生产中追踪与调试所需的信息。使用 BentoML logger 只需导入 logging 库、设置格式、获取 BentoML logger 并设置日志级别。我们可以在 service.py 中这样写(见清单 11.1)。

清单 11.1 为 BentoML Service 配置日志

import logging
ch = logging.StreamHandler()    #1
formatter = logging.Formatter( #2
    "%(asctime)s - %(name)s - %(levelname)s - " #2
    "%(message)s")     #2
ch.setFormatter(formatter)
bentoml_logger = logging.getLogger("bentoml")    #3
bentoml_logger.addHandler(ch)    #4
bentoml_logger.setLevel(logging.DEBUG)
#1 控制台 handler
#2 设置日志格式
#3 定义 logger 对象
#4 配置 logger

例如,在目标检测模型中,我们可以用 BentoML logger 记录由于处理错误导致结果为空的情况:

       if len(results) == 0:
            bentoml_logger.error(
                ("Error while processing object detection, "
                 "model returned 0 results"))
            return {"status": "failed"}

如果我们提供的图片尺寸太小无法推理,就能在日志里看到类似错误:

2024-08-23T13:54:54+0800 [ERROR] [runner:yolov8runnable:1] 
Error while processing (trace=dde1822c5c4f36ae1244a7864f38ce97,"
"span=9f3a24ed49c71167,")
sampled=0,service.name=yolov8runnable)
2024-08-23 13:54:54,400 - bentoml - ERROR - Error while processing

如果我们把应用部署在 Google Kubernetes Engine(GKE)或 Elastic Kubernetes Service(EKS)中,可以把日志与指标统一到一个平台:日志会出现在它们各自的监控服务中。但这会导致指标在 Grafana,而日志在 GCP Stackdriver 或 AWS CloudWatch。将日志与指标集中化被认为是最佳实践,集中化有以下优势:

  • 提供统一平台来查看全基础设施的应用性能与事故
  • 聚合多源数据,带来更好的上下文,从而更快定位与解决故障
  • 让多团队更容易访问日志与指标,提升协作效率

Grafana Labs 提供了 Loki——一个开源日志聚合系统,专为与 Grafana 无缝协作而设计。Loki 很轻量,主要索引元数据而不是完整日志内容,因此成本更低且可扩展。它与 Prometheus 与 Grafana 深度集成,使用户能把日志与指标关联起来,获得统一的可观测性体验。其架构也支持在云原生环境中轻松部署,是希望集中日志、又不想承担复杂索引开销团队的理想选择。我们可以用下面的 Helm 命令安装 Loki 并把它集成进 Grafana:

helm install loki grafana/loki  --namespace loki --create-namespace

安装完成后,在 Grafana dashboard 中把左上角数据源切换为 Loki,选择某个 BentoML deployment 作为 App(必要时进一步过滤),在右上角选择时间范围,然后在日志中搜索特定文本(图 11.9)。

image.png

图 11.9 在 Grafana 中使用 Loki 进行日志聚合与查询

通过采集指标并集中日志,我们能洞察系统行为,快速发现并解决事故,确保用户体验平滑。二者共同构成有效可观测性策略的基础,使我们能主动管理事故。

下一节我们将讨论:基于监控与日志采集到的数据设置告警,从而对潜在中断及时通知,确保快速响应并尽量减少停机时间。

11.1.4 告警(Alerting)

如果没有告警,系统故障、性能退化或安全事件可能要到造成严重影响后才会被发现。良好的告警意味着运维团队能采取预防措施来维持应用功能与健康,减少停机并降低对用户的影响。告警通过自动化通知流程,确保责任方能及时获知重大问题。

对我们的应用而言,一个关键告警是监控可用性(uptime)。因为服务会被用户或其他服务调用,必须及时处理任何可能导致宕机的事故。

Alertmanager 是 Prometheus 生态的重要组成部分。Prometheus 生成告警,Alertmanager 按预设规则处理并路由告警。当 Prometheus 检测到问题(如 CPU 过高或服务不可用)时,会把告警发送给 Alertmanager。Alertmanager 处理后,按其配置转发到邮箱、Slack 或其他渠道,确保相关个人或系统能及时获知(图 11.10)。

image.png

图 11.10 Prometheus 生成告警后发送至 Alertmanager,由其路由到 Slack、邮箱或 PagerDuty 等渠道

我们会先设置告警规则,然后配置 Alertmanager 把告警路由到邮箱。Prometheus 告警规则本质上就是带条件的 PromQL 表达式。

如果要监控 BentoML deployments 的存活(up/down),可以使用 up 指标。Prometheus 的 up 是一个特殊指标,用于表示目标或服务是否被成功抓取:目标正常时值为 1,不可用或宕机时为 0:

up{
  job="yatai/bento-deployment", 
  yatai_ai_bento_deployment_component_type=~"api-server|runner"
} == 0

up 在应用遇到错误、指标无法继续被抓取时很有用。但如果 deployment 本身被删除或终止,那么 up 可能就不适用了。此时我们可以使用 absent 函数:用于检测某个指标在特定时间点是否缺失。若指标不存在返回 1,存在返回 0:

absent(up{
  job="yatai/bento-deployment", 
  yatai_ai_bento_deployment_component_type=~"api-server|runner"
}) == 1

有了表达式后,我们还需要定义:服务要 down 多久才触发告警、发送什么消息、告警附带哪些 labels(例如 severity),如下所示。

清单 11.2 为 BentoML Service 设置告警规则

- name: BentoDeploymentServiceAlerts
   rules:
      - alert: ServiceDown
        expr: up{job="yatai/bento-deployment", #1
          yatai_ai_bento_deployment_component_type=~  #1
          "api-server|runner"} == 0     #1
        for: 5m
        labels:
          severity: critical
        annotations:
           summary: "Service {{ $labels.job }} on instance 
             {{ $labels.instance }} is down"
           description: "The job {{ $labels.job }} on instance 
             {{ $labels.instance }} has been 
             down for more than 5 minutes."
    - alert: MissingUpMetric
      expr: absent(up{job="yatai/bento-deployment",
        yatai_ai_bento_deployment_component_type=~
        "api-server|runner"}) == 1  
      for: 5m    #2
      labels:
        severity: critical    #3
      annotations:
         summary: "Instance is missing the 'up' metric for "
         "{{ $labels.instance }}"
         description: "The 'up' metric for {{ $labels.job }} on 
           instance {{ $labels.instance }} has been missing for 
           more than 5 minutes, which may indicate the target is 
           down."
#1 触发告警的 PromQL 条件表达式
#2 条件需持续成立多久才告警
#3 附加到告警上的 label

要加入这些告警,我们必须修改 Prometheus 配置。由于 Prometheus 是用 Helm chart 部署的,我们会在 values 文件里加入新规则:把它们加到 serverFiles > bentoDeploymentRules.yaml 下,并放在同一个告警组里。Prometheus 中的告警组(alert group)是一起评估的一组告警规则,用于按相关标准组织与管理多条告警。配置如下所示。

清单 11.3 在 Prometheus 中配置告警

serverFiles:    #1
  bentoDeploymentRules.yaml:
       groups:
          - name: BentoDeploymentServiceAlerts
            rules:
              - alert: ServiceDown
                expr: up{job="yatai/bento-deployment",
                  yatai_ai_bento_deployment_component_type=~
                  "api-server|runner"} == 0
                for: 5m
                labels:
                  severity: critical
                annotations:
                  summary: "Service {{ $labels.job }} on instance 
                    {{ $labels.instance }} is down"
                  description: "The job {{ $labels.job }} on instance 
                    {{ $labels.instance }} has been down for more than 
                    5 minutes."
              - alert: MissingUpMetric
                expr: absent(up{job="yatai/bento-deployment",
                  yatai_ai_bento_deployment_component_type=~
                  "api-server|runner"}) == 1
                for: 5m
                labels:
                  severity: critical
                annotations:
                  summary: "Instance is missing the 'up' metric for 
                    {{ $labels.instance }}"
                  description: "The 'up' metric for {{ $labels.job }} on 
                    instance {{ $labels.instance }} has been missing for
                    more than 5 minutes, which may indicate the target is
                    down."
#1 在 Prometheus 配置中加入告警定义

然后更新 Helm 安装:

helm upgrade --install prometheus \
prometheus-community/prometheus -n prometheus \
-f values.yaml

更新后,打开 Prometheus UI,在 Alerts 标签页可以看到我们定义的两条告警规则。也能看到它们尚未触发,处于 inactive(图 11.11)。

image.png

图 11.11 告警为绿色表示尚未触发

我们用“终止 BentoML deployment”的方式测试 MissingUpMetric 告警:进入 BentoML UI 的 Yatai > Deployments,点击任意一个 BentoML deployment 的 Terminate

终止后再看 Prometheus UI 的 alerts 页面,会看到 MissingUpMetric 变成黄色,表示告警处于 pending:触发条件已经发生,但还需要等待预设时间才会变为真正触发。这里我们希望当指标缺失持续 5 分钟或更久时触发(图 11.12)。

image.png

图 11.12 告警为黄色表示规则已满足,处于 pending 状态

再过 5 分钟刷新 UI,会看到 MissingUpMetric 变成红色,表示告警已触发(图 11.13)。

image.png

图 11.13 告警为红色表示已触发

告警触发后,我们需要把它路由给某个接收方。告警可以路由到 Slack、Gmail 或 PagerDuty 等多个渠道。这里我们通过修改 Helm values 文件中的 Alertmanager 配置,把告警路由到 Gmail:在 values 文件中 alertmanager 下加入 config

接着需要在 Gmail 中创建 app password:进入你的 Google 账户主页,若未启用则先开通双因素认证(2FA);然后访问 https://myaccount.google.com/apppasswords 创建一个新的 app password。该密码会用于配置中发送告警邮件。生成 app password 后,把如下配置放到 alertmanager 下:

清单 11.4 配置 Alertmanager 路由逻辑

  config:
      global:    #1
        smtp_smarthost: 'smtp.gmail.com:587'
        smtp_from: '<gmail_address>'
        smtp_auth_username: '<gmail_address>'
        smtp_auth_password: '<app password>' 
        smtp_require_tls: true

      route:    #2
        receiver: 'gmail-alerts'
        group_by: ['alertname', 'job']
        group_wait: 30s
        group_interval: 5m
        repeat_interval: 3h

      receivers:    #3
        - name: 'gmail-alerts'
          email_configs:
            - to: '<alert-recipient-email-address>'
              send_resolved: true 
#1 发送邮件所需的全局 SMTP 配置
#2 描述告警如何被处理、分组并分发给 receiver
#3 描述要发送告警的邮箱地址

然后用 helm upgrade --install 更新部署。安装完成后,收件人会收到一封包含告警标签与预定义描述的邮件(图 11.14)。

image.png

图 11.14 告警邮件示例:包含告警标签与描述

我们还可以 port-forward 到 Alertmanager UI 来查看告警:

kubectl port-forward svc/prometheus-alertmanager \
 -n prometheus 9091:80

你会看到除了 MissingUpMetric 以外可能还有其他告警被触发;每个告警都会触发一封邮件(图 11.15)。

image.png

图 11.15 Alertmanager 将多个告警路由到 Gmail

Alertmanager 里的告警可以根据路由逻辑路由到不同 receiver(如 email、Slack、PagerDuty)。你可以按告警触发的 namespace 或告警 label 来路由。例如,我们可能希望把 severity: critical 的告警路由到 PagerDuty,而 severity: mediumseverity: low 路由到 Gmail:

      route:
        receiver: 'gmail-alerts'
        group_by: ['alertname', 'job']
        group_wait: 30s
        group_interval: 5m
        repeat_interval: 3h
        routes:
            - match: severity: medium 
               receiver: 'gmail-alerts' 
            - match: severity: low 
              receiver: 'gmail-alerts'

总之,告警与 Alertmanager 通过提供实时监控与事故响应能力,在保障 ML 服务的可靠性与稳定性方面扮演关键角色。组织可以通过配置良好的告警规则尽早发现问题,并由 Alertmanager 通过路由将通知分发给合适团队,确保开发者始终知情并能迅速响应与修复问题。下一节我们将讨论如何把数据漂移技术应用到这两个项目中。

11.2 数据漂移检测(Data drift detection)

在第 6 章中,我们讨论了检测数据漂移的必要性,并探索了不同类型的数据漂移。在那一章里,我们聚焦于收入分类器项目中的表格数据,并使用统计检验在实时与批处理用例中识别漂移。本节会把这些方法扩展到目标检测与电影推荐项目。

11.2.1 目标检测(Object detection)

目标检测项目属于计算机视觉(CV)项目,因此我们必须用不同于表格数据的方式来做数据漂移检测。对于推荐项目这类表格数据,我们有一组预定义特征,可以监控相对于训练特征的分布变化。对于图像数据,我们可以比较图像特征或属性的差异,例如亮度(brightness)、对比度(contrast)、纵横比(aspect ratio)等。为此,我们需要先计算训练图像的这些属性,再与推理阶段拿到的图像进行对比,然后检验数据分布是否在统计意义上发生了变化。

不过,我们可以借助工具来完成这件事。就像 Evidently 常用于表格数据一样,我们可以使用 Python 库 deepchecks 来识别图像数据的漂移。deepchecks 旨在帮助数据科学家与 ML 从业者保证数据与模型的质量与完整性。它提供了广泛的数据与模型检查与校验能力,重点识别诸如数据漂移、数据泄漏、以及模型性能随时间退化等异常。deepchecks 也可以用于图像之外的漂移检测场景。

deepchecks 的关键特性包括:

  • 数据完整性检查(Data integrity checks) ——验证数据的一致性与质量,检测异常、缺失值与数据类型问题。
  • 模型验证(Model validation) ——检测过拟合、类别不平衡、以及意料之外的偏差等常见问题。
  • 数据漂移检测(Data drift detection) ——作为核心能力之一,deepchecks 可通过对比新数据集与训练数据中各特征分布来识别漂移,并突出特征分布变化;这可能意味着模型在新数据上重训或部署后性能会下降。
  • 可定制检查(Customizable checks) ——允许基于具体需求定制或新增检查,使其适配多种用例。

我们用 pip 安装 deepchecks:

pip install deepchecks

deepchecks 可以成为保持模型长期可靠性的重要工具,尤其在数据快速演进的环境中。我们将用 deepchecks 来识别目标检测用例中的漂移。

首先,我们会生成一个与训练图像数据集属性不同的数据集。具体来说,我们会用 Python Imaging Library(PIL)的 ImageEnhance 模块调整测试图像的亮度,使其与训练图像的亮度不同。然后用 deepchecks 判断这种亮度变化(使新数据可能落在训练分布之外)是否能被识别为漂移。

我们把一批身份证图片及其对应标签存到一个目录中(大约 100 张图像就足够)。接着使用 adjust_brightness 函数修改图像亮度(见清单 11.5)。当 brightness_factor < 1 时会降低亮度;如果想提升亮度,则把 factor 设为大于 1。同时,我们指定图像输入目录,以及保存修改后图像的输出目录。

清单 11.5 调整图像亮度以引入漂移

    for i, image_file in enumerate(image_files[:100]):
        try:
            img_path = os.path.join(input_dir, image_file)
            img = Image.open(img_path)
            unmodified_path = os.path.join(
                unmodified_dir, f"{os.path.splitext(image_file)[0]}.tif"
            )
            img.save(unmodified_path, format="TIFF")
            label_filename = f"{os.path.splitext(image_file)[0]}.txt"
            input_label_path = os.path.join(
                input_dir.replace("images", "labels"), label_filename
            )
            if os.path.exists(input_label_path):
                unmodified_label_path = os.path.join(
                    unmodified_dir.replace("images", "labels"), label_filename
                )
                with open(input_label_path, "r") as src, open(
                    unmodified_label_path, "w"
                ) as dst:
                    dst.write(src.read())
            enhancer = ImageEnhance.Brightness(img)    #1
            img_enhanced = enhancer.enhance(brightness_factor)
            output_path = os.path.join(
                output_dir, f"{os.path.splitext(image_file)[0]}.tif"
            )
            img_enhanced.save(output_path, format="TIFF")    #2
#1 调整图像亮度
#2 保存亮度被修改后的图像

我们可以查看其中一张图片来验证亮度是否被修改(图 11.16)。

image.png

图 11.16 调整亮度前后对比:我们降低了原始训练图像的亮度

现在我们有了两个目录:一个包含训练数据图像及其标签;另一个包含亮度被调整过的图像及其标签。为了构建自定义数据集,我们会用这两个目录去继承 torchvision.datasets.VisionDataset,创建一个子类,命名为 IDCardDatasetIDCardDataset 会持有训练与测试图像的路径与标签。在这个场景中,训练集是亮度未修改的图像样本,而测试集是亮度已调整的图像样本。

我们还需要定义一个 load_dataset 函数,分别为 train 与 test 返回 deepchecks 的 VisionData。随后我们就能在 VisionData 上运行 deepchecks 的 ImagePropertyDriftIDCardDatasetload_dataset 的代码可以在 object-detection 仓库中找到。

我们使用 load_dataset 分别加载训练集与“漂移集”。然后对这两个数据集运行 deepchecks.ImagePropertyDrift,如清单 11.6 所示。我们可以把结果保存成 HTML 文件,也可以直接打印测试的原始值。

清单 11.6 运行 deepchecks ImagePropertyDrift

from Dataset import load_dataset
from deepchecks.vision.checks import ImagePropertyDrift
train_dataset = load_dataset(train=True, object_type="VisionData")
test_dataset = load_dataset(train=False, object_type="VisionData")
check_result = ImagePropertyDrift().run( #1
    train_dataset, test_dataset  #1
)     #1
check_result.save_as_html( #2
    "deepcheck_vision_drift_check.xhtml"  #2
)     #2
print(check_result.value)    #3
#1 在亮度未修改与已修改的图像之间运行漂移检测
#2 将结果保存为 HTML 文件
#3 可打印统计检验结果

我们会得到多种图像属性的漂移分数,并观察到 Brightness(我们修改的属性)上出现显著漂移;而 Area 与 Aspect Ratio 的漂移为 0,因为我们没有改变它们:

{
    'Aspect Ratio': {
        'Drift score': 0.0, 
        'Method': 'Kolmogorov-Smirnov'
    }, 
    'Area': {
        'Drift score': 0.0, 
        'Method': 'Kolmogorov-Smirnov'
    }, 
    'Brightness': {
        'Drift score': 0.6188968140751308, 
        'Method': 'Kolmogorov-Smirnov'
    }, 
    'RMS Contrast': {
        'Drift score': 0.3095339990489777, 
        'Method': 'Kolmogorov-Smirnov'
    }, 
    'Mean Red Relative Intensity': {
        'Drift score': 0.13938183547313365, 
        'Method': 'Kolmogorov-Smirnov'
    }, 
    'Mean Green Relative Intensity': {
        'Drift score': 0.288525915359011, 
        'Method': 'Kolmogorov-Smirnov'
    }, 
    'Mean Blue Relative Intensity': {
        'Drift score': 0.06760342368045646, 
        'Method': 'Kolmogorov-Smirnov'
    }
}

保存该报告的 HTML 文件会提供这些属性分布的可视化。我们可以看到亮度分布差异很大(图 11.17)。

image.png

图 11.17 训练集与测试集亮度分布差异:测试集方差更大

我们能看到测试集分布的方差更大;而 Area 与 Aspect Ratio 的分布则没有差异(图 11.18)。

image.png

图 11.18 纵横比与面积分布无差异

对于我们的目标检测 BentoML Service,我们需要把所有用于推理的图片以及预测标签存储到一个 bucket 或数据库中。然后定期计算漂移分数,检查生产环境中观测到的图像属性分布是否与训练时一致,是否存在显著差异。

11.2.2 电影推荐(Movie recommender)

在电影推荐项目中,我们使用一个在用户-物品评分矩阵上训练的矩阵分解模型。模型部署到生产后,为了适配新用户或新物品,通常需要频繁再训练。但同时,检测评分数据的漂移也很有价值:它能帮助我们观察用户偏好或物品热度的潜在变化。我们可以通过监控用户与物品因子(user/item factors)的变化来实现这一点。为说明这个思路,我们会取 MovieLens 数据集的一个子集,在物品评分上引入漂移,然后比较用户与物品因子的分布,看看是否能检测到漂移。

为了引入漂移,我们会把部分电影的评分随机提高 1(同时保证评分仍小于等于 5):

def introduce_item_drift(df, movie_ids, drift_amount=1):
    drift_indices = df['itemId'].isin(movie_ids)
    df.loc[drift_indices, 'rating'] = (
        df.loc[drift_indices, 'rating'] + drift_amount
    )
    df['rating'] = df['rating'].clip(1, 5)
      return df

然后我们生成两份数据集——一份有漂移,一份无漂移。要生成带漂移的数据集,只需要传入一组 movie IDs 与评分数据框:

movie_ids = list(range(1, 101))
drifted_ratings = df['itemId'].isin(movie_ids)

拿到数据集后,我们分别在“带漂移”和“不带漂移”的版本上重训模型,以获取 user 与 item 因子。embedding 可以直接从模型中取出:

item_embedding_layer = model.item_factors.weight
item_embeddings = item_embedding_layer.detach().numpy()
user_embedding_layer = model.user_factors.weight
user_embeddings = user_embedding_layer.detach().numpy()
drifted_item_embedding_layer = model_with_drift.item_factors.weight
drifted_item_embeddings = drifted_item_embedding_layer.detach().numpy()
drifted_user_embedding_layer = model_with_drift.user_factors.weight
drifted_user_embeddings = drifted_user_embedding_layer.detach().numpy()

我们现在用 deepchecks 的 FeatureDrift 来验证那些被修改评分的电影数据是否发生漂移。FeatureDrift(顾名思义)用于追踪单个特征的漂移。我们把矩阵转置,使每一列代表一个 item 或 user,然后转成 pandas data frame,再包装成 deepchecks 的 dataset 格式:

df_item_factors_t1 = pd.DataFrame(
    item_embeddings.T, 
    columns=[f'item_factor_{i}' 
             for i in range(item_embeddings.shape[0])])
df_item_factors_t2 = pd.DataFrame(
    drifted_item_embeddings.T, 
    columns=[f'item_factor_{i}' 
             for i in range(drifted_item_embeddings.shape[0])])
from deepchecks.tabular import Dataset
dataset_item_factors_t1 = Dataset(df_item_factors_t1, label=None)
dataset_item_factors_t2 = Dataset(df_item_factors_t2, label=None)

接着对某个电影运行 FeatureDrift 来计算特征漂移:

from deepchecks.tabular.checks import FeatureDrift
drift_check_item = FeatureDrift(
    columns=["item_factor_2"]
).run(dataset_item_factors_t1, dataset_item_factors_t2)
drift_check_item.save_as_html()

我们会看到 item 的潜在因子分布确实发生了漂移,这意味着用户对 item/movie 编号 2 的偏好随时间发生了变化(图 11.19)。我们可以建立一个监控流水线,持续追踪潜在因子分布随时间的变化,从而获得关于物品热度与用户偏好的有价值洞察。

image.png

图 11.19 训练集与测试集之间 item 潜在因子分布差异

数据漂移监控对于确保生产环境中模型表现一致至关重要。通过持续追踪数据分布随时间的变化(例如特征值或目标分布的变化),我们可以检测生产输入是否已显著偏离训练数据。定期监控漂移有助于防止模型性能退化,并确保模型与真实世界当前状态保持一致。

下一节我们将探讨模型可解释性(model explainability):它关注让 ML 模型更透明、更可解释。随着模型变得更复杂——尤其是深度学习与集成方法这类可能表现很强但像黑盒一样的技术——理解预测是如何产生的就变得愈发重要。

11.3 可解释性(Explainability)

监控能告诉我们模型是否“跑得好”,而可解释性则帮助我们理解它为什么会做出某个具体决策。这种理解对以下方面至关重要:

  • 与干系人建立信任
  • 调试模型行为
  • 满足合规/监管要求
  • 改进模型性能

数据科学中的模型可解释性(model explainability),也被称为可解释 AI(interpretable AI / explainable AI,XAI),是现代 ML 与 AI 系统的重要组成部分。它指的是:以人类可理解的方式理解并解释复杂模型的决策与预测。近年来,随着模型复杂度上升以及 AI 在各行业关键决策流程中广泛应用,这一概念的重要性显著提升。

从技术角度看,可解释性让数据科学家与工程师能更有效地调试、改进与验证模型。它有助于识别偏差(biases)、理解模型的优势与弱点,并确保模型是基于相关特征做决策,而不是依赖偶然相关(spurious correlations)。这种洞察对构建稳健、可靠且公平的 AI 系统至关重要。

从业务角度看,可解释性同样关键,原因包括:第一,它能在客户、合作伙伴与监管机构等干系人之间建立信任——当企业能解释 AI 系统如何做决策时,人们对技术及其应用的信心会更高。第二,在高度监管行业中,可解释模型有助于满足监管要求。最后,它还能辅助业务决策,让业务负责人理解 AI 驱动建议背后的逻辑,从而做出更明智的选择。

金融领域(尤其是信用风险模型)是模型可解释性重要性的典型例子。这类模型用于评估信用资质并做出放贷决策,对个人与企业影响巨大。以美国的《平等信贷机会法》(Equal Credit Opportunity Act,ECOA)为例,该法规要求放贷方在采取不利行动(包括拒贷)时提供明确原因。这就要求模型具备较高可解释性。例如,当信用申请被拒时,金融机构必须能够解释哪些因素导致了这个决定,比如信用评分、收入水平或负债收入比。这种透明性不仅满足合规要求,也有助于维护公平性并减少放贷实践中的歧视。

模型可解释性可分为两大类:基于模型的可解释性(model-based)事后可解释性(post hoc) 。基于模型的可解释性指模型本身就被设计成能提供决策过程洞察的技术;这类模型(如线性回归、决策树)结构透明,用户容易理解输入特征如何影响预测。相对地,事后可解释性是在模型训练完成后,对复杂且往往不透明的模型进行分析以提取解释性洞察。该路线通常使用 SHapley Additive ExPlanations(SHAP)与 Local Interpretable Model-agnostic Explanations(LIME)等方法,通过近似黑盒模型的行为,为单次预测提供解释。

在目标检测系统中,可解释性之所以重要,有几个原因。首先,它能帮助理解为什么某些对象被检测到或被漏检,这对提升模型性能与可靠性非常关键。例如,在自动驾驶系统中,理解模型为何会把行人误分类,或为何没检测到交通标志,至关重要;这类洞察能指导我们有针对性地改进模型或数据采集流程。此外,在医学影像等疾病检测应用中,可解释性可以帮助医生理解并核验模型发现,从而提高诊断准确性并增强对 AI 辅助医疗的信任。

对电影推荐引擎来说,可解释性的目的有所不同但同样重要。虽然这类系统通常不像信用风险模型那样面临严格监管,但可解释性能够提升用户体验与参与度。当推荐系统能解释为什么推荐某部电影(例如“因为与你相似的 50 位用户给这部电影打了 5/5”),用户就能获得上下文,从而更信任推荐结果。这种透明性可带来更高的满意度与更有效的内容发现。从业务角度看,可解释推荐也能提供用户偏好与行为的洞察,为内容采购与制作决策提供依据。下面我们为目标检测项目搭建可解释性能力。

11.3.1 目标检测(Object detection)

在计算机视觉领域,目标检测模型越来越复杂,但其决策过程往往仍然不透明。这种缺乏透明度在关键应用中会带来问题,因为理解模型为何做出某些预测至关重要。为了解决这一点,研究者提出了多种可解释技术,其中一种是 基于主成分的类别激活映射(Class Activation Mapping using Principal Components,Eigen-CAM)

Eigen-CAM 是 CAM(Class Activation Mapping)技术家族的扩展,专门用于为卷积神经网络(CNN)的决策提供可视化解释。它在目标检测中的应用方式如下:

  • 突出关键区域——Eigen-CAM 生成热力图(heatmaps),标出图像中对模型检测与分类决策影响最大的区域。热力图叠加在原图上,展示模型在做预测时关注了哪里。
  • 无需额外训练——与一些可解释方法不同,Eigen-CAM 不需要修改或重训模型;它可以直接应用于现有的、预训练的目标检测模型,因此非常通用且实用。
  • 适配复杂架构——Eigen-CAM 对复杂架构的目标检测模型尤其有用,因为它能在不要求显式访问中间特征图的前提下,为决策过程提供洞察。

把 Eigen-CAM 纳入目标检测项目后,开发者与研究者能更深入理解模型行为,从而构建更稳健、可靠且值得信赖的目标检测系统。这种可解释性既能推动技术改进,也能增强用户与干系人对目标检测系统决策过程的信心。以身份证检测为例,我们可以验证模型是否聚焦在身份证的人脸区域;如果人脸并不重要,那么我们就能把模型泛化到可能没有人脸的身份证样式上!

我们将为目标检测模型使用 Eigen-CAM 来生成热力图,以验证模型在分类时是否确实聚焦于身份证区域。这可以通过使用位于 https://github.com/rigvedrs/YOLO-V8-CAM/tree/main 的 Eigen-CAM 模块来实现。结合该模块与我们的模型,我们可以为训练与推理阶段观察到的图像生成热力图,用于评估模型关注点,并指导是否需要调整模型架构或进行再训练。

如清单 11.7 所示,我们加载模型并指定目标层(target layers)。这些层通常包含高度抽象的特征,并携带用于预测 bounding boxes 与类别概率所需的关键空间信息。随后初始化 Eigen-CAM 并把任务设为目标检测。最后,我们给出要生成热力图的一组图片并绘制结果。

清单 11.7 运行 Eigen-CAM 生成热力图

import cv2
import numpy as np
import matplotlib.pyplot as plt
from ultralytics import YOLO
from yolo_cam.eigen_cam import EigenCAM
from yolo_cam.utils.image import show_cam_on_image
model = YOLO("../serving/yolov8_custom.pt")    #1
target_layers =[ #2
    model.model.model[-2],  #2
    model.model.model[-3],  #2
    model.model.model[-4]  #2
]     #2
cam = EigenCAM(model, target_layers,task='od')    #3
img_list = ["CA01_06.tif","CA01_02.tif","CA01_30.tif"]
for i in img_list:
    img = cv2.imread(i)
    rgb_img = img.copy()
    img = np.float32(img) / 255
    grayscale_cam = cam(rgb_img)[0, :, :]

    cam_image = show_cam_on_image(img, grayscale_cam, use_rgb=True)
    plt.imshow(cam_image)    #4
    plt.show()
#1 加载模型
#2 定义目标层
#3 基于模型与目标层初始化 Eigen-CAM,任务为目标检测
#4 绘制热力图

我们会看到模型按预期正确聚焦在身份证上,同时也会关注到图像的角落(图 11.20)。我们可以周期性运行这个过程,确保模型始终在图像中聚焦于期望对象。此外,在再训练模型时也可以用该方法验证模型是否仍能准确锁定相关对象。

image.png

图 11.20 Eigen-CAM 热力图:可视化对模型决策贡献最大的图像区域

11.3.2 电影推荐(Movie recommendation)

对于电影推荐项目,我们将使用一种基于模型的可解释方法。我们会在一部分数据上训练一个可解释的矩阵分解(Explainable Matrix Factorization,EMF)模型来演示可解释性。EMF 的解释基于在潜在空间(latent space)中识别相似用户和/或物品。可解释性通过用户与物品邻域内的评分分布来计算:我们计算一个可解释性分数(explainability score),其定义为“对某个物品打过分的相似用户数”除以“对该物品打过分的所有用户数”。这个分数随后作为训练算法中的权重,直觉是:如果某个物品对某个用户是“可解释的”,那么它们在潜在空间中的表示应该更接近。

为训练 EMF 模型,我们使用 MovieLens 100K 数据集。我们先初始化 EMFModel 并在训练数据上拟合;然后把模型封装进 Recommender 对象并构建一个 EMFExplainer,用于解释推荐结果(清单 11.8)。EMFModelEMFExplainerRecommender 的代码可以在 movie-recommender 仓库中找到。

清单 11.8 可解释矩阵分解模型

emf = EMFModel( #1
    learning_rate=0.01, 
    reg_term=0.001, 
    expl_reg_term=0.0, #2
    latent_dim=80, 
    epochs=10, 
    positive_threshold=3, 
    knn=10)    
emf.fit(train)
recommender = Recommender(train, emf)    #3
recommendations = recommender.recommend_all()
explanations = EMFExplainer(emf, recommendations, data)
recs_with_explainations = \ 
explanations.explain_recommendations()    #4
#1 定义并训练 EMF 模型
#2 定义并训练 EMF 模型
#3 封装为 recommender 并获取推荐结果
#4 使用 EMFExplainer 生成带解释的推荐数据框

recs_with_explanations 包含每个用户的 top 10 推荐,以及一个 explanations 列。explanations 列是一个字典,表示 {rating: 给出该评分的相似用户数量}。有些解释可能为空,这通常是因为对某些用户找不到相似用户。例如,用户 1 的排序推荐与解释如下:

userId  itemId   rank   explanations
1176    2.0      1450.0 1.0   {}
12      2.0       286.0 2.0   {5: 1}
201     2.0       475.0 3.0   {3: 1, 4: 4, 5: 4}
135     2.0       409.0 4.0   {4: 2, 5: 4}
1094    2.0      1368.0 5.0   {}
238     2.0       512.0 6.0   {4: 2, 5: 2}
384     2.0       658.0 7.0   {}
29      2.0       303.0 8.0   {4: 1, 5: 2}
374     2.0       648.0 9.0   {4: 1}
210     2.0       484.0 10.0  {5: 6}

我们可以看到 top-ranked 推荐没有解释。第三名电影的解释可以这样理解:“在 9 个对这部电影打过分的相似用户中,有 8 个给了 4 分或以上。”这能让用户与业务对模型预测更有信心。

模型可解释性帮助把复杂 AI 系统变得对人类可理解。它在很多领域都很重要:例如确保金融模型符合法规、让目标检测系统更安全可靠、以及提升推荐引擎的用户体验。通过让 AI 决策更清晰,可解释性是负责任且有效使用 AI 的关键。

小结(Summary)

  • 监控 ML 应用对保持服务可靠性与性能至关重要。基础监控包括追踪资源利用率与请求指标,可通过 BentoML 等提供的预构建仪表盘进行可视化。
  • 自定义指标可用于追踪应用特定细节,例如目标检测中的置信度分数,或推荐系统中 ranked movie 的计数;这些指标可集成到监控仪表盘中以获得更好洞察。
  • 日志提供调试与排障所需的上下文与细节信息。使用 Loki 等工具集中日志能增强可观测性并提升日志分析效率。
  • 告警对主动事故管理必不可少。基于监控指标与日志设置告警规则,并使用 Alertmanager 路由通知,可确保对关键事件及时响应。
  • 数据漂移监控对维持模型准确率很重要。deepchecks 提供可检测多种数据类型(包括图像与 embedding)的漂移工具。定期监控漂移有助于避免模型性能退化。
  • 模型可解释性对建立信任与理解 AI 决策至关重要。目标检测可用 Eigen-CAM 等方法,推荐系统可用基于模型的方法来解释预测。可解释性提升了 ML 系统的透明度与可追责性。