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 血缘关系。

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

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

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

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

相关推荐
yunteng52122 分钟前
通用架构(同城双活)(单点接入)
架构·同城双活·单点接入
麦聪聊数据1 小时前
Web 原生架构如何重塑企业级数据库协作流?
数据库·sql·低代码·架构
小鸡吃米…2 小时前
机器学习的商业化变现
人工智能·机器学习
程序员侠客行2 小时前
Mybatis连接池实现及池化模式
java·后端·架构·mybatis
bobuddy3 小时前
射频收发机架构简介
架构·射频工程
桌面运维家3 小时前
vDisk考试环境IO性能怎么优化?VOI架构实战指南
架构
木非哲4 小时前
机器学习--随机森林--从一棵树的直觉到一片林的哲学
人工智能·随机森林·机器学习
一个骇客5 小时前
让你的数据成为“操作日志”和“模型饲料”:事件溯源、CQRS与DataFrame漫谈
架构
A尘埃5 小时前
保险公司车险理赔欺诈检测(随机森林)
算法·随机森林·机器学习
鹏北海-RemHusband6 小时前
从零到一:基于 micro-app 的企业级微前端模板完整实现指南
前端·微服务·架构