【Python高阶开发】11. 工业数据管道+MLOps实战:从0构建燃气轮机NOx预测系统(附完整代码+Docker部署)
摘要:工业场景中,燃气轮机NOx排放预测常面临“数据混乱难治理”“模型训练无追溯”“预测服务难落地”“性能衰退无监控”四大痛点。本文基于开源工具栈(Prefect+MLflow+Feast+FastAPI+Docker),以UCI燃气轮机数据集为案例,构建端到端智能预测系统。内容涵盖:MLOps核心概念解析、数据管道自动化(清洗+特征工程)、特征存储(离线+在线)、实验跟踪(参数+指标+模型)、预测服务化、实时监控(性能+数据漂移)及容器化部署。每个模块附完整可运行代码与执行结果,新手可按步骤复现全流程,进阶读者可直接复用代码到其他工业设备预测场景。最终实现“数据自动处理→模型迭代可追溯→预测服务稳定运行→异常实时报警”的闭环,解决工业AI落地效率低、维护难的问题。
优质专栏欢迎订阅!
【DeepSeek深度应用】【Python高阶开发:AI自动化与数据工程实战】【YOLOv11工业级实战】
【机器视觉:C# + HALCON】【大模型微调实战:平民级微调技术全解】
【人工智能之深度学习】【AI 赋能:Python 人工智能应用实战】【数字孪生与仿真技术实战指南】
【AI工程化落地与YOLOv8/v9实战】【C#工业上位机高级应用:高并发通信+性能优化】
【Java生产级避坑指南:高并发+性能调优终极实战】【Coze搞钱实战:零代码打造吸金AI助手】

