MLops | 基于AWS Lambda 架构构建强大的机器学习(ML)血缘关系

本篇文章From Data Drift to Compliance: Building Robust Machine Learning (ML) Lineage in ML Applications为构建机器学习(ML)应用的完整数据和模型追踪提供了详细指南。文章的技术亮点在于使用DVC和Prefect实现了全面的ML谱系管理,确保数据的可追溯性和合规性。适用场景包括需要遵循法规和保证模型公平性的行业,如金融和医疗。实际案例中,通过AWS Lambda架构,成功集成了ETL管道、数据漂移检测、模型调优及风险评估等步骤,为机器学习项目提供了结构化的管理方案。


文章目录

  • [1 前言](#1 前言)
    • [1.1 引言](#1.1 引言)
    • [1.2 什么是机器学习血缘关系](#1.2 什么是机器学习血缘关系)
    • [1.3 我们将构建什么](#1.3 我们将构建什么)
    • [1.4 工作流实践](#1.4 工作流实践)
  • [2 步骤 1. 初始化 DVC 项目](#2 步骤 1. 初始化 DVC 项目)
  • [3 步骤 2. ML 血缘关系](#3 步骤 2. ML 血缘关系)
      • [3.1 阶段 1. ETL 管道](#3.1 阶段 1. ETL 管道)
      • [3.2 阶段 2. 数据漂移检查](#3.2 阶段 2. 数据漂移检查)
      • [3.3 阶段 3. 预处理](#3.3 阶段 3. 预处理)
      • [3.4 阶段 4. 调优模型](#3.4 阶段 4. 调优模型)
      • [3.5 阶段 5. 执行推理](#3.5 阶段 5. 执行推理)
      • [3.6 阶段 6. 评估模型风险和公平性](#3.6 阶段 6. 评估模型风险和公平性)
  • [4 本地测试](#4 本地测试)
  • [5 步骤 3. 部署 DVC 项目](#5 步骤 3. 部署 DVC 项目)
  • [6 步骤 4. 使用 Prefect 配置计划运行](#6 步骤 4. 使用 Prefect 配置计划运行)
    • [6.1 配置 Docker 镜像注册表](#6.1 配置 Docker 镜像注册表)
    • [6.2 配置 Prefect 任务和流程](#6.2 配置 Prefect 任务和流程)
    • [6.3 本地测试](#6.3 本地测试)
  • [7 步骤 5. 部署应用程序](#7 步骤 5. 部署应用程序)
    • [7.1 本地测试](#7.1 本地测试)
  • [8 结论](#8 结论)

1 前言

1.1 引言

在任何强大的机器学习(ML)系统中,ML 血缘关系都至关重要,它用于跟踪数据和模型版本,确保可复现性、可审计性和合规性。

尽管存在许多服务,但创建一个全面且易于管理的血缘关系通常会很复杂。

在本文中,我将详细介绍如何为部署在无服务器 AWS Lambda 架构上的 ML 应用程序集成全面的 ML 血缘解决方案,涵盖端到端管道阶段:

  • ETL 管道,
  • 数据漂移检测,
  • 预处理,
  • 模型调优,以及
  • 风险和公平性评估。

1.2 什么是机器学习血缘关系

机器学习(ML)血缘关系是一个用于跟踪和理解机器学习模型完整生命周期的框架。

它包含不同级别的信息,例如:

  • 代码:用于模型训练的脚本、库和配置。
  • 数据:原始数据、转换和特征。
  • 实验:训练运行、超参数调优结果。
  • 模型:训练好的模型及其版本。
  • 预测:部署模型的输出。

ML 血缘关系对于多种原因至关重要:

  • 可复现性:重现相同的模型和预测以进行验证。
  • 根本原因分析:当模型在生产中失败时,追溯到数据、代码或配置更改。
  • 合规性:一些受监管行业要求提供模型训练证明,以确保公平性、透明度并遵守 GDPR 和欧盟人工智能法案等法律。

1.3 我们将构建什么

在这个项目中,我将使用 DVC(一个用于 ML 应用程序的开源版本控制系统)为 AWS Lambda 架构上的定价系统 集成完整的 ML 血缘关系。

下图展示了整个系统:

图 A. 无服务器 Lambda 上 ML 应用程序的全面 ML 血缘关系

在该系统中,GitHub 处理代码血缘关系,而 DVC 捕获以下内容的血缘关系:

  • 数据(蓝色框):ETL 和预处理,
  • 实验(浅橙色):超参数调优和验证,
  • 模型(深橙色):最终模型工件,以及
  • 预测(深橙色):预测和公平性测试结果。

DVC 在不同的阶段跟踪这些血缘关系,从数据提取到公平性测试(图 A 中的黄色行)。

对于每个阶段,DVC 使用 MD5 哈希 跟踪工件、指标和报告,并将此元数据推送到其在 AWS S3 上的 DVC 远程存储。

然后,只有通过数据漂移和公平性测试的模型工件才能通过 API 网关在生产中提供其预测(图 A 中的红色框)。

最后,整个血缘过程由开源工作流调度器 Prefect 每周触发。

Prefect 提示 DVC 检查数据和脚本的任何更新,如果检测到更改,则执行完整的血缘过程。

1.4 工作流实践

构建过程涉及五个主要步骤:

  1. 初始化 DVC 项目,
  2. 使用 DVC 脚本 dvc.yaml 和相应的 Python 脚本定义血缘阶段,
  3. 部署 DVC 项目,
  4. 使用 Prefect 配置计划运行,以及
  5. 部署应用程序。

让我们来看看。

2 步骤 1. 初始化 DVC 项目

第一步是初始化 DVC 项目:

bash 复制代码
$dvc init

此命令会自动在项目根目录创建 .dvc 目录:

复制代码
.  
.dvc/  
│  
└── cache/           
└── tmp/             
└── .gitignore       
└── config           
└── config.local   

DVC 通过将大型文件的原始数据与仓库分离,维护一个快速、轻量级的 Git 仓库。

该过程包括:

  • 将原始数据缓存到本地 .dvc/cache 目录中,
  • 创建一个小的 .dvc 元数据文件,其中包含 MD5 哈希和指向原始数据文件路径的链接,
  • 将小的元数据文件推送到 Git,以及
  • 将原始数据推送到 DVC 远程存储。

3 步骤 2. ML 血缘关系

接下来,我将配置 ML 血缘关系,包括以下阶段:

  1. etl_pipeline:提取、清理、填充原始数据并执行特征工程。
  2. data_drift_check:运行数据漂移测试。如果测试失败,系统将退出。
  3. preprocess:创建训练、验证和测试数据集。
  4. tune_primary_model:调优超参数并训练模型。
  5. inference_primary_model:在测试数据集上执行推理。
  6. assess_model_risk:运行风险和公平性测试。

每个阶段都需要在 dvc.yaml 上定义 DVC 命令及其相应的 Python 脚本。

让我们来看看。

3.1 阶段 1. ETL 管道

第一个阶段是提取、清理、填充原始数据并执行特征工程。

DVC 配置

我将在项目根目录创建 dvc.yaml 文件,并添加 etl_pipeline 阶段:

dvc.yaml

yaml 复制代码
stages:  
  etl_pipeline:  
    cmd: python src/data_handling/etl_pipeline.py 

    deps:  
      - src/data_handling/etl_pipeline.py   
      - src/data_handling/  
      - src/_utils/

    outs:  
      - data/original_df.parquet   
      - data/processed_df.parquet

dvc.yaml 文件通过定义阶段及其部分(如:)来定义 DVC 项目的配置:

  • cmd:要为该阶段执行的 shell 命令,
  • deps:运行 cmd 所需的依赖项,
  • params:在 params.yaml 文件中定义的 cmd 的默认参数,
  • metrics:要跟踪的指标文件,
  • reports:要跟踪的报告文件,
  • plots:用于可视化的 DVC 绘图文件,以及
  • outs:由 cmd 生成的输出文件,DVC 将跟踪这些文件。

此配置有助于 DVC:

  • 通过明确列出每个阶段的依赖项、输出和命令来确保可复现性
  • 通过建立工作流的有向无环图(DAG)管理血缘关系,将每个阶段链接到下一个阶段。

Python 脚本

接下来,我将添加 Python 脚本,确保数据使用 dvc.yaml 文件 outs 部分中指定的文件路径存储:

src/data_handling/etl_pipeline.py

python 复制代码
import os  
import argparse  
import src.data_handling.scripts as scripts

from src._utils import main_logger

def etl_pipeline(stockcode: str = '', impute_stockcode: bool = False):  
      
    df = scripts.extract_original_dataframe()

      
    ORIGINAL_DF_PATH = os.path.join('data', 'original_df.parquet')  
    df.to_parquet(ORIGINAL_DF_PATH, index=False) 

      
    df = scripts.structure_missing_values(df=df)  
    df = scripts.handle_feature_engineering(df=df)  
    PROCESSED_DF_PATH = os.path.join('data', 'processed_df.parquet')  
    df.to_parquet(PROCESSED_DF_PATH, index=False) 

    return df

  
if __name__ == '__main__':    
    parser = argparse.ArgumentParser(description="run etl pipeline")  
    parser.add_argument('--stockcode', type=str, default='', help="specific stockcode to process. empty runs full pipeline.")  
    parser.add_argument('--impute', action='store_true', help="flag to create imputation values")  
    args = parser.parse_args()

    etl_pipeline(stockcode=args.stockcode, impute_stockcode=args.impute)

输出

原始数据和结构化数据在 Pandas 的 DataFrame 中存储在 DVC 缓存中:

  • data/original_df.parquet
  • data/processed_df.parquet

3.2 阶段 2. 数据漂移检查

在进入预处理之前,我将运行数据漂移测试。

数据漂移是指模型训练数据中统计特性(如均值、方差或分布)的任何变化。

其主要类别包括:

  • 协变量漂移(特征漂移):输入特征分布的变化。
  • 先验概率漂移(标签漂移):目标变量分布的变化。
  • 概念漂移:输入数据和目标变量之间关系的变化。

这些数据漂移会随着时间的推移损害模型的泛化能力。

DVC 配置

我将在 etl_pipeline 阶段之后添加 data_drift_check 阶段:

dvc.yaml

yaml 复制代码
stages:  
  etl_pipeline:  
      
  data_drift_check:  
    cmd: >  
      python src/data_handling/report_data_drift.py  
      data/processed/processed_df.csv   
      data/processed_df_${params.stockcode}.parquet  
      reports/data_drift_report_${params.stockcode}.html  
      metrics/data_drift_${params.stockcode}.json  
      ${params.stockcode}  
      
      
    params:  
      - params.stockcode

    deps:  
      - src/data_handling/report_data_drift.py  
      - src/

      
    plots:  
      - reports/data_drift_report_${params.stockcode}.html

    metrics:  
      - metrics/data_drift_${params.stockcode}.json:  
          type: json

然后,为传递给 DVC 命令的参数添加默认值:

params.yaml

yaml 复制代码
params:  
  stockcode: <STOCKCODE OF CHOICE>

Python 脚本

从 EventlyAI 工作区生成 API 令牌 后,我将添加一个 Python 脚本来检测数据漂移并将结果存储在 metrics 变量中:

src/data_handling/report_data_drift.py

python 复制代码
import os  
import sys  
import json  
import pandas as pd  
import datetime  
from dotenv import load_dotenv

from evidently import Dataset, DataDefinition, Report  
from evidently.presets import DataDriftPreset  
from evidently.ui.workspace import CloudWorkspace

import src.data_handling.scripts as scripts  
from src._utils import main_logger

if __name__ == '__main__':  
      
    load_dotenv(override=True)  
    ws = CloudWorkspace(token=os.getenv('EVENTLY_API_TOKEN'), url='https://app.evidently.cloud')

      
    project = ws.get_project('EVENTLY AI PROJECT ID')

      
    REFERENCE_DATA_PATH = sys.argv[1]  
    CURRENT_DATA_PATH = sys.argv[2]  
    REPORT_OUTPUT_PATH = sys.argv[3]  
    METRICS_OUTPUT_PATH = sys.argv[4]  
    STOCKCODE = sys.argv[5]

      
    os.makedirs(os.path.dirname(REPORT_OUTPUT_PATH), exist_ok=True)  
    os.makedirs(os.path.dirname(METRICS_OUTPUT_PATH), exist_ok=True)

      
    reference_data_full = pd.read_csv(REFERENCE_DATA_PATH)  
    reference_data_stockcode = reference_data_full[reference_data_full['stockcode'] == STOCKCODE]  
    current_data_stockcode = pd.read_parquet(CURRENT_DATA_PATH)

      
    nums, cats = scripts.categorize_num_cat_cols(df=reference_data_stockcode)  
    for col in nums: current_data_stockcode[col] = pd.to_numeric(current_data_stockcode[col], errors='coerce')  
    schema = DataDefinition(numerical_columns=nums, categorical_columns=cats)

      
    eval_data_1 = Dataset.from_pandas(reference_data_stockcode, data_definition=schema)  
    eval_data_2 = Dataset.from_pandas(current_data_stockcode, data_definition=schema)

      
    report = Report(metrics=[DataDriftPreset()])  
    data_eval = report.run(reference_data=eval_data_1, current_data=eval_data_2)  
    data_eval.save_html(REPORT_OUTPUT_PATH)

      
    report_dict = json.loads(data_eval.json())  
    num_drifts = report_dict['metrics'][0]['value']['count']  
    shared_drifts = report_dict['metrics'][0]['value']['share']  
    metrics = dict(  
        drift_detected=bool(num_drifts > 0.0), num_drifts=num_drifts, shared_drifts=shared_drifts,  
        num_cols=nums,  
        cat_cols=cats,  
        stockcode=STOCKCODE,  
        timestamp=datetime.datetime.now().isoformat(),  
    )

      
    with open(METRICS_OUTPUT_PATH, 'w') as f:  
        json.dump(metrics, f, indent=4)  
        main_logger.info(f'... drift metrics saved to {METRICS_OUTPUT_PATH}... ')

      
    if num_drifts > 0.0: sys.exit('❌ FATAL: data drift detected. stopping pipeline')

如果检测到数据漂移,脚本将立即使用最终的 sys.exit 命令退出。

输出

脚本生成两个 DVC 将跟踪的文件:

  • reports/data_drift_report.html:HTML 格式的数据漂移报告。
  • metrics/data_drift.json:JSON 格式的数据漂移指标,包括漂移结果以及特征列和时间戳:

metrics/data_drift.json

json 复制代码
{  
    "drift_detected": false,  
    "num_drifts": 0.0,  
    "shared_drifts": 0.0,  
    "num_cols": [  
        "invoiceno",  
        "invoicedate",  
        "unitprice",  
        "product_avg_quantity_last_month",  
        "product_max_price_all_time",  
        "unitprice_vs_max",  
        "unitprice_to_avg",  
        "unitprice_squared",  
        "unitprice_log"  
    ],  
    "cat_cols": [  
        "stockcode",  
        "customerid",  
        "country",  
        "year",  
        "year_month",  
        "day_of_week",  
        "is_registered"  
    ],  
    "timestamp": "2025-10-07T00:24:29.899495"  
}

漂移测试结果也可以在 Evently 工作区仪表板上进行进一步分析:

图. Evently 工作区仪表板截图

3.3 阶段 3. 预处理

如果未检测到数据漂移,血缘关系将进入预处理阶段。

DVC 配置

我将在 data_drift_check 阶段之后添加 preprocess 阶段:

dvc.yaml

yaml 复制代码
stages:  
  etl_pipeline:  
      
  data_drift_check:  
      
  preprocess:  
    cmd: >  
      python src/data_handling/preprocess.py --target_col ${params.target_col} --should_scale ${params.should_scale} --verbose ${params.verbose}
    deps:  
      - src/data_handling/preprocess.py  
      - src/data_handling/  
      - src/_utils  
  
      
    params:  
      - params.target_col  
      - params.should_scale  
      - params.verbose

    outs:  
        
      - data/x_train_df.parquet  
      - data/x_val_df.parquet  
      - data/x_test_df.parquet  
      - data/y_train_df.parquet  
      - data/y_val_df.parquet  
      - data/y_test_df.parquet

        
      - data/x_train_processed.parquet  
      - data/x_val_processed.parquet  
      - data/x_test_processed.parquet

        
      - preprocessors/column_transformer.pkl  
      - preprocessors/feature_names.json

并添加 cmd 中使用的参数的默认值:

params.yaml

yaml 复制代码
params:  
  target_col: "quantity"  
  should_scale: True  
  verbose: False

Python 脚本

接下来,我将添加其相应的 Python 脚本:

python 复制代码
import os  
import argparse  
import json  
import joblib  
import pandas as pd  
import numpy as np  
from sklearn.model_selection import train_test_split

import src.data_handling.scripts as scripts  
from src._utils import main_logger

def preprocess(stockcode: str = '', target_col: str = 'quantity', should_scale: bool = True, verbose: bool = False):  
      
    DATA_DRIFT_METRICS_PATH = os.path.join('metrics', f'data_drift_{stockcode}.json')  
    if os.path.exists(DATA_DRIFT_METRICS_PATH):  
        with open(DATA_DRIFT_METRICS_PATH, 'r') as f:  
            metrics = json.load(f)  
    else: metrics = dict()

      
    PROCESSED_DF_PATH = os.path.join('data', 'processed_df.parquet')  
    df = pd.read_parquet(PROCESSED_DF_PATH)

      
    num_cols, cat_cols = scripts.categorize_num_cat_cols(df=df, target_col=target_col)  
    if verbose: main_logger.info(f'num_cols: {num_cols} \ncat_cols: {cat_cols}')

      
    if cat_cols:  
        for col in cat_cols: df[col] = df[col].astype('string')

      
    PREPROCESSOR_PATH = os.path.join('preprocessors', 'column_transformer.pkl')  
    try:  
        preprocessor = joblib.load(PREPROCESSOR_PATH)  
    except:  
        preprocessor = scripts.create_preprocessor(num_cols=num_cols if should_scale else [], cat_cols=cat_cols)

      
    y = df[target_col]  
    X = df.copy().drop(target_col, axis='columns')

      
    test_size, random_state = 50000, 42  
    X_tv, X_test, y_tv, y_test = train_test_split(X, y, test_size=test_size, random_state=random_state, shuffle=False)  
    X_train, X_val, y_train, y_val = train_test_split(X_tv, y_tv, test_size=test_size, random_state=random_state, shuffle=False)

      
    X_train.to_parquet('data/x_train_df.parquet', index=False)  
    X_val.to_parquet('data/x_val_df.parquet', index=False)  
    X_test.to_parquet('data/x_test_df.parquet', index=False)  
    y_train.to_frame(name=target_col).to_parquet('data/y_train_df.parquet', index=False)  
    y_val.to_frame(name=target_col).to_parquet('data/y_val_df.parquet', index=False)  
    y_test.to_frame(name=target_col).to_parquet('data/y_test_df.parquet', index=False)

      
    X_train = preprocessor.fit_transform(X_train)  
    X_val = preprocessor.transform(X_val)  
    X_test = preprocessor.transform(X_test)

      
    pd.DataFrame(X_train).to_parquet(f'data/x_train_processed.parquet', index=False)  
    pd.DataFrame(X_val).to_parquet(f'data/x_val_processed.parquet', index=False)  
    pd.DataFrame(X_test).to_parquet(f'data/x_test_processed.parquet', index=False)

      
    with open('preprocessors/feature_names.json', 'w') as f:  
        feature_names = preprocessor.get_feature_names_out()  
        json.dump(feature_names.tolist(), f)  
    return  X_train, X_val, X_test, y_train, y_val, y_test, preprocessor

  
if __name__ == '__main__':  
    parser = argparse.ArgumentParser(description='run data preprocessing')  
    parser.add_argument('--stockcode', type=str, default='', help='specific stockcode')  
    parser.add_argument('--target_col', type=str, default='quantity', help='the target column name')  
    parser.add_argument('--should_scale', type=bool, default=True, help='flag to scale numerical features')  
    parser.add_argument('--verbose', type=bool, default=False, help='flag for verbose logging')  
    args = parser.parse_args()  
    X_train, X_val, X_test, y_train, y_val, y_test, preprocessor = preprocess(  
        target_col=args.target_col,  
        should_scale=args.should_scale,  
        verbose=args.verbose,  
        stockcode=args.stockcode,  
    )

输出

此阶段生成模型训练和推理所需的必要数据集:

输入特征:

  • data/x_train_df.parquet
  • data/x_val_df.parquet
  • data/x_test_df.parquet

预处理后的输入特征:

  • data/x_train_processed_df.parquet
  • data/x_val_processed_df.parquet
  • data/x_test_processed_df.parquet

目标变量:

  • data/y_train_df.parquet
  • data/y_val_df.parquet
  • data/y_test_df.parquet

预处理器和人类可读的特征名称也存储在缓存中,用于后续的推理和 SHAP 特征影响分析:

  • preprocessors/column_transformer.pk
  • preprocessors/feature_names.json

此外,DVC 更新数据摘要指标以实现可追溯性:

metrics/data.json

json 复制代码
{  
    "drift_detected": false,  
    "num_drifts": 0.0,  
    "shared_drifts": 0.0,  
    "num_cols": [  
        "invoiceno",  
        "invoicedate",  
        "unitprice",  
        "product_avg_quantity_last_month",  
        "product_max_price_all_time",  
        "unitprice_vs_max",  
        "unitprice_to_avg",  
        "unitprice_squared",  
        "unitprice_log"  
    ],  
    "cat_cols": [  
        "stockcode",  
        "customerid",  
        "country",  
        "year",  
        "year_month",  
        "day_of_week",  
        "is_registered"  
    ],  
    "timestamp": "2025-10-07T00:24:29.899495",  
    "preprocess_status": "completed",  
    "x_train_processed_path": "data/x_train_processed_85123A.parquet",  
    "preprocessor_path": "preprocessors/column_transformer.pkl"  
}

这就是数据血缘关系的全部内容。

接下来,我将进入模型/实验血缘关系。

3.4 阶段 4. 调优模型

创建数据集后,我将使用在 preprocess 阶段创建的训练和验证数据集,调优和训练主模型,即 PyTorch 上的多层前馈网络。

DVC 配置

首先,我将在 preprocess 阶段之后添加 tuning_primary_model 阶段:

dvc.yaml

yaml 复制代码
stages:  
  etl_pipeline:  
      
  data_drift_check:  
      
  preprocess:  
      
  tune_primary_model:  
    cmd: >  
      python src/model/torch_model/main.py  
      data/x_train_processed_${params.stockcode}.parquet  
      data/x_val_processed_${params.stockcode}.parquet  
      data/y_train_df_${params.stockcode}.parquet  
      data/y_val_df_${params.stockcode}.parquet  
      ${tuning.should_local_save}  
      ${tuning.grid}  
      ${tuning.n_trials}  
      ${tuning.num_epochs}  
      ${params.stockcode}  
   
    deps:  
      - src/model/torch_model/main.py  
      - src/data_handling/  
      - src/model/  
      - src/_utils/

    params:  
      - params.stockcode  
      - tuning.n_trials  
      - tuning.grid  
      - tuning.should_local_save

    outs:  
      - models/production/dfn_best_${params.stockcode}.pth 

    metrics:  
      - metrics/dfn_val_${params.stockcode}.json: 

然后,为参数添加默认值:

params.yaml

yaml 复制代码
params:  
  target_col: "quantity"  
  should_scale: True  
  verbose: False

tuning:  
  n_trials: 100  
  num_epochs: 3000  
  should_local_save: False  
  grid: False

Python 脚本

接下来,我将添加 Python 脚本,以使用贝叶斯优化 调优模型,然后在 preprocess 阶段创建的完整 X_trainy_train 数据集上训练最佳模型。

src/model/torch_model/main.py

python 复制代码
import os  
import sys  
import json  
import datetime  
import pandas as pd  
import torch  
import torch.nn as nn

import src.model.torch_model.scripts as scripts

def tune_and_train(        X_train, X_val, y_train, y_val,  
        stockcode: str = '',  
        should_local_save: bool = True,  
        grid: bool = False,  
        n_trials: int = 50,  
        num_epochs: int = 3000    ) -> tuple[nn.Module, dict]:

      
    best_dfn, best_optimizer, best_batch_size, best_checkpoint = scripts.bayesian_optimization(  
        X_train, X_val, y_train, y_val, n_trials=n_trials, num_epochs=num_epochs  
    )

      
    DFN_FILE_PATH = os.path.join('models', 'production', f'dfn_best_{stockcode}.pth' if stockcode else 'dfn_best.pth')  
    os.makedirs(os.path.dirname(DFN_FILE_PATH), exist_ok=True)  
    torch.save(best_checkpoint, DFN_FILE_PATH)

    return best_dfn, best_checkpoint

def track_metrics_by_stockcode(X_val, y_val, best_model, checkpoint: dict, stockcode: str):  
    MODEL_VAL_METRICS_PATH = os.path.join('metrics', f'dfn_val_{stockcode}.json')  
    os.makedirs(os.path.dirname(MODEL_VAL_METRICS_PATH), exist_ok=True)

      
    _, mse, exp_mae, rmsle = scripts.perform_inference(model=best_model, X=X_val, y=y_val)  
    model_version = f"dfn_{stockcode}_{os.getpid()}"  
    metrics = dict(  
        stockcode=stockcode,  
        mse_val=mse,  
        mae_val=exp_mae,  
        rmsle_val=rmsle,  
        model_version=model_version,  
        hparams=checkpoint['hparams'],  
        optimizer=checkpoint['optimizer_name'],  
        batch_size=checkpoint['batch_size'],  
        lr=checkpoint['lr'],  
        timestamp=datetime.datetime.now().isoformat()  
    )

      
    with open(MODEL_VAL_METRICS_PATH, 'w') as f:  
        json.dump(metrics, f, indent=4)  
        main_logger.info(f'... validation metrics saved to {MODEL_VAL_METRICS_PATH} ...')

if __name__ == '__main__':  
      
    X_TRAIN_PATH = sys.argv[1]  
    X_VAL_PATH = sys.argv[2]  
    Y_TRAIN_PATH = sys.argv[3]  
    Y_VAL_PATH = sys.argv[4]  
    SHOULD_LOCAL_SAVE = sys.argv[5] == 'True'  
    GRID = sys.argv[6] == 'True'  
    N_TRIALS = int(sys.argv[7])  
    NUM_EPOCHS = int(sys.argv[8])  
    STOCKCODE = str(sys.argv[9])

      
    X_train, X_val = pd.read_parquet(X_TRAIN_PATH), pd.read_parquet(X_VAL_PATH)  
    y_train, y_val = pd.read_parquet(Y_TRAIN_PATH

```python
    X_train, X_val = pd.read_parquet(X_TRAIN_PATH), pd.read_parquet(X_VAL_PATH)  
    y_train, y_val = pd.read_parquet(Y_TRAIN_PATH), pd.read_parquet(Y_VAL_PATH)

      
    best_model, checkpoint = tune_and_train(  
        X_train, X_val, y_train, y_val,  
        stockcode=STOCKCODE, should_local_save=SHOULD_LOCAL_SAVE, grid=GRID, n_trials=N_TRIALS, num_epochs=NUM_EPOCHS  
    )

      
    track_metrics_by_stockcode(X_val, y_val, best_model=best_model, checkpoint=checkpoint, stockcode=STOCKCODE)

输出

此阶段生成两个文件:

  • models/production/dfn_best.pth:包含模型工件和检查点,例如最佳超参数集。
  • metrics/dfn_val.json:包含调优结果、模型版本、时间戳以及 MSE、MAE 和 RMSLE 的验证结果:

metrics/dfn_val.json

json 复制代码
{  
    "stockcode": "85123A",  
    "mse_val": 0.6137686967849731,  
    "mae_val": 9.092489242553711,  
    "rmsle_val": 0.6953299045562744,  
    "model_version": "dfn_85123A_35604",  
    "hparams": {  
        "num_layers": 4,  
        "batch_norm": false,  
        "dropout_rate_layer_0": 0.13765888061300502,  
        "n_units_layer_0": 184,  
        "dropout_rate_layer_1": 0.5509872409359128,  
        "n_units_layer_1": 122,  
        "dropout_rate_layer_2": 0.2408753527744403,  
        "n_units_layer_2": 35,  
        "dropout_rate_layer_3": 0.03451842588822594,  
        "n_units_layer_3": 224,  
        "learning_rate": 0.026240673135104406,  
        "optimizer": "adamax",  
        "batch_size": 64  
    },  
    "optimizer": "adamax",  
    "batch_size": 64,  
    "lr": 0.026240673135104406,  
    "timestamp": "2025-10-07T00:31:08.700294"  
}

3.5 阶段 5. 执行推理

模型调优阶段完成后,我将配置测试推理以进行最终评估。

最终评估使用 MSE、MAE 和 RMSLE 指标,以及用于特征影响和可解释性分析的 SHAP

SHAP (SHapley Additive exPlanations) 是一个框架,通过使用博弈论中的 Shapley 值概念来量化每个特征对模型预测的贡献。

SHAP 值用于未来的 EDA 和特征工程。

DVC 配置

首先,我将 inference_primary_model 阶段添加到 DVC 配置中。

此阶段具有 plots 部分,DVC 将在此处跟踪和版本化生成的 SHAP 值可视化文件。

dvc.yaml

yaml 复制代码
stages:  
  etl_pipeline:  
      
  data_drift_check:  
      
  preprocess:  
      
  tune_primary_model:  
      
  inference_primary_model:  
    cmd: >  
      python src/model/torch_model/inference.py  
      data/x_test_processed_${params.stockcode}.parquet  
      data/y_test_df_${params.stockcode}.parquet  
      models/production/dfn_best_${params.stockcode}.pth  
      ${params.stockcode}  
      ${tracking.sensitive_feature_col}  
      ${tracking.privileged_group}  
  
    deps:  
      - src/model/torch_model/inference.py  
      - models/production/  
      - src/

    params:  
      - params.stockcode  
      - tracking.sensitive_feature_col  
      - tracking.privileged_group

    metrics:  
      - metrics/dfn_inf_${params.stockcode}.json:   
          type: json

    plots:  
        
      - reports/dfn_shap_summary_${params.stockcode}.json:  
          template: simple  
          x: shap_value  
          y: feature_name  
          title: SHAP Beeswarm Plot

        
      - reports/dfn_shap_mean_abs_${params.stockcode}.json:  
          template: bar  
          x: mean_abs_shap  
          y: feature_name  
          title: Mean Absolute SHAP Importance

    outs:  
      - data/dfn_inference_results_${params.stockcode}.parquet  
      - reports/dfn_raw_shap_values_${params.stockcode}.parquet 

Python 脚本

接下来,我将添加相应的 Python 脚本:

src/model/torch_model/inference.py

python 复制代码
import os  
import sys  
import json  
import datetime  
import numpy as np  
import pandas as pd  
import torch  
import shap

import src.model.torch_model.scripts as scripts  
from src._utils import main_logger

if __name__ == '__main__':  
      
    X_TEST_PATH = sys.argv[1]  
    Y_TEST_PATH = sys.argv[2]  
    X_test, y_test = pd.read_parquet(X_TEST_PATH), pd.read_parquet(Y_TEST_PATH)

      
    X_test_with_col_names = X_test.copy()  
    FEATURE_NAMES_PATH = os.path.join('preprocessors', 'feature_names.json')  
    try:  
        with open(FEATURE_NAMES_PATH, 'r') as f: feature_names = json.load(f)  
    except FileNotFoundError: feature_names = X_test.columns.tolist()  
    if len(X_test_with_col_names.columns) == len(feature_names): X_test_with_col_names.columns = feature_names

      
    MODEL_PATH = sys.argv[3]  
    checkpoint = torch.load(MODEL_PATH)  
    model = scripts.load_model(checkpoint=checkpoint)

      
    y_pred, mse, exp_mae, rmsle = scripts.perform_inference(model=model, X=X_test, y=y_test, batch_size=checkpoint['batch_size'])

      
    STOCKCODE = sys.argv[4]  
    SENSITIVE_FEATURE = sys.argv[5]  
    PRIVILEGED_GROUP = sys.argv[6]  
    inference_df = pd.DataFrame(y_pred.cpu().numpy().flatten(), columns=['y_pred'])  
    inference_df['y_true'] = y_test  
    inference_df[SENSITIVE_FEATURE] = X_test_with_col_names[f'cat__{SENSITIVE_FEATURE}_{str(PRIVILEGED_GROUP)}'].astype(bool)  
    inference_df.to_parquet(path=os.path.join('data', f'dfn_inference_results_{STOCKCODE}.parquet'))

      
    MODEL_INF_METRICS_PATH = os.path.join('metrics', f'dfn_inf_{STOCKCODE}.json')  
    os.makedirs(os.path.dirname(MODEL_INF_METRICS_PATH), exist_ok=True)  
    model_version = f"dfn_{STOCKCODE}_{os.getpid()}"  
    inf_metrics = dict(  
        stockcode=STOCKCODE,  
        mse_inf=mse,  
        mae_inf=exp_mae,  
        rmsle_inf=rmsle,  
        model_version=model_version,  
        hparams=checkpoint['hparams'],  
        optimizer=checkpoint['optimizer_name'],  
        batch_size=checkpoint['batch_size'],  
        lr=checkpoint['lr'],  
        timestamp=datetime.datetime.now().isoformat()  
    )  
    with open(MODEL_INF_METRICS_PATH, 'w') as f:   
        json.dump(inf_metrics, f, indent=4)  
        main_logger.info(f'... inference metrics saved to {MODEL_INF_METRICS_PATH} ...')

      
      
    model.eval()

      
    X_test_tensor = torch.from_numpy(X_test.values.astype(np.float32)).to(device_type)

      
    background = X_test_tensor[np.random.choice(X_test_tensor.shape[0], 100, replace=False)].to(device_type)

      
    explainer = shap.DeepExplainer(model, background)

      
    shap_values = explainer.shap_values(X_test_tensor) 

      
    if isinstance(shap_values, list): shap_values = shap_values[0]  
    if isinstance(shap_values, torch.Tensor): shap_values = shap_values.cpu().numpy()  
    shap_values = shap_values.squeeze(axis=-1)  
    shap_df = pd.DataFrame(shap_values, columns=feature_names)

      
    RAW_SHAP_OUT_PATH = os.path.join('reports', f'dfn_raw_shap_values_{STOCKCODE}.parquet')  
    os.makedirs(os.path.dirname(RAW_SHAP_OUT_PATH), exist_ok=True)  
    shap_df.to_parquet(RAW_SHAP_OUT_PATH, index=False)  
    main_logger.info(f'... shap values saved to {RAW_SHAP_OUT_PATH} ...')

      
    mean_abs_shap = shap_df.abs().mean().sort_values(ascending=False)  
    shap_mean_abs_df = pd.DataFrame({'feature_name': feature_names, 'mean_abs_shap': mean_abs_shap.values })  
    MEAN_ABS_SHAP_PATH = os.path.join('reports', f'dfn_shap_mean_abs_{STOCKCODE}.json')  
    shap_mean_abs_df.to_json(MEAN_ABS_SHAP_PATH, orient='records', indent=4)

输出

此阶段生成五个输出文件:

  • data/dfn_inference_result_${params_stockcode}.parquet:存储预测结果、标记的目标以及包含敏感特征(如性别、年龄、收入等)的任何列。我将在最后一个阶段使用此文件进行公平性测试。
  • metrics/dfn_inf.json:存储评估指标和调优结果:

metrics/dfn_inf.json

json 复制代码
{  
    "stockcode": "85123A",  
    "mse_inf": 0.6841545701026917,  
    "mae_inf": 11.5866117477417,  
    "rmsle_inf": 0.7423332333564758,  
    "model_version": "dfn_85123A_35834",  
    "hparams": {  
        "num_layers": 4,  
        "batch_norm": false,  
        "dropout_rate_layer_0": 0.13765888061300502,  
        "n_units_layer_0": 184,  
        "dropout_rate_layer_1": 0.5509872409359128,  
        "n_units_layer_1": 122,  
        "dropout_rate_layer_2": 0.2408753527744403,  
        "n_units_layer_2": 35,  
        "dropout_rate_layer_3": 0.03451842588822594,  
        "n_units_layer_3": 224,  
        "learning_rate": 0.026240673135104406,  
        "optimizer": "adamax",  
        "batch_size": 64  
    },  
    "optimizer": "adamax",  
    "batch_size": 64,  
    "lr": 0.026240673135104406,  
    "timestamp": "2025-10-07T00:31:12.946405"  
}

reports/dfn_shap_mean_abs.json:存储平均 SHAP 值:

json 复制代码
[  
    {  
        "feature_name":"num__invoicedate",  
        "mean_abs_shap":0.219255722  
    },  
    {  
        "feature_name":"num__unitprice",  
        "mean_abs_shap":0.1069829418  
    },  
    {  
        "feature_name":"num__product_avg_quantity_last_month",  
        "mean_abs_shap":0.1021453096  
    },  
    {  
        "feature_name":"num__product_max_price_all_time",  
        "mean_abs_shap":0.0855356899  
    },  
...  
]
  • reports/dfn_shap_summary.json:包含绘制蜂群/条形图所需的数据点。
  • reports/dfn_raw_shap_values.parquet:存储原始 SHAP 值。

3.6 阶段 6. 评估模型风险和公平性

最后一个阶段是评估最终推理结果的风险和公平性。

公平性测试

ML 中的公平性测试是系统地评估模型预测的过程,以确保它们不会对由敏感属性(如种族和性别)定义的特定群体产生不公平的偏见。

在此项目中,我将使用注册状态 is_registered 列作为敏感特征,并确保**平均结果差异(MOD)**在指定的阈值 0.1 0.1 0.1 内。

MOD 计算为特权(已注册)和非特权(未注册)群体的平均预测值之间的绝对差异。

DVC 配置

首先,我将在 inference_primary_model 阶段之后添加 assess_model_risk 阶段:

dvc.yaml

yaml 复制代码
stages:  
  etl_pipeline:  
      
  data_drift_check:  
      
  preprocess:  
      
  tune_primary_model:  
      
  inference_primary_model:  
      
  assess_model_risk:  
    cmd: >  
      python src/model/torch_model/assess_risk_and_fairness.py  
      data/dfn_inference_results_${params.stockcode}.parquet  
      metrics/dfn_risk_fairness_${params.stockcode}.json  
      ${tracking.sensitive_feature_col}  
      ${params.stockcode}  
      ${tracking.privileged_group}  
      ${tracking.mod_threshold}  
  
    deps:  
      - src/model/torch_model/assess_risk_and_fairness.py  
      - src/_utils/  
      - data/dfn_inference_results_${params.stockcode}.parquet 

    params:  
      - params.stockcode  
      - tracking.sensitive_feature_col  
      - tracking.privileged_group  
      - tracking.mod_threshold

    metrics:  
      - metrics/dfn_risk_fairness_${params.stockcode}.json:  
          type: json

然后,为参数添加默认值:

param.yaml

yaml 复制代码
params:  
  target_col: "quantity"  
  should_scale: True  
  verbose: False

tuning:  
  n_trials: 100  
  num_epochs: 3000  
  should_local_save: False  
  grid: False

  
tracking:  
  sensitive_feature_col: "is_registered"  
  privileged_group: 1   
  mod_threshold: 0.1

Python 脚本

相应的 Python 脚本包含 calculate_fairness_metrics 函数,该函数执行风险和公平性评估:

src/model/torch_model/assess_risk_and_fairness.py

python 复制代码
import os  
import json  
import datetime  
import argparse  
import pandas as pd  
from sklearn.metrics import mean_absolute_error, mean_squared_error, root_mean_squared_log_error

from src._utils import main_logger

def calculate_fairness_metrics(        df: pd.DataFrame,  
        sensitive_feature_col: str,  
        label_col: str = 'y_true',  
        prediction_col: str = 'y_pred',  
        privileged_group: int = 1,  
        mod_threshold: float = 0.1,    ) -> dict:  
    metrics = dict()  
    unprivileged_group = 0 if privileged_group == 1 else 1

      
    for group, name in zip([unprivileged_group, privileged_group], ['unprivileged', 'privileged']):  
        subset = df[df[sensitive_feature_col] == group]  
        if len(subset) == 0: continue  
        y_true = subset[label_col].values  
        y_pred = subset[prediction_col].values  
        metrics[f'mse_{name}'] = float(mean_squared_error(y_true, y_pred))  
        metrics[f'mae_{name}'] = float(mean_absolute_error(y_true, y_pred))  
        metrics[f'rmsle_{name}'] = float(root_mean_squared_log_error(y_true, y_pred))

          
        metrics[f'mean_prediction_{name}'] = float(y_pred.mean()) 

      
      
    mae_diff = metrics.get('mae_unprivileged', 0) - metrics.get('mae_privileged', 0)  
    metrics['mae_diff'] = float(mae_diff)

      
    mod = metrics.get('mean_prediction_unprivileged', 0) - metrics.get('mean_prediction_privileged', 0)  
    metrics['mean_outcome_difference'] = float(mod)  
    metrics['is_mod_acceptable'] = 1 if abs(mod) <= mod_threshold else 0

    return metrics

def main():  
    parser = argparse.ArgumentParser(description='assess bias and fairness metrics on model inference results.')  
    parser.add_argument('inference_file_path', type=str, help='parquet file path to the inference results w/ y_true, y_pred, and sensitive feature cols.')  
    parser.add_argument('metrics_output_path', type=str, help='json file path to save the metrics output.')  
    parser.add_argument('sensitive_feature_col', type=str, help='column name of sensitive features')  
    parser.add_argument('stockcode', type=str)  
    parser.add_argument('privileged_group', type=int, default=1)  
    parser.add_argument('mod_threshold', type=float, default=.1)  
    args = parser.parse_args()

    try:  
          
        df_inference = pd.read_parquet(args.inference_file_path)  
        LABEL_COL = 'y_true'  
        PREDICTION_COL = 'y_pred'  
        SENSITIVE_COL = args.sensitive_feature_col

          
        metrics = calculate_fairness_metrics(  
            df=df_inference,  
            sensitive_feature_col=SENSITIVE_COL,  
            label_col=LABEL_COL,  
            prediction_col=PREDICTION_COL,  
            privileged_group=args.privileged_group,  
            mod_threshold=args.mod_threshold,  
        )

          
        metrics['model_version'] = f'dfn_{args.stockcode}_{os.getpid()}'  
        metrics['sensitive_feature'] = args.sensitive_feature_col  
        metrics['privileged_group'] = args.privileged_group  
        metrics['mod_threshold'] = args.mod_threshold  
        metrics['stockcode'] = args.stockcode  
        metrics['timestamp'] = datetime.datetime.now().isoformat()

          
        with open(args.metrics_output_path, 'w') as f:  
            json_metrics = { k: (v if pd.notna(v) else None) for k, v in metrics.items() }  
            json.dump(json_metrics, f, indent=4)

    except Exception as e:  
        main_logger.error(f'... an error occurred during risk and fairness assessment: {e} ...')  
        exit(1)

  
if __name__ == '__main__':  
    main()

输出

最终阶段生成一个指标文件,其中包含测试结果和模型版本:

metrics/dfn_risk_fairness.json

json 复制代码
{  
    "mse_unprivileged": 3.5370739412593575,  
    "mae_unprivileged": 1.48263614013523,  
    "rmsle_unprivileged": 0.6080000224747837,  
    "mean_prediction_unprivileged": 1.8507767915725708,  
    "mae_diff": 1.48263614013523,  
    "mean_outcome_difference": 1.8507767915725708,  
    "is_mod_acceptable": 1,  
    "model_version": "dfn_85123A_35971",  
    "sensitive_feature": "is_registered",  
    "privileged_group": 1,  
    "mod_threshold": 0.1,  
    "timestamp": "2025-10-07T00:31:15.998590"  
}

这就是血缘配置的全部内容。

现在,我将在本地进行测试。

4 本地测试

我将运行整个血缘关系:

bash 复制代码
$dvc repro -f

-f 强制 DVC 重新运行所有阶段,无论是否有任何更新。

此命令将自动在项目根目录创建 dvc.lock 文件:

yaml 复制代码
schema: '2.0'  
stages:  
  etl_pipeline_full:  
    cmd: python src/data_handling/etl_pipeline.py  
    deps:  
    - path: src/_utils/  
      hash: md5  
      md5: ae41392532188d290395495f6827ed00.dir  
      size: 15870  
      nfiles: 10  
    - path: src/data_handling/  
      hash: md5  
      md5: a8a61a4b270581a7c387d51e416f4e86.dir  
      size: 95715  
...

dvc.lock 文件必须发布到 Git,以确保 DVC 将加载最新文件:

bash 复制代码
$git add dvc.lock .dvc dvc.yaml params.yaml  
$git commit -m'updated dvc config'  
$git push

5 步骤 3. 部署 DVC 项目

接下来,我将部署 DVC 项目,以确保 AWS Lambda 函数可以在生产中访问缓存文件。

此步骤首先配置 DVC 远程存储。

DVC 提供 各种存储类型,如 AWS S3 和 Google Cloud。我将为本项目选择 AWS S3,但选择取决于项目生态系统、熟悉程度和资源限制。

首先,我将在选定的 AWS 区域中创建一个新的 S3 存储桶:

bash 复制代码
$aws s3 mb s3://<PROJECT NAME>/<BUCKET NAME>  --region <AWS REGION>

确保 IAM 角色具有以下权限: _s3:ListBucket_ _s3:GetObject_ _s3:PutObject__ 和__s3:DeleteObject__)

然后,将 S3 存储桶的 URI 添加到 DVC 远程存储:

bash 复制代码
$dvc remote add -d <DVC REMOTE NAME> ss3://<PROJECT NAME>/<BUCKET NAME>

然后,我将缓存文件推送到 DVC 远程存储:

bash 复制代码
$dvc push

现在,所有缓存文件都存储在 S3 存储桶中:

图. AWS S3 存储桶中的 DVC 远程存储截图

图 A 所示,此部署步骤对于 AWS Lambda 函数在生产中访问 DVC 缓存是必需的。

6 步骤 4. 使用 Prefect 配置计划运行

下一步是使用 Prefect 配置整个血缘关系的计划运行。

Prefect 是一个开源工作流编排工具,用于构建、调度和监控管道。

它使用工作池的概念,有效地将编排逻辑与执行基础设施解耦。

然后,工作池通过运行 Docker 容器镜像作为标准化基础配置,以保证所有流程的一致执行环境。

6.1 配置 Docker 镜像注册表

第一步是为 Prefect 工作池配置 Docker 镜像注册表:

  • 对于本地部署:Docker Hub 中的容器注册表。
  • 对于生产部署:AWS ECR

对于本地部署,我将首先验证 Docker 客户端:

bash 复制代码
$docker login

并授予用户无需 sudo 即可运行 Docker 命令的权限:

bash 复制代码
$sudo dscl . -append /Groups/docker GroupMembership $USER

对于生产部署,我将创建一个新的 ECR:

bash 复制代码
$aws ecr create-repository --repository-name <REGISTORY NAME> --region <AWS REGION>

确保 IAM 角色可以访问此新的 ECR URI。

6.2 配置 Prefect 任务和流程

接下来,我将配置项目中的 Prefect taskflow

  • Prefect task 执行 dvc reprodvc push 命令
  • Prefect flow 每周执行 Prefect task

src/prefect_flows.py

python 复制代码
import os  
import sys  
import subprocess  
from datetime import timedelta, datetime  
from dotenv import load_dotenv  
from prefect import flow, task  
from prefect.schedules import Schedule  
from prefect_aws import AwsCredentials

from src._utils import main_logger

  
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))

  
@task(retries=3, retry_delay_seconds=30)  
def run_dvc_pipeline():  
      
    result = subprocess.run(["dvc", "repro"], capture_output=True, text=True, check=True)

      
    subprocess.run(["dvc", "push"], check=True)

  
@flow(name="Weekly Data Pipeline")  
def weekly_data_flow():  
    run_dvc_pipeline()

if __name__ == '__main__':  
      
    load_dotenv(override=True)  
    ENV = os.getenv('ENV', 'production')  
    DOCKER_HUB_REPO = os.getenv('DOCKER_HUB_REPO')  
    ECR_FOR_PREFECT_PATH = os.getenv('S3_BUCKET_FOR_PREFECT_PATH')  
    image_repo = f'{DOCKER_HUB_REPO}:ml-sales-pred-data-latest' if ENV == 'local' else f'{ECR_FOR_PREFECT_PATH}:latest'

      
    weekly_schedule = Schedule(  
        interval=timedelta(weeks=1),  
        anchor_date=datetime(2025, 9, 29, 9, 0, 0),  
        active=True,  
    )

          
    AwsCredentials(  
        aws_access_key_id=os.getenv('AWS_ACCESS_KEY_ID'),  
        aws_secret_access_key=os.getenv('AWS_SECRET_ACCESS_KEY'),  
        region_name=os.getenv('AWS_REGION_NAME'),  
    ).save('aws', overwrite=True)

          
    weekly_data_flow.deploy(  
        name='weekly-data-flow',  
        schedule=weekly_schedule,   
        work_pool_name="wp-ml-sales-pred",   
        image=image_repo,   
        concurrency_limit=3,  
        push=True   
    )

6.3 本地测试

接下来,我将使用 Prefect 服务器在本地测试工作流:

bash 复制代码
$uv run prefect server start
bash 复制代码
$export PREFECT_API_URL="http://127.0.0.1:4200/api"

运行 prefect_flows.py 脚本:

bash 复制代码
$uv run src/prefect_flows.py

成功执行后,Prefect 仪表板将显示工作流已计划运行:

图. Prefect 仪表板截图

7 步骤 5. 部署应用程序

最后一步是通过配置 Dockerfile 和 Flask 应用程序脚本来部署整个应用程序作为容器化 Lambda。

此最终部署步骤中的具体过程取决于基础设施。

但共同点是 DVC 消除了将大型 Parquet 或 CSV 文件直接存储在特征存储或模型存储中的需求,因为它将它们缓存为轻量级哈希文件。

因此,首先,我将通过使用 dvc.api 框架简化 Flask 应用程序脚本的加载逻辑:

app.py

python 复制代码
import dvc.api

DVC_REMOTE_NAME=<REMOTE NAME IN .dvc/config file>

def configure_dvc_for_lambda():  
      
    os.environ.update({  
        'DVC_CACHE_DIR': '/tmp/dvc-cache',  
        'DVC_DATA_DIR': '/tmp/dvc-data',  
        'DVC_CONFIG_DIR': '/tmp/dvc-config',  
        'DVC_GLOBAL_CONFIG_DIR': '/tmp/d
```python
import dvc.api

DVC_REMOTE_NAME=<REMOTE NAME IN .dvc/config file>

def configure_dvc_for_lambda():  
      
    os.environ.update({  
        'DVC_CACHE_DIR': '/tmp/dvc-cache',  
        'DVC_DATA_DIR': '/tmp/dvc-data',  
        'DVC_CONFIG_DIR': '/tmp/dvc-config',  
        'DVC_GLOBAL_CONFIG_DIR': '/tmp/dvc-global-config',  
        'DVC_SITE_CACHE_DIR': '/tmp/dvc-site-cache'  
    })  
    for dir_path in ['/tmp/dvc-cache', '/tmp/dvc-data', '/tmp/dvc-config']:  
        os.makedirs(dir_path, exist_ok=True)

def load_x_test():  
    global X_test  
    if not os.environ.get('PYTEST_RUN', False):  
        main_logger.info("... loading x_test ...")

                  
        configure_dvc_for_lambda()  
        try:  
            with dvc.api.open(X_TEST_PATH, remote=DVC_REMOTE_NAME, mode='rb') as fd:  
                X_test = pd.read_parquet(fd)  
                main_logger.info('✅ successfully loaded x_test via dvc api')  
        except Exception as e:  
            main_logger.error(f'❌ general loading error: {e}', exc_info=True)

def load_preprocessor():  
    global preprocessor  
    if not os.environ.get('PYTEST_RUN', False):  
        main_logger.info("... loading preprocessor ...")  
        configure_dvc_for_lambda()  
        try:  
            with dvc.api.open(PREPROCESSOR_PATH, remote=DVC_REMOTE_NAME, mode='rb') as fd:  
                preprocessor = joblib.load(fd)  
                main_logger.info('✅ successfully loaded preprocessor via dvc api')

               except Exception as e:  
            main_logger.error(f'❌ general loading error: {e}', exc_info=True)

然后,更新 Dockerfile 以使 Docker 能够正确引用 DVC 组件:

Dockerfile.lambda.production

dockerfile 复制代码
FROM public.ecr.aws/lambda/python:3.12

  
ENV JOBLIB_MULTIPROCESSING=0  
ENV DVC_HOME="/tmp/.dvc"  
ENV DVC_CACHE_DIR="/tmp/.dvc/cache"  
ENV DVC_REMOTE_NAME="storage"  
ENV DVC_GLOBAL_SITE_CACHE_DIR="/tmp/dvc_global"

  
COPY requirements.txt ${LAMBDA_TASK_ROOT}  
RUN python -m pip install --upgrade pip  
RUN pip install --no-cache-dir -r requirements.txt  
RUN pip install --no-cache-dir dvc dvc-s3

  
RUN dvc init --no-scm  
RUN dvc config core.no_scm true

  
COPY . ${LAMBDA_TASK_ROOT}

  
CMD [ "app.handler" ]

最后,确保大型文件从 Docker 容器镜像中被忽略:

.dockerignore

复制代码
.dvc/cache  
.dvcignore

  
data/  
preprocessors/  
models/  
reports/  
metrics/

7.1 本地测试

最后,我将构建并测试 Docker 镜像:

bash 复制代码
$docker build -t my-app -f Dockerfile.lambda.local .  
$docker run -p 5002:5002 -e ENV=local my-app app.py

成功配置后,waitress 服务器将运行 Flask 应用程序。


确认更改后,我将代码推送到 Git:

bash 复制代码
$git add .  
$git commit -m'updated dockerfiles and flask app scripts'  
$git push

push 命令通过 GitHub Actions 触发 CI/CD 管道,该管道生成 Docker 容器镜像并将其推送到 AWS ECR。

在管道流程成功并验证后,我们可以使用 GitHub Actions 手动运行部署工作流

要设置此 CI/CD 管道,请查看 集成基础设施 CI/CD 管道

这就是 ML 血缘关系集成的全部内容。

所有代码都可以在 我的 GitHub 仓库中找到。

模拟应用程序也可以在 我的作品集网站上找到。

8 结论

构建强大的 ML 应用程序需要全面的 ML 血缘关系,以确保可靠性和可追溯性。

在本文中,我们演示了如何通过集成 DVC 和 Prefect 等开源服务来构建 ML 血缘关系。

在实践中,初始规划很重要。

具体来说,定义如何以及在哪个阶段跟踪指标直接导致更清晰、更易于维护的代码结构和未来的可扩展性。

展望未来,我们可以考虑在血缘关系中添加更多阶段,并集成用于数据漂移检测或公平性测试的高级逻辑。

这将进一步确保生产环境中模型的持续性能和数据完整性。

相关推荐
007php0074 小时前
百度面试题解析:微服务架构、Dubbo、Redis及其一致性问题(一)
redis·百度·docker·微服务·容器·职场和发展·架构
WWZZ20254 小时前
快速上手大模型:机器学习2(一元线性回归、代价函数、梯度下降法)
人工智能·算法·机器学习·计算机视觉·机器人·大模型·slam
koo3646 小时前
李宏毅机器学习笔记21-26周汇总
人工智能·笔记·机器学习
Blossom.1187 小时前
把AI“撒”进农田:基于极值量化与状态机的1KB边缘灌溉决策树
人工智能·python·深度学习·算法·目标检测·决策树·机器学习
救救孩子把9 小时前
18-机器学习与大模型开发数学教程-第1章 1-10 本章总结与习题
人工智能·数学·机器学习
救救孩子把9 小时前
17-机器学习与大模型开发数学教程-第1章 1-9 凸函数与凸优化基础
人工智能·数学·机器学习
明月照山海-9 小时前
机器学习周报十八
人工智能·机器学习
敢敢のwings10 小时前
VLA: 从具身智能到自动驾驶的关键桥梁
人工智能·机器学习·自动驾驶
尘世中一位迷途小书童10 小时前
版本管理实战:Changeset 工作流完全指南(含中英文对照)
前端·面试·架构