项目背景与目标

左图衡量风险排序,右图展示阈值 0.4 时的误报和漏报数量。两张图分别回答排序与决策问题。
批处理平台希望在任务开始前识别超时风险,以便调整队列或分配资源。可用输入包括文件数量、输入体积、队列长度、节点负载和任务类型;任务结束后的实际耗时、重试次数和状态不能作为预测特征。
输入是完全自建的任务样本 CSV,输出是超时概率、决策结果、评估报告和可独立加载的模型流水线。结果应满足:固定随机种子可重建数据;预处理无泄漏;模型超过多数类基线;阈值在验证集选择;测试集只使用一次。
项目用于学习完整流程,合成规律不代表真实生产负载。
整体架构
自建任务数据
│
├── 训练集 ──► 预处理 Pipeline ──► 模型训练/交叉验证
├── 验证集 ──► 模型比较与阈值选择
└── 测试集 ──► 一次最终评估
│
▼
pipeline.joblib + metadata.json
│
▼
独立推理脚本
目录:
timeout-risk/
├── data/
├── model_output/
├── prepare_data.py
├── train.py
├── predict.py
└── requirements.txt
核心流程
- 生成 1200 条带随机噪声的混合类型数据。
- 检查标签比例、缺失值和特征在预测时是否可用。
- 分层划分训练、验证和测试集。
- 在 Pipeline 中完成填充、标准化和 One-Hot 编码。
- 比较多数类基线、逻辑回归和随机森林。
- 按验证集 F1 选择决策阈值,在测试集报告最终指标。
- 保存模型、阈值、版本和特征契约,使用新进程推理。
数据字典与泄漏审查
| 字段 | 含义 | 预测时可用 | 处理方式 |
|---|---|---|---|
file_count | 待处理文件数 | 是 | 数值填充、标准化 |
input_mb | 输入总体积 | 是 | 数值填充、标准化 |
queue_depth | 提交时队列长度 | 是 | 数值填充、标准化 |
worker_load | 分配前节点负载 | 是 | 数值填充、标准化 |
task_type | 任务类别 | 是 | One-Hot 编码 |
timeout | 是否超过时限 | 否,目标 | 二分类标签 |
实际耗时、重试次数、最终状态和人工处置结果都在任务执行后产生,不能加入预测输入。节点负载必须使用“预测时点快照”,若误用任务结束时负载,同样属于未来信息。
合成标签来自隐藏风险公式和随机抽样,因此模型能够学习统计关系,但不会达到完美分类。随机噪声模拟相同输入下仍可能因未观测因素产生不同结果。
建模与评估口径
多数类模型始终预测不超时,在正类 F1 上为 0,但准确率可能接近 69%。这说明基线指标必须与目标一致。模型比较使用 Average Precision,因为正类约占三成且关心风险排序;最终决策再用验证集 F1 选择阈值。
交叉验证只作用于训练集。验证集用于候选模型和阈值,测试集在所有选择结束后使用一次。当前流程没有把训练集与验证集合并重训,因为重训后概率分布可能改变,原阈值需要重新校准;更正式的流程可以使用嵌套验证或单独校准集。
关键实现
pandas
numpy
scikit-learn
joblib
生成自建数据:
# prepare_data.py
from pathlib import Path
import numpy as np
import pandas as pd
rng = np.random.default_rng(42)
count = 1200
task_types = np.array(["text", "table", "archive", "image"])
data = pd.DataFrame({
"file_count": rng.integers(1, 100, count),
"input_mb": rng.gamma(shape=2.2, scale=22.0, size=count),
"queue_depth": rng.integers(0, 20, count),
"worker_load": rng.uniform(0.15, 1.0, count),
"task_type": rng.choice(task_types, count, p=[0.42, 0.25, 0.18, 0.15]),
})
type_risk = data["task_type"].map(
{"text": -0.5, "table": 0.0, "archive": 0.5, "image": 0.8}
)
logit = (
-5.4 + 0.018 * data["file_count"] + 0.025 * data["input_mb"]
+ 0.13 * data["queue_depth"] + 2.0 * data["worker_load"] + type_risk
)
probability = 1 / (1 + np.exp(-logit))
data["timeout"] = rng.binomial(1, probability)
# 注入少量缺失,验证流水线处理能力
data.loc[rng.choice(data.index, 20, replace=False), "input_mb"] = np.nan
output = Path("data/batch_timeout.csv")
output.parent.mkdir(parents=True, exist_ok=True)
data.to_csv(output, index=False)
print(data.shape)
print(data["timeout"].value_counts(normalize=True).sort_index())
训练、比较、选择阈值并保存:
# train.py
import json
from pathlib import Path
import joblib
import numpy as np
import pandas as pd
import sklearn
from sklearn.compose import ColumnTransformer
from sklearn.dummy import DummyClassifier
from sklearn.ensemble import RandomForestClassifier
from sklearn.impute import SimpleImputer
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import (
average_precision_score, classification_report, f1_score,
)
from sklearn.model_selection import cross_val_score, train_test_split
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import OneHotEncoder, StandardScaler
NUMERIC = ["file_count", "input_mb", "queue_depth", "worker_load"]
CATEGORICAL = ["task_type"]
FEATURES = NUMERIC + CATEGORICAL
TARGET = "timeout"
def make_preprocess() -> ColumnTransformer:
return ColumnTransformer([
("number", Pipeline([
("impute", SimpleImputer(strategy="median")),
("scale", StandardScaler()),
]), NUMERIC),
("category", OneHotEncoder(handle_unknown="ignore"), CATEGORICAL),
])
def make_pipeline(estimator) -> Pipeline:
return Pipeline([("preprocess", make_preprocess()), ("model", estimator)])
data = pd.read_csv("data/batch_timeout.csv")
if set(FEATURES + [TARGET]) - set(data.columns):
raise ValueError("训练数据缺少必需字段")
X, y = data[FEATURES], data[TARGET]
X_train, X_temp, y_train, y_temp = train_test_split(
X, y, test_size=0.4, stratify=y, random_state=42,
)
X_valid, X_test, y_valid, y_test = train_test_split(
X_temp, y_temp, test_size=0.5, stratify=y_temp, random_state=42,
)
baseline = make_pipeline(DummyClassifier(strategy="most_frequent"))
baseline.fit(X_train, y_train)
print("baseline F1:", f1_score(y_valid, baseline.predict(X_valid)))
candidates = {
"logistic": make_pipeline(LogisticRegression(max_iter=1000, random_state=42)),
"forest": make_pipeline(RandomForestClassifier(
n_estimators=180, max_depth=8, min_samples_leaf=4,
random_state=42, n_jobs=-1,
)),
}
best_name = ""
best_model = None
best_ap = -1.0
for name, model in candidates.items():
cv_scores = cross_val_score(model, X_train, y_train, cv=5, scoring="average_precision")
model.fit(X_train, y_train)
valid_probability = model.predict_proba(X_valid)[:, 1]
valid_ap = average_precision_score(y_valid, valid_probability)
print(name, "cv AP", round(cv_scores.mean(), 3), "valid AP", round(valid_ap, 3))
if valid_ap > best_ap:
best_name, best_model, best_ap = name, model, valid_ap
valid_probability = best_model.predict_proba(X_valid)[:, 1]
thresholds = np.arange(0.20, 0.81, 0.05)
best_threshold = max(
thresholds,
key=lambda value: f1_score(y_valid, valid_probability >= value),
)
test_probability = best_model.predict_proba(X_test)[:, 1]
test_prediction = (test_probability >= best_threshold).astype(int)
print("selected:", best_name, "threshold:", round(float(best_threshold), 2))
print("test AP:", round(average_precision_score(y_test, test_probability), 3))
print(classification_report(y_test, test_prediction, digits=3))
errors = X_test.loc[test_prediction != y_test.to_numpy()].copy()
errors["actual"] = y_test.loc[errors.index]
errors["probability"] = test_probability[test_prediction != y_test.to_numpy()]
print("error samples:\n", errors.head().to_string(index=False))
output = Path("model_output")
output.mkdir(exist_ok=True)
joblib.dump({"model": best_model, "threshold": float(best_threshold)}, output / "pipeline.joblib")
(output / "metadata.json").write_text(json.dumps({
"model": best_name,
"features": FEATURES,
"target": TARGET,
"sklearn_version": sklearn.__version__,
"selection_metric": "validation average_precision",
}, ensure_ascii=False, indent=2), encoding="utf-8")
可视化模型结果
评估数字之外,保存标签分布、PR 曲线和混淆矩阵:
from pathlib import Path
import matplotlib.pyplot as plt
from sklearn.metrics import ConfusionMatrixDisplay, PrecisionRecallDisplay
figure_dir = Path("model_output/figures")
figure_dir.mkdir(parents=True, exist_ok=True)
figure, axis = plt.subplots(figsize=(6, 4))
PrecisionRecallDisplay.from_predictions(y_test, test_probability, ax=axis)
axis.set_title("Test precision-recall curve")
figure.tight_layout()
figure.savefig(figure_dir / "precision_recall.png", dpi=150)
plt.close(figure)
figure, axis = plt.subplots(figsize=(5, 4))
ConfusionMatrixDisplay.from_predictions(y_test, test_prediction, ax=axis)
axis.set_title(f"Test confusion matrix, threshold={best_threshold:.2f}")
figure.tight_layout()
figure.savefig(figure_dir / "confusion_matrix.png", dpi=150)
plt.close(figure)
PR 曲线检查不同工作点的取舍,混淆矩阵给出具体误报和漏报数量。它们必须来自最终测试预测,不能反过来用于重新选择阈值。
错误样本怎样分类
将错误拆成假正和假负,再按任务类型、输入规模和负载区间聚合。假负集中在某种任务,可能缺少交互特征或训练样本;假正集中在极端体积,可能需要补充节点能力字段。错误分析的产出应是新的数据或假设,而不是只打印五行记录。
独立推理只接收训练时可用的字段:
# predict.py
import joblib
import pandas as pd
artifact = joblib.load("model_output/pipeline.joblib")
sample = pd.DataFrame([{
"file_count": 55,
"input_mb": 72.0,
"queue_depth": 12,
"worker_load": 0.86,
"task_type": "archive",
}])
probability = float(artifact["model"].predict_proba(sample)[0, 1])
prediction = int(probability >= artifact["threshold"])
print({"timeout_probability": round(probability, 4), "timeout": prediction})
运行与结果检查
python3 -m venv .venv
source .venv/bin/activate
python -m pip install -r requirements.txt
python prepare_data.py
python train.py
python predict.py
验证时确认:数据为 1200 行且标签两类都存在;验证 AP 高于随机水平;最终模型超过多数类 F1 基线;测试报告只输出一次;model_output 包含模型和元数据;推理脚本不需要重新拟合预处理。
预期输出不要求每个平台小数完全一致,但固定依赖版本时应接近:正类约 31%,测试正类 F1 约 0.59,并能够为示例高负载任务输出较高超时概率。
为数据和推理补测试
import joblib
import pandas as pd
def test_generated_data_contract() -> None:
data = pd.read_csv("data/batch_timeout.csv")
assert len(data) == 1200
assert set(data["timeout"].unique()) == {0, 1}
assert (data[["file_count", "queue_depth", "worker_load"]] >= 0).all().all()
def test_saved_pipeline_accepts_raw_fields() -> None:
artifact = joblib.load("model_output/pipeline.joblib")
sample = pd.DataFrame([{
"file_count": 10,
"input_mb": 20.0,
"queue_depth": 2,
"worker_load": 0.3,
"task_type": "text",
}])
probability = artifact["model"].predict_proba(sample)[0, 1]
assert 0.0 <= probability <= 1.0
assert 0.0 < artifact["threshold"] < 1.0
测试文件只能加载本项目刚训练的可信产物。真实接口还要测试缺列、错误类型、未知类别、超出范围和批量输入,并给出稳定错误响应。
遇到的问题
- 标签比例变化:生成参数或真实业务分布改变后,阈值和指标也要重新评估。
- 随机森林验证分数高但概率不稳定:检查校准曲线,不把树模型概率直接当可靠风险值。
- 错误样本集中在大输入任务:训练范围可能不足,应补数据而不是只调深度。
- 保存后无法加载:核对可信来源、Python 与 scikit-learn 版本,不跨不兼容环境硬加载。
- 特征在真实预测时不可获得:删除该字段并重新训练,不能用事后数据填补。
结果、评估与阶段复盘
项目形成了从任务定义到独立推理的闭环:基线证明模型是否增加价值,Pipeline 防止预处理泄漏,验证集承担模型与阈值选择,测试集给出最终估计,错误样本揭示下一轮数据需求。
合成数据往往比真实系统整洁,且标签规律由生成公式决定,因此测试分数不能作为生产效果承诺。真正进入应用前还需要时间外验证、概率校准、数据漂移监控和人工处置流程。
改进方向
- 按时间划分历史与未来数据,验证跨时间泛化。
- 增加任务来源和节点能力,但排除预测后才产生的字段。
- 根据漏报与误报成本而不是单纯 F1 选择阈值。
- 增加模型校准、数据漂移和分群指标。
- 用受控接口加载模型,禁止接收不可信持久化文件。
- 将数据、代码、依赖锁定文件、模型哈希和评估报告共同归档,确保实验可重建。
License: CC BY-NC 4.0
Updated 2 hours ago
Was this article helpful? Give it a like.
0 comments