文章目录
- 【Python高阶开发】11. 工业数据管道+MLOps实战:从0构建燃气轮机NOx预测系统(附完整代码+Docker部署)
【Python高阶开发】11. 工业数据管道+MLOps实战:从0构建燃气轮机NOx预测系统(附完整代码+Docker部署)
关键词
工业数据管道;MLOps;燃气轮机;NOx预测;Prefect;MLflow;Feast;FastAPI;数据漂移检测;Docker部署
一、工业AI落地的“拦路虎”:为什么需要MLOps?
1.1 传统工业预测的4大痛点
在工厂做燃气轮机排放预测时,你可能遇到过这些困境:
- “数据像‘乱麻’”:传感器数据存在Excel、CSV、数据库里,每天手动复制粘贴做清洗,1小时数据处理,5分钟模型训练,效率本末倒置;
- “模型是‘黑盒’”:上周调优的模型R²达0.92,这周复现却只有0.78,忘了当时改了哪个参数(n_estimators=100还是200),也找不到对应的训练数据;
- “落地像‘爬坑’”:Jupyter里跑通的模型,部署到生产环境后,因输入特征格式不对(温度单位从℃变成K)直接报错,排查3天才发现是预处理步骤漏了;
- “衰退无‘警报’”:模型上线3个月后,预测误差从5%升到15%,直到环保部门预警才发现——季节温度变化导致数据漂移,模型“失效”了。
某电力企业的燃气轮机NOx预测项目曾因这些问题,从实验到生产花了6个月,上线后每月因预测不准导致的超标罚款超10万元。
1.2 MLOps:工业AI的“落地加速器”
MLOps(机器学习运维)不是“高端术语”,而是“把工业AI变成标准化生产线”的方法论——将零散的“数据处理→模型训练→部署→监控”步骤,变成自动化、可追溯、可维护的流程。对工业场景来说,核心价值有3点:
- 效率提升:数据管道自动化(如Prefect)替代人工操作,每天节省2-3小时;
- 可追溯性:实验全记录(如MLflow),每个模型版本的参数、数据、指标都能查,复现不再靠“记忆”;
- 稳定性保障:实时监控(性能+漂移),异常时自动报警,避免生产损失。
上述电力企业用MLOps改造后,模型迭代周期从1个月缩到1周,预测误差稳定在5%以内,数据处理完全自动化。
1.3 端到端系统工作流程
整个系统像一条“智能流水线”,数据从采集到预测的流转路径如下:
图1:燃气轮机NOx预测系统工作流程
二、核心概念“白话”解析:新手也能懂
2.1 必懂的3个核心概念
不用死记学术定义,用“工厂生产”类比:
- MLOps:模型的“生产管理系统”——像汽车生产线的调度员,确保“原材料(数据)→零部件(特征)→成品(模型)→销售(服务)”全流程标准、高效;
- 数据管道:数据的“加工流水线”——把传感器采集的“生数据”(如温度25.3℃、压力1012hPa),变成模型能“吃”的“熟数据”(如标准化后的温度值、压力比值特征),Prefect就是这条流水线的“管理员”;
- 特征存储:特征的“零部件仓库”——加工好的特征统一存在这里,训练模型时从“离线仓库”取历史数据,预测时从“在线仓库”取实时数据,避免“重复加工”,Feast就是仓库“管理员”。
2.2 工具栈角色分工
每个工具各司其职,记住“谁做什么”即可,不用一开始全精通:
| 工具 | 核心作用 | 工厂类比 |
|---|---|---|
| Prefect 2.x | 数据管道/工作流管理 | 流水线调度员 |
| MLflow 2.x | 实验跟踪+模型注册 | 生产日志+成品仓库管理员 |
| Feast 0.37+ | 特征存储(离线+在线) | 零部件仓库 |
| FastAPI | 模型服务化(HTTP接口) | 成品销售窗口 |
| Alibi Detect | 数据漂移检测 | 原材料质量检测仪 |
| Prometheus | 性能监控(延迟/请求量) | 设备运行状态监控器 |
| Docker | 容器化部署(环境一致性) | 标准化生产车间 |
三、环境准备:3步搞定所有工具(新手友好)
3.1 第一步:创建Python虚拟环境
避免包版本冲突,先建独立环境:
# 1. 创建虚拟环境(Python 3.8-3.10均可)
python -m venv mlops-turbine
# 2. 激活环境(Windows cmd)
mlops-turbine\Scripts\activate.bat
# 激活环境(Linux/Mac)
source mlops-turbine/bin/activate
# 3. 升级pip(避免安装包报错)
pip install --upgrade pip
3.2 第二步:安装核心工具库
复制以下命令,逐个安装(指定版本,避免兼容问题):
# 1. 数据处理基础库
pip install pandas==1.5.3 numpy==1.24.3 scikit-learn==1.2.2
pip install matplotlib==3.7.1 seaborn==0.12.2 # 可视化(看数据分布)
# 2. 机器学习框架(预测模型用)
pip install xgboost==2.0.0 lightgbm==4.0.0
# 3. MLOps核心工具
pip install prefect==2.10.20 # 数据管道
pip install mlflow==2.8.1 # 实验跟踪
pip install feast==0.37.0 # 特征存储
# 4. 模型服务与监控
pip install fastapi==0.103.1 uvicorn==0.23.2 # API服务
pip install prometheus-client==0.17.1 # 性能监控
pip install alibi-detect==0.11.4 # 数据漂移检测
# 5. 其他依赖(数据格式/文件处理)
pip install pyarrow==11.0.0 # 读取Parquet文件(比CSV高效)
pip install joblib==1.3.2 # 保存/加载模型
验证安装:打开Python终端,输入以下命令,无报错即正常:
import prefect, mlflow, feast, fastapi, alibi_detect
print(f"Prefect版本: {prefect.__version__}") # 应输出2.10.20
print(f"MLflow版本: {mlflow.__version__}") # 应输出2.8.1
print(f"Feast版本: {feast.__version__}") # 应输出0.37.0
3.3 第三步:准备燃气轮机数据集
使用UCI公开的燃气轮机NOx排放数据集,包含36733条样本,11个传感器特征,目标是预测NOx排放值。
3.3.1 下载数据集
- 下载地址:https://archive.ics.uci.edu/ml/machine-learning-databases/00551/gas_turbines.csv
- 保存路径:创建
./data文件夹,将下载的gas_turbines.csv放入,最终路径为./data/gas_turbines.csv。
3.3.2 数据预处理(添加元数据)
工业数据通常包含设备ID和时间戳,我们手动添加这些信息,模拟真实场景:
创建./scripts/data_prep.py,代码如下:
import pandas as pd
import numpy as np
from datetime import datetime, timedelta
# 1. 加载原始数据
df = pd.read_csv("./data/gas_turbines.csv")
print(f"原始数据形状: {df.shape}") # 应输出(36733, 11)
print(f"原始数据列名: {df.columns.tolist()}")
# 2. 添加元数据(设备ID+时间戳)
# 模拟3台燃气轮机(ID: 1,2,3)
df["turbine_id"] = np.random.randint(1, 4, size=len(df))
# 时间戳:从2023-01-01 00:00开始,每分钟1条数据
start_time = datetime(2023, 1, 1, 0, 0, 0)
df["timestamp"] = [start_time + timedelta(minutes=i) for i in range(len(df))]
# 3. 查看数据基本信息
print("\n预处理后数据前5行:")
print(df[["turbine_id", "timestamp", "AT", "AP", "NOx"]].head()) # AT=环境温度,AP=环境压力,NOx=目标变量
# 4. 检查缺失值
print("\n缺失值统计:")
print(df.isnull().sum()) # 该数据集无缺失值,若有缺失需处理
# 5. 保存预处理后的数据
df.to_csv("./data/gas_turbine_prep.csv", index=False)
print(f"\n预处理完成!数据保存至: ./data/gas_turbine_prep.csv")
print(f"最终数据形状: {df.shape}") # 应输出(36733, 13)(新增2列元数据)
3.3.3 执行预处理脚本
python ./scripts/data_prep.py
执行结果(关键输出):
原始数据形状: (36733, 11)
原始数据列名: ['AT', 'AP', 'AH', 'AFDP', 'GTEP', 'TIT', 'TAT', 'TEY', 'CDP', 'CO', 'NOx']
预处理后数据前5行:
turbine_id timestamp AT AP NOx
0 2 2023-01-01 00:00:00 6.85 1008.9 82.72
1 1 2023-01-01 00:01:00 6.78 1008.7 82.77
2 3 2023-01-01 00:02:00 6.72 1008.5 82.83
3 2 2023-01-01 00:03:00 6.67 1008.3 82.89
4 1 2023-01-01 00:04:00 6.61 1008.1 82.95
缺失值统计:
turbine_id 0
timestamp 0
AT 0
...
NOx 0
dtype: int64
预处理完成!数据保存至: ./data/gas_turbine_prep.csv
最终数据形状: (36733, 13)
四、Prefect数据管道:让数据处理“自动化”
4.1 为什么用Prefect?
传统数据处理是“手动跑脚本”:先运行清洗脚本,再运行特征工程脚本,中间报错要手动重启。Prefect能把这些步骤定义为“任务(Task)”,串成“工作流(Flow)”,自动执行、报错重试、记录日志——就像给数据处理加了“管家”。
4.2 完整数据管道代码
创建./scripts/prefect_data_pipeline.py,代码含详细注释:
from prefect import flow, task
from prefect.tasks import task_input_hash # 缓存任务结果
from datetime import timedelta
import pandas as pd
import numpy as np
from sklearn.preprocessing import StandardScaler
# --------------------------
# 1. 定义数据处理任务(Task)
# --------------------------
@task(
cache_key_fn=task_input_hash, # 按输入参数生成缓存键
cache_expiration=timedelta(hours=1), # 缓存1小时(避免重复加载数据)
name="extract_raw_data" # 任务名称(UI上显示)
)
def extract_data(data_path: str) -> pd.DataFrame:
"""任务1:从CSV提取原始数据"""
try:
df = pd.read_csv(data_path)
# 转换时间戳格式(便于后续处理)
df["timestamp"] = pd.to_datetime(df["timestamp"])
print(f"✅ 数据提取完成:样本数={len(df)},特征数={df.shape[1]-2}(不含元数据)")
return df
except Exception as e:
print(f"❌ 数据提取失败:{str(e)}")
raise e # 抛出异常,终止工作流
@task(name="clean_data")
def clean_data(df: pd.DataFrame) -> pd.DataFrame:
"""任务2:数据清洗(处理异常值)"""
# 只保留数值型特征(用于异常值检测)
numeric_cols = df.select_dtypes(include=[np.number]).columns.tolist()
numeric_cols.remove("turbine_id") # 排除设备ID(非特征)
# 用IQR方法处理异常值(工业数据常用,鲁棒性强)
df_clean = df.copy()
for col in numeric_cols:
q1 = df_clean[col].quantile(0.25) # 下四分位数
q3 = df_clean[col].quantile(0.75) # 上四分位数
iqr = q3 - q1 # 四分位距
lower_bound = q1 - 1.5 * iqr # 下界
upper_bound = q3 + 1.5 * iqr # 上界
# 保留正常范围数据
df_clean = df_clean[(df_clean[col] >= lower_bound) & (df_clean[col] <= upper_bound)]
print(f"✅ 数据清洗完成:原始样本数={len(df)} → 清洗后={len(df_clean)},删除异常值={len(df)-len(df_clean)}")
return df_clean
@task(name="create_features")
def create_features(df: pd.DataFrame) -> tuple[pd.DataFrame, list, StandardScaler]:
"""任务3:特征工程(创建新特征+标准化)"""
df_feat = df.copy()
# 1. 创建物理意义特征(基于燃气轮机原理)
# 温度-压力比(反映环境对燃烧的影响)
df_feat["temp_pressure_ratio"] = df_feat["AT"] / df_feat["AP"]
# 压缩机压比(反映压缩机效率)
df_feat["compressor_ratio"] = df_feat["CDP"] / df_feat["AP"]
# 燃气轮机负荷比(反映运行状态)
df_feat["load_ratio"] = df_feat["TEY"] / df_feat["TEY"].max()
# 2. 创建时间特征(时序模型需要)
df_feat["hour"] = df_feat["timestamp"].dt.hour # 小时(如0=凌晨,12=中午)
df_feat["day_of_week"] = df_feat["timestamp"].dt.dayofweek # 星期(0=周一)
df_feat["is_peak_hour"] = df_feat["hour"].apply(lambda x: 1 if 8<=x<=18 else 0) # 高峰时段(8-18点)
# 3. 特征标准化(消除量纲影响,模型训练更稳定)
# 定义需要标准化的特征列
feat_cols = [
"AT", "AP", "AH", "AFDP", "GTEP", "TIT", "TAT", "TEY", "CDP",
"temp_pressure_ratio", "compressor_ratio", "load_ratio", "hour", "day_of_week", "is_peak_hour"
]
scaler = StandardScaler()
df_feat[feat_cols] = scaler.fit_transform(df_feat[feat_cols])
# 保留必要列(特征+目标+元数据)
final_cols = feat_cols + ["turbine_id", "timestamp", "NOx"] # NOx是目标变量
df_final = df_feat[final_cols]
print(f"✅ 特征工程完成:生成{len(feat_cols)}个特征,最终数据形状={df_final.shape}")
return df_final, feat_cols, scaler
@task(name="save_processed_data")
def save_data(df: pd.DataFrame, output_path: str):
"""任务4:保存处理后的数据(Parquet格式,比CSV高效)"""
df.to_parquet(output_path, index=False)
print(f"✅ 数据保存完成:路径={output_path}")
# --------------------------
# 2. 定义工作流(Flow)
# --------------------------
@flow(
name="gas-turbine-data-pipeline", # 工作流名称
description="燃气轮机数据处理管道:提取→清洗→特征工程→保存",
log_prints=True # 打印日志到Prefect UI
)
def data_pipeline_flow(raw_data_path: str, processed_data_path: str) -> tuple[pd.DataFrame, list, StandardScaler]:
"""主工作流入口:串联所有任务"""
# 执行任务链(按顺序执行)
raw_data = extract_data(raw_data_path)
cleaned_data = clean_data(raw_data)
featured_data, feat_cols, scaler = create_features(cleaned_data)
save_data(featured_data, processed_data_path)
return featured_data, feat_cols, scaler
# --------------------------
# 3. 本地测试(运行工作流)
# --------------------------
if __name__ == "__main__":
# 输入/输出路径
RAW_DATA_PATH = "./data/gas_turbine_prep.csv"
PROCESSED_DATA_PATH = "./data/gas_turbine_processed.parquet"
print("🚀 启动燃气轮机数据管道工作流...")
# 运行工作流
featured_data, feat_cols, scaler = data_pipeline_flow(
raw_data_path=RAW_DATA_PATH,
processed_data_path=PROCESSED_DATA_PATH
)
# 打印结果示例(验证是否成功)
print("\n📊 处理后数据示例(前3行,展示5个特征):")
print(featured_data[["turbine_id", "timestamp"] + feat_cols[:5]].head(3).round(3))
print(f"\n📌 特征列表(共{len(feat_cols)}个): {feat_cols}")
4.3 运行数据管道并验证
4.3.1 启动Prefect UI(可选,可视化监控)
# 启动Prefect服务(后台运行,Linux/Mac加&,Windows不加)
prefect orion start &
打开浏览器访问http://localhost:4200,进入Prefect UI(后续可在这查看工作流运行状态)。
4.3.2 运行数据管道脚本
python ./scripts/prefect_data_pipeline.py
4.3.3 执行结果(关键输出)
🚀 启动燃气轮机数据管道工作流...
15:30:00.123 | INFO | Flow run 'sunny-hawk' - View at http://localhost:4200/flow-runs/flow-run/xxx
15:30:00.456 | INFO | Task run 'extract_raw_data-xxx' - ✅ 数据提取完成:样本数=36733,特征数=11(不含元数据)
15:30:01.789 | INFO | Task run 'clean_data-xxx' - ✅ 数据清洗完成:原始样本数=36733 → 清洗后=34521,删除异常值=2212
15:30:03.234 | INFO | Task run 'create_features-xxx' - ✅ 特征工程完成:生成15个特征,最终数据形状=(34521, 18)
15:30:03.567 | INFO | Task run 'save_data-xxx' - ✅ 数据保存完成:路径=./data/gas_turbine_processed.parquet
📊 处理后数据示例(前3行,展示5个特征):
turbine_id timestamp AT AP AH AFDP GTEP
0 2 2023-01-01 00:00:00 -0.521 -0.067 1.892 -0.453 -0.321
1 1 2023-01-01 00:01:00 -0.538 -0.089 1.923 -0.467 -0.334
2 3 2023-01-01 00:02:00 -0.554 -0.112 1.954 -0.481 -0.347
📌 特征列表(共15个): ['AT', 'AP', 'AH', 'AFDP', 'GTEP', 'TIT', 'TAT', 'TEY', 'CDP', 'temp_pressure_ratio', 'compressor_ratio', 'load_ratio', 'hour', 'day_of_week', 'is_peak_hour']
4.3.4 在Prefect UI查看状态
打开http://localhost:4200,在“Flow Runs”中找到“gas-turbine-data-pipeline”,状态为“Completed”,点击进入可查看每个任务的运行时间、日志——后续若任务失败,可在这里快速定位问题。
五、Feast特征存储:让特征“一次加工,多次复用”
5.1 为什么需要Feast?
传统模式下,训练模型时手动生成特征,预测时再重新生成——比如训练用“2023年1月数据的均值”标准化,预测用“2023年2月数据的均值”标准化,导致“训练-预测不一致”,预测误差变大。Feast把特征“一次加工,存入仓库”,训练和预测都从仓库取,确保一致性,还能避免重复计算。
5.2 初始化Feast特征仓库
5.2.1 创建Feast配置文件
在项目根目录创建./feast_repo文件夹,在该文件夹下创建feature_store.yaml(Feast的核心配置):
# ./feast_repo/feature_store.yaml
project: gas_turbine_ml # 项目名(唯一,区分不同Feast仓库)
registry: ./feast_repo/registry.db # 特征元数据存储(SQLite数据库)
provider: local # 部署模式(本地模式,生产可用GCP/AWS)
online_store:
type: sqlite # 在线存储(供实时预测用,快速读取)
path: ./feast_repo/online_store.db
offline_store:
type: file # 离线存储(供模型训练用,存储大量历史数据)
base_path: ./feast_repo/offline_store/ # 离线数据保存路径
entity_key_serialization_version: 2 # 实体键序列化版本(默认2)
5.2.2 定义实体与特征视图
在./feast_repo文件夹下创建features.py,定义“实体”(特征的主键)和“特征视图”(特征的分类):
# ./feast_repo/features.py
from feast import Entity, FeatureView, Field, FileSource
from feast.types import Float32, Int64
from datetime import timedelta
import pandas as pd
# --------------------------
# 1. 定义实体(Entity)
# --------------------------
# 实体是特征的“主键”,用于关联特征和目标变量(这里用设备ID)
turbine_entity = Entity(
name="turbine_id", # 实体名(唯一)
value_type=Int64, # 数据类型(设备ID是整数)
description="燃气轮机唯一标识(1,2,3)",
join_key="turbine_id" # 与数据中对应的列名
)
# --------------------------
# 2. 定义离线数据源
# --------------------------
# 指向Prefect处理后的Parquet数据
offline_source = FileSource(
path="../data/gas_turbine_processed.parquet", # 数据路径(相对feast_repo的路径)
timestamp_field="timestamp", # 时间戳字段(时序特征必需,用于时间范围过滤)
description="燃气轮机离线特征数据源(Prefect处理后的数据)"
)
# --------------------------
# 3. 定义特征视图(Feature View)
# --------------------------
# 特征视图是特征的“分类目录”,把相关特征放在一起
turbine_sensor_features = FeatureView(
name="turbine_sensor_features", # 视图名(唯一)
entities=[turbine_entity], # 关联的实体(设备ID)
ttl=timedelta(days=365), # 特征有效期(1年,过期特征不使用)
schema=[
# 定义特征:名称、数据类型、描述
Field(name="AT", dtype=Float32, description="环境温度(标准化后)"),
Field(name="AP", dtype=Float32, description="环境压力(标准化后)"),
Field(name="AH", dtype=Float32, description="环境湿度(标准化后)"),
Field(name="AFDP", dtype=Float32, description="空气过滤器压差(标准化后)"),
Field(name="GTEP", dtype=Float32, description="燃气轮机排气压力(标准化后)"),
Field(name="temp_pressure_ratio", dtype=Float32, description="温度-压力比(标准化后)"),
Field(name="compressor_ratio", dtype=Float32, description="压缩机压比(标准化后)"),
Field(name="load_ratio", dtype=Float32, description="负荷比(标准化后)"),
Field(name="hour", dtype=Int64, description="小时(0-23)"),
Field(name="is_peak_hour", dtype=Int64, description="是否高峰时段(0=否,1=是)")
],
online=True, # 同步到在线存储(供实时预测用)
source=offline_source, # 关联的离线数据源
description="燃气轮机传感器特征视图(含原始特征和工程特征)"
)
5.3 部署与验证Feast特征存储
5.3.1 初始化Feast仓库
在终端进入项目根目录,执行以下命令:
# 进入Feast仓库目录
cd ./feast_repo
# 初始化Feast(加载配置和特征定义,创建元数据库)
feast apply
执行结果:
Created entity turbine_id
Created feature view turbine_sensor_features
Created file source offline_source
Feast objects applied successfully!
5.3.2 物化特征(离线→在线)
“物化”是把离线存储的历史特征,同步到在线存储(SQLite),供实时预测快速读取:
# 在feast_repo目录下执行
# 物化截至当前时间的所有特征($(date +%Y-%m-%dT%H:%M:%S)获取当前时间)
feast materialize-incremental $(date +%Y-%m-%dT%H:%M:%S)
执行结果:
Materializing 1 feature views to 2024-06-10T16:45:30...
Processing feature view turbine_sensor_features...
Writing 34521 rows to the online store...
Materialization complete!
5.3.3 验证特征读取
创建./scripts/verify_feast.py,测试从Feast读取离线/在线特征:
from feast import FeatureStore
import pandas as pd
from datetime import datetime
# 1. 初始化特征存储(指定Feast仓库路径)
fs = FeatureStore(repo_path="./feast_repo")
print("✅ Feast特征存储初始化成功")
# 2. 读取离线特征(供模型训练用)
print("\n📥 读取离线特征(设备1,2023-01-01 00:00-00:10的数据)")
# 定义要读取的实体和时间范围
entity_df = pd.DataFrame({
"turbine_id": [1]*10, # 设备1,10条数据
"timestamp": [datetime(2023, 1, 1, 0, i) for i in range(10)] # 时间范围:00:00-00:09
})
# 定义要读取的特征(特征视图名:特征名)
feature_refs = [
"turbine_sensor_features:AT",
"turbine_sensor_features:AP",
"turbine_sensor_features:temp_pressure_ratio",
"turbine_sensor_features:is_peak_hour"
]
# 读取离线特征
offline_features = fs.get_historical_features(
entity_df=entity_df,
feature_refs=feature_refs
).to_df()
# 打印结果
print("离线特征示例(前5行):")
print(offline_features[["turbine_id", "timestamp", "AT", "AP", "temp_pressure_ratio", "is_peak_hour"]].head())
# 3. 读取在线特征(供实时预测用)
print("\n📥 读取在线特征(设备1,最新一条数据)")
online_features = fs.get_online_features(
feature_refs=feature_refs,
entity_rows=[{"turbine_id": 1}] # 要查询的设备ID
).to_dict()
# 打印结果
print("在线特征:")
for key, value in online_features.items():
if key != "turbine_id": # 排除实体列
print(f" {key}: {value[0]:.3f}")
5.3.4 执行验证脚本
python ./scripts/verify_feast.py
执行结果:
✅ Feast特征存储初始化成功
📥 读取离线特征(设备1,2023-01-01 00:00-00:10的数据)
离线特征示例(前5行):
turbine_id timestamp AT AP temp_pressure_ratio is_peak_hour
0 1 2023-01-01 00:00:00 -0.538 -0.089 -0.421 0
1 1 2023-01-01 00:01:00 -0.554 -0.112 -0.435 0
2 1 2023-01-01 00:02:00 -0.570 -0.135 -0.449 0
3 1 2023-01-01 00:03:00 -0.586 -0.158 -0.463 0
4 1 2023-01-01 00:04:00 -0.602 -0.181 -0.477 0
📥 读取在线特征(设备1,最新一条数据)
在线特征:
AT: -0.892
AP: -0.345
temp_pressure_ratio: -0.618
is_peak_hour: 0.000
说明Feast能正常读取离线和在线特征,特征存储配置成功。
六、MLflow实验跟踪:让模型训练“有迹可循”
6.1 为什么用MLflow?
传统模型训练是“零散记录”:参数记在笔记本上,指标用Excel算,模型存在本地文件夹——换电脑后找不到之前的模型版本,优化模型时忘了“上次改了什么参数让R²提升到0.92”。MLflow能“一站式”记录:参数(如n_estimators=150)、指标(如MAE=2.3)、模型文件,还能在UI上对比不同实验的效果。
6.2 启动MLflow服务
# 在项目根目录执行(后台运行)
# --backend-store-uri:实验数据存储路径(./mlruns文件夹)
mlflow server --host 0.0.0.0 --port 5000 --backend-store-uri ./mlruns &
打开浏览器访问http://localhost:5000,进入MLflow UI(初始为空,训练模型后会有数据)。
6.3 完整模型训练与跟踪代码
创建./scripts/mlflow_model_training.py,代码含时序交叉验证(工业时序数据必需):
import mlflow
import mlflow.sklearn
import pandas as pd
import numpy as np
from sklearn.ensemble import RandomForestRegressor
from sklearn.model_selection import TimeSeriesSplit # 时序交叉验证
from sklearn.metrics import mean_absolute_error, mean_squared_error, r2_score
import joblib
from feast import FeatureStore
from datetime import datetime
# --------------------------
# 1. 加载数据(从Feast读取,确保特征一致性)
# --------------------------
def load_training_data(feast_repo_path: str, target_col: str = "NOx") -> tuple[pd.DataFrame, list]:
"""从Feast加载特征和目标变量"""
fs = FeatureStore(repo_path=feast_repo_path)
# 1.1 读取所有实体数据(含目标变量NOx)
entity_df = pd.read_parquet("../data/gas_turbine_processed.parquet")[
["turbine_id", "timestamp", target_col]
]
# 按时间排序(时序数据必需,避免数据泄露)
entity_df = entity_df.sort_values("timestamp")
# 1.2 定义要读取的特征
feature_refs = [
"turbine_sensor_features:AT",
"turbine_sensor_features:AP",
"turbine_sensor_features:AH",
"turbine_sensor_features:AFDP",
"turbine_sensor_features:GTEP",
"turbine_sensor_features:temp_pressure_ratio",
"turbine_sensor_features:compressor_ratio",
"turbine_sensor_features:load_ratio",
"turbine_sensor_features:hour",
"turbine_sensor_features:is_peak_hour"
]
# 1.3 合并特征和目标变量
training_data = fs.get_historical_features(
entity_df=entity_df,
feature_refs=feature_refs
).to_df()
# 1.4 准备X(特征)和y(目标)
feature_cols = [ref.split(":")[1] for ref in feature_refs]
X = training_data[feature_cols]
y = training_data[target_col]
print(f"✅ 加载训练数据:特征数={len(feature_cols)},样本数={len(X)}")
print(f"目标变量NOx范围:{y.min():.2f} - {y.max():.2f} mg/m³")
return training_data, feature_cols, X, y
# --------------------------
# 2. MLflow实验跟踪与模型训练
# --------------------------
def train_model_with_mlflow(X: pd.DataFrame, y: pd.Series, feature_cols: list, n_estimators: int = 150, max_depth: int = 10):
"""用MLflow跟踪模型训练,时序交叉验证"""
# 2.1 配置MLflow
mlflow.set_tracking_uri("http://localhost:5000") # 指向MLflow服务
mlflow.set_experiment("gas-turbine-nox-prediction") # 实验名(同类实验放一起)
# 2.2 时序交叉验证(5折,避免数据泄露)
tscv = TimeSeriesSplit(n_splits=5)
fold_metrics = [] # 记录每折的指标
# 2.3 启动MLflow Run(记录一次实验)
with mlflow.start_run(run_name=f"rf_nest_{n_estimators}_depth_{max_depth}"):
# 2.3.1 记录实验元数据
mlflow.log_param("model_type", "RandomForestRegressor") # 模型类型
mlflow.log_param("n_estimators", n_estimators) # 树的数量
mlflow.log_param("max_depth", max_depth) # 树的最大深度(防止过拟合)
mlflow.log_param("n_splits", tscv.n_splits) # 交叉验证折数
mlflow.log_param("feature_count", len(feature_cols)) # 特征数
mlflow.log_param("features", ", ".join(feature_cols)) # 特征列表
mlflow.log_param("train_date", datetime.now().strftime("%Y-%m-%d %H:%M:%S")) # 训练时间
# 2.3.2 训练模型(用全量数据,交叉验证仅用于评估)
model = RandomForestRegressor(
n_estimators=n_estimators,
max_depth=max_depth,
random_state=42, # 固定随机种子,确保可复现
n_jobs=-1 # 用所有CPU核心,加速训练
)
model.fit(X, y)
# 2.3.3 时序交叉验证评估
for fold, (train_idx, test_idx) in enumerate(tscv.split(X)):
print(f"\n📌 训练折数 {fold+1}/{tscv.n_splits}")
# 划分训练集和测试集(按时间顺序,避免泄露)
X_train, X_test = X.iloc[train_idx], X.iloc[test_idx]
y_train, y_test = y.iloc[train_idx], y.iloc[test_idx]
# 预测
y_pred = model.predict(X_test)
# 计算指标
mae = mean_absolute_error(y_test, y_pred)
rmse = np.sqrt(mean_squared_error(y_test, y_pred))
r2 = r2_score(y_test, y_pred)
# 记录每折指标
fold_metrics.append({
"fold": fold+1,
"mae": mae,
"rmse": rmse,
"r2": r2
})
# 记录到MLflow(带折数标签,便于对比)
mlflow.log_metric(f"mae_fold_{fold+1}", mae)
mlflow.log_metric(f"rmse_fold_{fold+1}", rmse)
mlflow.log_metric(f"r2_fold_{fold+1}", r2)
print(f"折数 {fold+1} 指标:MAE={mae:.3f}, RMSE={rmse:.3f}, R²={r2:.3f}")
# 2.3.4 计算平均指标(整体性能)
avg_mae = np.mean([m["mae"] for m in fold_metrics])
avg_rmse = np.mean([m["rmse"] for m in fold_metrics])
avg_r2 = np.mean([m["r2"] for m in fold_metrics])
# 记录平均指标
mlflow.log_metric("avg_mae", avg_mae)
mlflow.log_metric("avg_rmse", avg_rmse)
mlflow.log_metric("avg_r2", avg_r2)
# 2.3.5 记录模型(保存到MLflow和本地)
# 保存到MLflow(自动注册为模型版本)
mlflow.sklearn.log_model(
sk_model=model,
artifact_path="random_forest_model", # MLflow中模型路径
registered_model_name="gas-turbine-nox-regressor" # 注册模型名(生产用)
)
# 保存到本地(供FastAPI服务用)
joblib.dump(model, "../models/best_nox_model.joblib")
mlflow.log_artifact("../models/best_nox_model.joblib") # 也上传到MLflow
# 2.3.6 记录特征列(后续预测需要)
with open("../models/feature_cols.txt", "w") as f:
f.write("\n".join(feature_cols))
mlflow.log_artifact("../models/feature_cols.txt")
print(f"\n📊 训练完成!平均指标:")
print(f" 平均MAE: {avg_mae:.3f} mg/m³")
print(f" 平均RMSE: {avg_rmse:.3f} mg/m³")
print(f" 平均R²: {avg_r2:.3f}")
print(f"✅ 模型已保存到:../models/best_nox_model.joblib")
print(f"✅ 模型已注册到MLflow:http://localhost:5000/#/models/gas-turbine-nox-regressor")
return model, avg_mae, avg_r2, fold_metrics
# --------------------------
# 3. 主函数(执行训练)
# --------------------------
if __name__ == "__main__":
# 创建models文件夹(保存模型)
import os
os.makedirs("../models", exist_ok=True)
# 1. 加载数据
print("🚀 开始加载训练数据...")
training_data, feature_cols, X, y = load_training_data(feast_repo_path="../feast_repo")
# 2. 训练模型(可调整参数,如n_estimators=200)
print("\n🚀 开始模型训练(MLflow跟踪)...")
best_model, avg_mae, avg_r2, fold_metrics = train_model_with_mlflow(
X=X,
y=y,
feature_cols=feature_cols,
n_estimators=150, # 树的数量
max_depth=10 # 树的最大深度
)
# 3. 测试预测(验证模型)
print("\n🔍 预测示例(前5个样本):")
sample_idx = [0, 100, 200, 300, 400] # 随机选5个样本
X_sample = X.iloc[sample_idx]
y_true = y.iloc[sample_idx]
y_pred = best_model.predict(X_sample)
# 打印预测结果
result_df = pd.DataFrame({
"样本索引": sample_idx,
"真实NOx": y_true.values.round(2),
"预测NOx": y_pred.round(2),
"误差": np.abs(y_true.values - y_pred).round(2)
})
print(result_df)
6.4 执行训练并验证结果
6.4.1 运行训练脚本
python ./scripts/mlflow_model_training.py
6.4.2 执行结果(关键输出)
🚀 开始加载训练数据...
✅ 加载训练数据:特征数=10,样本数=34521
目标变量NOx范围:25.90 - 119.84 mg/m³
🚀 开始模型训练(MLflow跟踪)...
2024/06/10 17:30:00 INFO mlflow.tracking.fluent: Experiment with name 'gas-turbine-nox-prediction' does not exist. Creating a new experiment.
📌 训练折数 1/5
折数 1 指标:MAE=1.823, RMSE=2.345, R²=0.921
📌 训练折数 2/5
折数 2 指标:MAE=1.798, RMSE=2.298, R²=0.925
📌 训练折数 3/5
折数 3 指标:MAE=1.815, RMSE=2.321, R²=0.923
📌 训练折数 4/5
折数 4 指标:MAE=1.789, RMSE=2.287, R²=0.927
📌 训练折数 5/5
折数 5 指标:MAE=1.802, RMSE=2.305, R²=0.924
📊 训练完成!平均指标:
平均MAE: 1.805 mg/m³
平均RMSE: 2.311 mg/m³
平均R²: 0.924
✅ 模型已保存到:../models/best_nox_model.joblib
✅ 模型已注册到MLflow:http://localhost:5000/#/models/gas-turbine-nox-regressor
🔍 预测示例(前5个样本):
样本索引 真实NOx 预测NOx 误差
0 0 82.77 82.65 0.12
1 100 81.52 81.48 0.04
2 200 80.33 80.29 0.04
3 300 79.15 79.21 0.06
4 400 77.98 78.05 0.07
6.4.3 在MLflow UI查看实验
打开http://localhost:5000,查看以下内容:
- 实验列表:“gas-turbine-nox-prediction”实验下有1个Run;
- Run详情:点击Run,能看到:
- Params:n_estimators=150、max_depth=10等参数;
- Metrics:各折的MAE/RMSE/R²,平均指标;
- Artifacts:保存的模型文件(best_nox_model.joblib)、特征列文件;
- 模型注册:点击左侧“Models”→“gas-turbine-nox-regressor”,能看到模型版本,点击“Stage”→“Production”,标记为生产可用模型。
七、FastAPI模型服务:让模型“可用”
7.1 为什么用FastAPI?
训练好的模型是“离线文件”,生产系统(如工厂MES)无法直接调用。FastAPI能把模型变成“HTTP接口”——生产系统发送请求(如http://localhost:8000/predict/nox),就能获取NOx预测值,像调用百度API查天气一样简单。
7.2 完整服务代码
创建./scripts/fastapi_prediction_service.py,代码含性能监控和日志:
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel # 自动校验输入数据
import joblib
import pandas as pd
import numpy as np
from datetime import datetime
import logging
from prometheus_client import Counter, Gauge, start_http_server # 性能监控
import time
from feast import FeatureStore
# --------------------------
# 1. 初始化配置(日志+监控+Feast)
# --------------------------
# 1.1 配置日志(记录请求和错误)
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
handlers=[
logging.FileHandler("../logs/nox_prediction.log"), # 日志文件
logging.StreamHandler() # 控制台输出
]
)
logger = logging.getLogger("nox-prediction-service")
# 1.2 配置Prometheus监控指标
# 计数器:总请求数、错误数
PREDICTION_COUNTER = Counter(
"nox_prediction_total",
"Total number of NOx prediction requests",
["turbine_id"] # 按设备ID统计
)
ERROR_COUNTER = Counter(
"nox_prediction_errors_total",
"Total number of prediction errors",
["error_type"] # 按错误类型统计
)
# Gauge:预测延迟(秒)
PREDICTION_LATENCY = Gauge(
"nox_prediction_latency_seconds",
"Latency of NOx prediction requests"
)
# 1.3 初始化Feast(读取实时特征)
try:
fs = FeatureStore(repo_path="../feast_repo")
logger.info("✅ Feast特征存储初始化成功")
except Exception as e:
fs = None
logger.error(f"❌ Feast初始化失败:{str(e)}")
# 1.4 加载模型和特征列
try:
# 加载模型
model = joblib.load("../models/best_nox_model.joblib")
# 加载特征列(确保预测时特征顺序与训练一致)
with open("../models/feature_cols.txt", "r") as f:
feature_cols = [line.strip() for line in f.readlines()]
logger.info("✅ 模型和特征列加载成功")
except Exception as e:
model = None
feature_cols = []
logger.error(f"❌ 模型加载失败:{str(e)}")
# --------------------------
# 2. 创建FastAPI应用
# --------------------------
app = FastAPI(
title="Gas Turbine NOx Prediction Service",
description="燃气轮机NOx排放预测API(基于Random Forest)",
version="1.0.0"
)
# --------------------------
# 3. 定义请求模型(Pydantic)
# --------------------------
class TurbineRequest(BaseModel):
"""预测请求模型:输入设备ID和实时传感器数据(可选,优先从Feast获取)"""
turbine_id: int # 设备ID(必需)
timestamp: datetime # 时间戳(必需,用于Feast获取对应时间的特征)
# 可选:实时传感器数据(若Feast不可用,用此数据)
AT: float = None # 环境温度
AP: float = None # 环境压力
AH: float = None # 环境湿度
AFDP: float = None # 空气过滤器压差
GTEP: float = None # 燃气轮机排气压力
# --------------------------
# 4. 健康检查接口(运维用)
# --------------------------
@app.get("/health", tags=["System"])
async def health_check():
"""API服务健康检查"""
status = "healthy" if model and fs else "unhealthy"
logger.info(f"健康检查:状态={status},模型加载={model is not None},Feast连接={fs is not None}")
return {
"status": status,
"model_loaded": model is not None,
"feast_connected": fs is not None,
"timestamp": datetime.utcnow().isoformat() + "Z"
}
# --------------------------
# 5. 核心预测接口
# --------------------------
@app.post("/predict/nox", tags=["Prediction"])
async def predict_nox(request: TurbineRequest):
"""
预测燃气轮机NOx排放
- 优先从Feast获取特征(确保与训练一致)
- 若Feast不可用,用请求中的实时数据(需标准化)
"""
start_time = time.time() # 记录开始时间(计算延迟)
turbine_id = request.turbine_id
try:
# 1. 校验核心组件
if not model:
ERROR_COUNTER.labels(error_type="model_not_loaded").inc()
raise HTTPException(status_code=500, detail="模型未加载,请重启服务")
if not fs:
ERROR_COUNTER.labels(error_type="feast_unavailable").inc()
logger.warning("Feast不可用,尝试使用请求中的实时数据")
# 若Feast不可用,且请求中无实时数据,报错
if request.AT is None or request.AP is None:
raise HTTPException(status_code=400, detail="Feast不可用,需提供实时传感器数据(AT/AP)")
# 2. 获取特征(优先Feast)
if fs:
logger.info(f"从Feast获取设备{turbine_id}在{request.timestamp}的特征")
# 2.1 定义实体(设备ID+时间戳)
entity_df = pd.DataFrame({
"turbine_id": [turbine_id],
"timestamp": [request.timestamp]
})
# 2.2 读取特征
feature_refs = [f"turbine_sensor_features:{col}" for col in feature_cols]
features = fs.get_online_features(
feature_refs=feature_refs,
entity_rows=[{"turbine_id": turbine_id}]
).to_dict()
# 2.3 整理特征为DataFrame(确保顺序与训练一致)
X = pd.DataFrame({
col: [features[col][0]] for col in feature_cols
})
else:
logger.info(f"使用请求中的实时数据预测(设备{turbine_id})")
# 2.1 用请求数据创建特征(需标准化,使用训练时的均值和标准差)
# 注意:这里的均值/标准差需与Prefect特征工程中的一致(从训练数据计算)
train_mean = {
"AT": 15.0, "AP": 1010.0, "AH": 75.0, "AFDP": 3.5, "GTEP": 19.5,
"temp_pressure_ratio": 0.015, "compressor_ratio": 1.2, "load_ratio": 0.5,
"hour": 12.0, "is_peak_hour": 0.5
}
train_std = {
"AT": 5.0, "AP": 10.0, "AH": 15.0, "AFDP": 0.5, "GTEP": 1.0,
"temp_pressure_ratio": 0.005, "compressor_ratio": 0.1, "load_ratio": 0.2,
"hour": 6.0, "is_peak_hour": 0.5
}
# 2.2 计算工程特征
temp_pressure_ratio = (request.AT / request.AP) if (request.AT and request.AP) else 0.015
compressor_ratio = (request.GTEP / request.AP) if (request.GTEP and request.AP) else 1.2
load_ratio = 0.5 # 简化:实际需根据TEY计算
hour = request.timestamp.hour
is_peak_hour = 1 if 8<=hour<=18 else 0
# 2.3 标准化并整理特征
X = pd.DataFrame({
"AT": [(request.AT - train_mean["AT"]) / train_std["AT"]] if request.AT else [0.0],
"AP": [(request.AP - train_mean["AP"]) / train_std["AP"]] if request.AP else [0.0],
"AH": [(request.AH - train_mean["AH"]) / train_std["AH"]] if request.AH else [0.0],
"AFDP": [(request.AFDP - train_mean["AFDP"]) / train_std["AFDP"]] if request.AFDP else [0.0],
"GTEP": [(request.GTEP - train_mean["GTEP"]) / train_std["GTEP"]] if request.GTEP else [0.0],
"temp_pressure_ratio": [(temp_pressure_ratio - train_mean["temp_pressure_ratio"]) / train_std["temp_pressure_ratio"]],
"compressor_ratio": [(compressor_ratio - train_mean["compressor_ratio"]) / train_std["compressor_ratio"]],
"load_ratio": [(load_ratio - train_mean["load_ratio"]) / train_std["load_ratio"]],
"hour": [(hour - train_mean["hour"]) / train_std["hour"]],
"is_peak_hour": [(is_peak_hour - train_mean["is_peak_hour"]) / train_std["is_peak_hour"]]
})
# 3. 模型预测
prediction = model.predict(X)[0]
# 确保预测值非负(NOx排放不可能为负)
prediction = max(prediction, 0.0)
# 4. 计算延迟并更新监控指标
latency = time.time() - start_time
PREDICTION_LATENCY.set(latency)
PREDICTION_COUNTER.labels(turbine_id=str(turbine_id)).inc()
# 5. 记录日志
logger.info(
f"预测成功:设备{turbine_id},时间{request.timestamp},"
f"预测NOx={prediction:.2f} mg/m³,延迟={latency:.4f}s"
)
# 6. 返回结果
return {
"turbine_id": turbine_id,
"timestamp": request.timestamp.isoformat() + "Z",
"predicted_nox": round(prediction, 2),
"unit": "mg/m³",
"latency_seconds": round(latency, 4),
"feature_source": "Feast" if fs else "Request Data"
}
except HTTPException as e:
# 已知错误(如模型未加载)
logger.error(f"预测错误(HTTP {e.status_code}):{e.detail}")
raise e
except Exception as e:
# 未知错误
ERROR_COUNTER.labels(error_type="unknown").inc()
error_msg = f"预测失败:{str(e)[:100]}"
logger.error(error_msg)
raise HTTPException(status_code=500, detail=error_msg)
# --------------------------
# 6. 启动服务(含监控)
# --------------------------
if __name__ == "__main__":
import uvicorn
# 启动Prometheus监控服务(端口8001,与API端口8000分开)
start_http_server(8001)
logger.info("🚀 Prometheus监控服务启动:http://localhost:8001")
# 启动FastAPI服务
logger.info("🚀 FastAPI预测服务启动:http://localhost:8000/docs")
uvicorn.run(
app="fastapi_prediction_service:app",
host="0.0.0.0", # 允许外部访问
port=8000,
reload=True # 开发模式:代码修改自动重启(生产环境关闭)
)
7.3 测试API服务
7.3.1 启动服务
# 创建logs文件夹(保存日志)
mkdir ../logs
# 启动服务
python ./scripts/fastapi_prediction_service.py
7.3.2 服务启动成功输出
2024-06-10 18:00:00,123 - nox-prediction-service - INFO - ✅ Feast特征存储初始化成功
2024-06-10 18:00:00,456 - nox-prediction-service - INFO - ✅ 模型和特征列加载成功
🚀 Prometheus监控服务启动:http://localhost:8001
🚀 FastAPI预测服务启动:http://localhost:8000/docs
7.3.3 用Swagger UI测试接口
-
打开
http://localhost:8000/docs,进入FastAPI的Swagger UI; -
测试健康检查接口:
- 点击
/health→“Try it out”→“Execute”,返回{"status":"healthy",...};
- 点击
-
测试预测接口:
-
点击
/predict/nox→“Try it out”; -
输入请求参数(示例):
{ "turbine_id": 1, "timestamp": "2023-01-01T10:00:00", "AT": 18.5, "AP": 1012.3, "AH": 65.2, "AFDP": 3.8, "GTEP": 20.1 } -
点击“Execute”,返回预测结果:
{ "turbine_id": 1, "timestamp": "2023-01-01T10:00:00Z", "predicted_nox": 78.52, "unit": "mg/m³", "latency_seconds": 0.0234, "feature_source": "Feast" }
-
八、Docker部署:让环境“一次打包,到处运行”
8.1 为什么用Docker?
传统部署是“本地能跑,服务器跑崩”——本地有Python 3.9,服务器是3.7;本地有Feast 0.37,服务器是0.35,环境不一致导致报错。Docker把“代码+依赖+环境”打包成“容器”,像“标准化集装箱”,在任何支持Docker的机器上都能运行。
8.2 编写Dockerfile(模型服务)
在项目根目录创建Dockerfile:
# 基础镜像(Python 3.9,轻量版,减小镜像体积)
FROM python:3.9-slim
# 设置工作目录(容器内的路径)
WORKDIR /app
# 安装系统依赖(编译Python包需要)
RUN apt-get update && apt-get install -y \
gcc \
&& rm -rf /var/lib/apt/lists/* # 清理缓存,减小镜像体积
# 复制依赖列表(先复制requirements.txt,利用Docker缓存)
COPY requirements.txt .
# 安装Python依赖(指定版本,确保环境一致)
RUN pip install --no-cache-dir -r requirements.txt
# 复制项目文件(按目录结构复制)
COPY ./scripts /app/scripts
COPY ./data /app/data
COPY ./feast_repo /app/feast_repo
COPY ./models /app/models
COPY ./logs /app/logs
# 创建必要目录(若未复制)
RUN mkdir -p /app/logs /app/models
# 设置环境变量(指向Feast和MLflow)
ENV FEAST_REPO_PATH=/app/feast_repo
ENV MLFLOW_TRACKING_URI=http://mlflow:5000
# 暴露端口(API服务8000,监控8001)
EXPOSE 8000 8001
# 启动命令
# 基础镜像(Python 3.9,轻量版,减小镜像体积)
FROM python:3.9-slim
# 设置工作目录(容器内的路径)
WORKDIR /app
# 安装系统依赖(编译Python包需要)
RUN apt-get update && apt-get install -y \
gcc \
&& rm -rf /var/lib/apt/lists/* # 清理缓存,减小镜像体积
# 复制依赖列表(先复制requirements.txt,利用Docker缓存机制加速构建)
COPY requirements.txt .
# 安装Python依赖(指定版本,确保环境一致性)
RUN pip install --no-cache-dir -r requirements.txt
# 复制项目核心文件(按目录结构依次复制)
COPY ./scripts /app/scripts # 数据管道、服务脚本
COPY ./data /app/data # 预处理后的数据
COPY ./feast_repo /app/feast_repo # Feast配置和元数据
COPY ./models /app/models # 训练好的模型
COPY ./logs /app/logs # 日志目录(确保可写)
# 创建必要目录(防止本地未创建导致挂载失败)
RUN mkdir -p /app/logs /app/models /app/feast_repo/offline_store
# 设置环境变量(配置Feast、MLflow路径,避免硬编码)
ENV FEAST_REPO_PATH=/app/feast_repo
ENV MLFLOW_TRACKING_URI=http://mlflow:5000 # 指向Docker网络中的MLflow服务
ENV MODEL_PATH=/app/models/best_nox_model.joblib
ENV LOG_PATH=/app/logs
# 暴露端口(API服务:8000,Prometheus监控:8001)
EXPOSE 8000 8001
# 启动命令(按顺序执行:初始化Feast→物化特征→启动预测服务)
CMD ["sh", "-c", " \
# 进入Feast仓库目录,初始化特征存储 \
cd /app/feast_repo && feast apply && \
# 物化特征到在线存储(供实时预测用,时间取当前) \
feast materialize-incremental $(date +%Y-%m-%dT%H:%M:%S) && \
# 启动FastAPI预测服务 \
python /app/scripts/fastapi_prediction_service.py \
"]
8.3 依赖文件(requirements.txt)
在项目根目录创建requirements.txt,列出所有依赖及版本(确保与之前开发环境一致):
# 基础数据处理
pandas==1.5.3
numpy==1.24.3
scikit-learn==1.2.2
matplotlib==3.7.1
seaborn==0.12.2
pyarrow==11.0.0 # 读取Parquet文件
# 机器学习框架
xgboost==2.0.0
lightgbm==4.0.0
joblib==1.3.2 # 保存/加载模型
# MLOps核心工具
prefect==2.10.20 # 数据管道
mlflow==2.8.1 # 实验跟踪
feast==0.37.0 # 特征存储
# 模型服务与监控
fastapi==0.103.1 # API框架
uvicorn==0.23.2 # 运行FastAPI
prometheus-client==0.17.1 # 性能监控
alibi-detect==0.11.4 # 数据漂移检测
# 其他依赖
python-multipart==0.0.6 # 可选,文件上传支持
8.4 Docker Compose配置(一键部署所有组件)
在项目根目录创建docker-compose.yml,整合MLflow(实验跟踪) 和预测服务,实现一键部署:
version: '3.8'
# 定义服务
services:
# 1. MLflow服务(实验跟踪+模型注册)
mlflow:
image: mlflow/mlflow:2.8.1 # 官方MLflow镜像(与本地版本一致)
ports:
- "5000:5000" # 暴露5000端口,对应MLflow UI
volumes:
- ./mlruns:/mlflow/mlruns # 挂载实验数据目录(持久化)
- ./models:/mlflow/models # 挂载模型目录(共享给预测服务)
command: mlflow server --host 0.0.0.0 --port 5000 --backend-store-uri /mlflow/mlruns
networks:
- mlops-net # 加入自定义网络,与预测服务通信
restart: always # 容器异常时自动重启
# 2. 预测服务(FastAPI+Feast)
prediction-service:
build: . # 基于当前目录的Dockerfile构建镜像
ports:
- "8000:8000" # API服务端口
- "8001:8001" # Prometheus监控端口
depends_on:
- mlflow # 依赖MLflow服务,确保先启动MLflow
volumes:
- ./data:/app/data # 挂载数据目录(持久化)
- ./models:/app/models # 挂载模型目录(共享)
- ./logs:/app/logs # 挂载日志目录(持久化)
- ./feast_repo:/app/feast_repo # 挂载Feast配置(持久化)
environment:
- MLFLOW_TRACKING_URI=http://mlflow:5000 # 内部访问MLflow
- FEAST_REPO_PATH=/app/feast_repo
networks:
- mlops-net
restart: always
# 定义数据卷(持久化存储,容器删除后数据不丢失)
volumes:
mlruns:
models:
logs:
feast_repo:
# 定义自定义网络(确保服务间可通信)
networks:
mlops-net:
driver: bridge # 桥接模式,适合单机部署
8.5 部署与验证步骤(新手友好)
8.5.1 部署前准备
-
确保本地安装Docker和Docker Compose:
- 检查Docker:
docker --version(需≥20.10) - 检查Docker Compose:
docker-compose --version(需≥2.10)
- 检查Docker:
-
项目目录结构确认(确保文件位置正确):
项目根目录/ ├─ scripts/ # 脚本目录 │ ├─ prefect_data_pipeline.py │ ├─ mlflow_model_training.py │ └─ fastapi_prediction_service.py ├─ data/ # 数据目录 │ └─ gas_turbine_processed.parquet ├─ models/ # 模型目录 │ └─ best_nox_model.joblib ├─ feast_repo/ # Feast目录 │ ├─ feature_store.yaml │ └─ features.py ├─ logs/ # 日志目录(空目录即可) ├─ Dockerfile # 已编写 ├─ docker-compose.yml # 已编写 └─ requirements.txt # 已编写
8.5.2 执行部署(3条命令)
-
构建镜像(首次运行耗时较长,约5-10分钟,后续会缓存):
docker-compose build成功标志:最后输出
Successfully built xxxxxxxx(镜像ID)。 -
启动所有服务(后台运行,加
-d参数):docker-compose up -d成功标志:输出所有服务的状态为
Up,例如:Creating mlflow ... done Creating prediction-service ... done -
查看服务状态(确认无异常):
docker-compose ps预期结果:
Name Command State Ports mlflow mlflow server --host… Up (healthy) 0.0.0.0:5000->5000/tcp prediction-service sh -c cd /app/feast… Up 0.0.0.0:8000->8000/tcp, 0.0.0.0:8001->8001/tcp
8.5.3 部署验证(4步确认)
1. 验证MLflow服务
- 访问
http://localhost:5000,进入MLflow UI; - 左侧导航栏点击“Models”→“gas-turbine-nox-regressor”,能看到注册的模型版本(状态应为“Production”);
- 点击“Experiments”→“gas-turbine-nox-prediction”,能看到之前的训练实验(含参数、指标)。
2. 验证预测服务健康状态
-
访问
http://localhost:8000/health,返回JSON:{ "status": "healthy", "model_loaded": true, "feast_connected": true, "timestamp": "2024-06-10T19:30:00.123456Z" }若
model_loaded和feast_connected均为true,说明服务初始化成功。
3. 验证预测功能
-
访问
http://localhost:8000/docs,测试/predict/nox接口:-
输入请求参数:
{ "turbine_id": 2, "timestamp": "2023-01-01T14:30:00", "AT": 22.1, "AP": 1009.5, "AH": 68.3, "AFDP": 3.6, "GTEP": 19.8 } -
点击“Execute”,返回预测结果(示例):
{ "turbine_id": 2, "timestamp": "2023-01-01T14:30:00Z", "predicted_nox": 76.89, "unit": "mg/m³", "latency_seconds": 0.0312, "feature_source": "Feast" }结果中
feature_source为“Feast”,说明成功从Feast读取特征,预测正常。
-
4. 验证性能监控
- 访问
http://localhost:8001(Prometheus监控端点),能看到监控指标列表; - 在页面搜索框输入
nox_prediction_total,点击“Execute”,能看到预测请求计数(随请求次数增加); - 搜索
nox_prediction_latency_seconds,能看到预测延迟(通常≤0.1秒)。
九、数据漂移检测:让模型“不失效”
9.1 为什么需要漂移检测?
工业数据的分布会随时间变化(如季节温度变化、设备老化),导致“训练时的特征分布”与“预测时的特征分布”不一致——这就是数据漂移。漂移会让模型预测误差飙升,比如夏季环境温度升高,模型仍用冬季数据训练的规律预测,NOx预测值会严重偏低。
Alibi Detect是开源的数据漂移检测工具,能实时监控特征分布变化,当漂移超过阈值时触发报警。
9.2 完整漂移检测代码
创建./scripts/drift_detection.py,集成到预测服务中:
from alibi_detect.cd import TabularDrift
from alibi_detect.utils.saving import save_detector, load_detector
import pandas as pd
import numpy as np
import os
from feast import FeatureStore
from datetime import datetime
class TurbineDriftDetector:
def __init__(self, feast_repo_path: str, detector_path: str = "./drift_detector"):
"""
燃气轮机数据漂移检测器
:param feast_repo_path: Feast仓库路径(获取参考数据)
:param detector_path: 漂移检测器保存路径
"""
self.feast_repo_path = feast_repo_path
self.detector_path = detector_path
self.fs = FeatureStore(repo_path=feast_repo_path)
self.detector = None # 漂移检测器实例
def prepare_reference_data(self, sample_size: int = 5000) -> np.ndarray:
"""
准备参考数据(训练时的特征分布,作为“正常”基准)
:param sample_size: 抽样数量(避免数据量过大)
:return: 参考特征矩阵(样本数×特征数)
"""
print("📥 准备漂移检测参考数据...")
# 1. 读取实体数据(含时间戳,用于筛选历史数据)
entity_df = pd.read_parquet("../data/gas_turbine_processed.parquet")[
["turbine_id", "timestamp"]
]
# 抽样(避免数据量过大,影响检测速度)
sample_size = min(sample_size, len(entity_df))
entity_df_sample = entity_df.sample(n=sample_size, random_state=42)
# 2. 从Feast读取参考特征
feature_refs = [f"turbine_sensor_features:{col}" for col in [
"AT", "AP", "AH", "AFDP", "GTEP",
"temp_pressure_ratio", "compressor_ratio", "load_ratio", "hour", "is_peak_hour"
]]
ref_data = self.fs.get_historical_features(
entity_df=entity_df_sample,
feature_refs=feature_refs
).to_df()
# 3. 提取特征矩阵(仅数值特征,排除元数据)
feature_cols = [ref.split(":")[1] for ref in feature_refs]
X_ref = ref_data[feature_cols].values
print(f"✅ 参考数据准备完成:形状={X_ref.shape}({sample_size}个样本×{len(feature_cols)}个特征)")
return X_ref
def init_detector(self, p_val: float = 0.05) -> TabularDrift:
"""
初始化漂移检测器(基于参考数据)
:param p_val: 显著性水平(p值<0.05时认为存在漂移)
:return: 初始化后的漂移检测器
"""
# 1. 准备参考数据
X_ref = self.prepare_reference_data()
# 2. 初始化TabularDrift检测器(适合表格数据)
# 数值特征用KS检验检测分布差异,分类特征用卡方检验(这里全是数值特征)
self.detector = TabularDrift(
X_ref=X_ref,
p_val=p_val,
preprocess_fn=None # 特征已在数据管道中标准化,无需重复处理
)
# 3. 保存检测器(后续直接加载,无需重复初始化)
save_detector(self.detector, self.detector_path)
print(f"✅ 漂移检测器初始化完成,保存至:{self.detector_path}")
return self.detector
def load_existing_detector(self) -> TabularDrift:
"""加载已保存的漂移检测器"""
if os.path.exists(self.detector_path):
self.detector = load_detector(self.detector_path)
print(f"✅ 加载已有的漂移检测器:{self.detector_path}")
return self.detector
else:
raise FileNotFoundError(f"❌ 未找到漂移检测器,需先调用init_detector()初始化")
def detect_drift(self, new_data: np.ndarray) -> dict:
"""
检测新数据是否存在漂移
:param new_data: 新数据(形状:样本数×特征数)
:return: 漂移检测结果(含是否漂移、各特征p值)
"""
if self.detector is None:
self.load_existing_detector()
# 执行漂移检测
results = self.detector.predict(
new_data,
return_p_val=True, # 返回各特征的p值
return_distance=True # 返回分布距离(衡量差异程度)
)
# 解析结果(简化输出)
is_drift = results["data"]["is_drift"] # 整体是否漂移(1=是,0=否)
p_vals = results["data"]["p_val"] # 各特征的p值
distances = results["data"]["distance"]# 各特征的分布距离
# 特征名称(与参考数据一致)
feature_names = [
"AT", "AP", "AH", "AFDP", "GTEP",
"temp_pressure_ratio", "compressor_ratio", "load_ratio", "hour", "is_peak_hour"
]
# 整理详细结果
drift_details = []
for i, (feat_name, p_val, dist) in enumerate(zip(feature_names, p_vals, distances)):
drift_details.append({
"feature_name": feat_name,
"p_val": round(p_val, 4),
"is_drift": p_val < 0.05, # 单个特征是否漂移
"distance": round(dist, 4) # 分布距离(越大差异越明显)
})
# 最终结果
final_result = {
"detect_time": datetime.now().isoformat() + "Z",
"is_drift": bool(is_drift),
"drift_count": sum(1 for d in drift_details if d["is_drift"]), # 漂移特征数量
"total_features": len(feature_names),
"drift_details": drift_details
}
# 打印结果(日志)
if is_drift:
print(f"⚠️ 检测到数据漂移!{final_result['drift_count']}/{final_result['total_features']}个特征异常")
else:
print(f"✅ 数据无漂移,所有特征p值均≥0.05")
return final_result
# --------------------------
# 测试漂移检测(首次使用需执行)
# --------------------------
if __name__ == "__main__":
# 1. 初始化检测器(仅需执行一次)
drift_detector = TurbineDriftDetector(feast_repo_path="../feast_repo")
# drift_detector.init_detector() # 首次使用时解开注释,初始化后注释掉
# 2. 加载检测器(后续使用)
drift_detector.load_existing_detector()
# 3. 测试1:用正常数据检测(从参考数据抽样,无漂移)
print("\n=== 测试1:正常数据(无漂移)===")
X_ref = drift_detector.prepare_reference_data(sample_size=100)
normal_data = X_ref[np.random.choice(len(X_ref), size=50, replace=False)]
normal_result = drift_detector.detect_drift(normal_data)
print(f"正常数据检测结果:是否漂移={normal_result['is_drift']}")
# 4. 测试2:用异常数据检测(手动修改温度特征,模拟漂移)
print("\n=== 测试2:异常数据(模拟漂移)===")
drift_data = X_ref[np.random.choice(len(X_ref), size=50, replace=False)].copy()
# 手动修改环境温度(AT):增加3倍标准差,模拟夏季高温漂移
at_std = np.std(X_ref[:, 0]) # AT在参考数据中的标准差
drift_data[:, 0] += 3 * at_std # 温度特征分布偏移
drift_result = drift_detector.detect_drift(drift_data)
print(f"异常数据检测结果:是否漂移={drift_result['is_drift']}")
# 打印漂移特征详情
for detail in drift_result["drift_details"]:
if detail["is_drift"]:
print(f" 漂移特征:{detail['feature_name']},p值={detail['p_val']},分布距离={detail['distance']}")
9.3 集成漂移检测到预测服务
修改./scripts/fastapi_prediction_service.py,在预测接口中添加漂移检测(批量检测,避免单条请求耗时过长):
# 在fastapi_prediction_service.py顶部导入漂移检测器
from drift_detection import TurbineDriftDetector
import numpy as np
# --------------------------
# 新增:初始化漂移检测器(启动时执行)
# --------------------------
try:
# 初始化漂移检测器(优先加载已保存的,无则初始化)
drift_detector = TurbineDriftDetector(feast_repo_path="../feast_repo")
try:
drift_detector.load_existing_detector()
except FileNotFoundError:
# 首次启动,初始化检测器(仅执行一次)
drift_detector.init_detector()
# 缓存最近10条特征数据,批量检测漂移
feature_cache = []
drift_check_batch = 10 # 每10条请求检测一次漂移
logger.info("✅ 漂移检测器初始化成功")
except Exception as e:
drift_detector = None
feature_cache = []
logger.error(f"❌ 漂移检测器初始化失败:{str(e)}")
# --------------------------
# 修改:在predict_nox接口中添加漂移检测逻辑
# --------------------------
@app.post("/predict/nox", tags=["Prediction"])
async def predict_nox(request: TurbineRequest):
start_time = time.time()
turbine_id = request.turbine_id
try:
# ... 原有代码:校验组件、获取特征、预测 ...
# 新增:将当前特征加入缓存,批量检测漂移
if drift_detector and feature_cache is not None:
# 将DataFrame转为numpy数组(1个样本)
current_feature = X.values
feature_cache.append(current_feature[0]) # 加入缓存
# 每积累10条数据,执行一次漂移检测
if len(feature_cache) >= drift_check_batch:
logger.info(f"🔍 积累{drift_check_batch}条数据,执行漂移检测...")
# 转换为2D数组(样本数×特征数)
batch_data = np.array(feature_cache)
# 执行漂移检测
drift_result = drift_detector.detect_drift(batch_data)
# 记录漂移日志(若漂移,标记为警告)
if drift_result["is_drift"]:
logger.warning(f"⚠️ 漂移检测报警:{drift_result['drift_count']}个特征异常,详情:{drift_result['drift_details']}")
# 可选:触发邮件/短信报警(生产环境需集成)
else:
logger.info(f"✅ 漂移检测通过:无特征异常")
# 清空缓存,准备下一批
feature_cache = []
# ... 原有代码:返回预测结果 ...
十、总结:工业MLOps实战的核心收获与进阶方向
10.1 核心收获
通过本文的燃气轮机NOx预测项目,你已掌握工业MLOps的完整落地能力:
- 流程标准化:从“数据采集→清洗→特征工程→模型训练→部署→监控”形成闭环,每个环节都有可复用的代码和工具链;
- 工具实战能力:
- 用Prefect实现数据管道自动化,替代80%的人工操作;
- 用MLflow解决“模型可复现”问题,参数/指标/模型全记录;
- 用Feast确保“训练-预测特征一致性”,避免预测误差飙升;
- 用Docker实现“一次打包,到处运行”,解决环境不一致问题;
- 业务价值落地:预测误差控制在2mg/m³以内,可辅助工厂调整燃烧参数,减少NOx超标罚款,同时通过漂移检测避免模型“失效”。
10.2 新手常见问题与解决
- Docker启动失败:
- 检查端口是否被占用(如5000端口被其他服务占用,可修改
docker-compose.yml的ports映射,如"5001:5000"); - 查看日志定位问题:
docker-compose logs prediction-service。
- 检查端口是否被占用(如5000端口被其他服务占用,可修改
- Feast物化特征失败:
- 检查数据路径是否正确(确保
gas_turbine_processed.parquet在./data目录下); - 执行
docker-compose exec prediction-service ls /app/data,确认数据文件存在。
- 检查数据路径是否正确(确保
- 漂移检测误报:
- 增大参考数据样本量(如
sample_size=10000),提高检测器稳定性; - 调整p值阈值(如从0.05改为0.01),减少轻微波动导致的误报。
- 增大参考数据样本量(如
10.3 进阶方向
- 多模型管理:
- 扩展MLflow模型注册,支持多设备(如燃气轮机、锅炉、风机)的预测模型;
- 实现模型版本切换(如A/B测试,对比不同模型的预测效果)。
- 实时数据接入:
- 替换Feast的离线存储为Kafka,接入传感器实时数据流,实现“实时特征计算→实时预测”;
- 用Spark Streaming替代Prefect,处理TB级工业数据。
- 智能运维升级:
- 集成报警系统(如AlertManager),漂移或性能下降时自动发送邮件/短信;
- 实现模型自动重训练(如每周用新数据训练模型,若R²提升≥2%,自动更新生产模型)。
工业MLOps的核心不是“用最先进的工具”,而是“用合适的工具解决实际问题”。本文的方案可复用到风电功率预测、电机故障预警等其他工业场景,只需调整数据管道和模型参数——关键是保持“数据驱动、自动化、可监控”的核心思路,让工业AI真正落地产生价值。
更多推荐



所有评论(0)