目录
前言
碎碎念:遇事不决先问春风,春风不语。
============================================================================
第二问要解决的"问题"是什么?
-----------------------------------------------------------------------------
第一问把每段振动信号提成了一条 20 维的结构化特征向量(bearing_features.csv)。
第二问的任务,本质上就是:用这些特征训练一个"源域监督分类器",并给出可复现的
训练-验证-对比-可视化流程,回答"这些手工特征是否足以区分四种状态(Normal/IR/OR/B)"。
注意:第二问不涉及目标域 A-P.mat 的预测,它是一个"在源域上做诊断建模"的参考组,
其价值在于:
- 作为后续迁移学习(第三问 DANN)的对照组:不迁移时在源域能做到什么水平?
- 检查第一问特征质量:若源域上也分不开,后续迁移更难成立。
=============================================================================
第二问的价值
它的价值并不在于"最终一定要选出某个最强模型",而在于把两个关键问题厘清楚:其一,这20维手工特征是否真的"有区分度",能否在源域上把四类状态(Normal/Inner/Outer/Ball)分开;其二,如果在源域上能分开,传统模型的上限大致在哪里,为第三问的迁移学习提供一个可以对照的基准。
输入
整个第二问的输入是第一问输出的 `bearing_features.csv`。这张表的列结构在代码中被显式利用:`Filename`是样本来源路径,`Label`是监督学习标签,`RPM`是转速元信息,其余列都是特征(例如 `RMS`、`Kurtosis`、`BPFO_Amplitude`、`WPT_E1`等)。
数据预处理
解决样本泄露
第二问的数据预处理做得很"标准",但每一步都有它必须存在的原因。代码首先读取CSV并打印标签分布,为了在建模前确认类别是否严重不均衡;如果某一类样本过少,那么简单的准确率会被大类"抬高",而模型对小类的召回会很差。紧接着,代码用列表推导`['Filename','Label','RPM']`排除掉得到特征列,这里隐含了一个重要判断:把 `Filename`喂给模型会直接引入"样本ID泄漏"(模型可能学到目录名规律而不是故障机理);而 `RPM`虽然看似有用,但在本项目里RPM有一部分是"从文件名解析/默认值补全"的工程性字段,它可能带来两种风险------一是数据质量不稳定(某些样本RPM并不可靠),二是模型可能过度依赖转速差异而忽略真正的故障模式。本文在第二问阶段选择不把RPM当特征,是一种偏保守但更利于可解释性的做法:先证明"只靠信号特征也能分类",再在后续扩展实验里讨论"加上RPM是否提升/是否引入偏差"会更清晰。
数据集划分
完成特征列选择后,代码将文本标签用 `LabelEncoder`编码为0..K-1的整数,这是传统sklearn训练器普遍要求的格式。更关键的一步是训练测试划分:`train_test_split(..., stratify=y_encoded)`。这一步相当于在划分时强制保持各类比例一致,避免出现某一类在测试集中几乎没有样本的情况。对于故障诊断这种多分类问题,分层抽样能显著降低评估方差,使得后续的分类报告(precision/recall/F1)更可信。这里还固定了 `random_state=42`,这保证了复现性:同一份数据、同一份代码,不会因为随机划分不同而得到完全不同的结果;在竞赛与论文场景里,复现性本身就是"可信度"的一部分。
标准化
接下来是标准化(`StandardScaler`)这一步。第二问选择的模型中,KNN和SVM本质上都依赖"距离/间隔"概念:KNN用欧氏距离或其他距离度量找最近邻,SVM(特别是RBF核)对特征尺度很敏感。如果不标准化,像 `WPT_E*`这种能量特征可能数值范围极大,而 `Skewness`、`Kurtosis`这种统计量范围相对小,距离计算会被大尺度特征完全主导,模型会在一种"虚假的几何空间"里做分类,最终表现不稳定。代码里标准化的方式也遵循了避免信息泄漏的原则:只在训练集上 `fit`,再对训练集和测试集分别 `transform`。这点在写复盘时值得强调:如果在全数据上fit scaler,再切分训练测试,会让测试集的信息(均值方差)提前进入训练流程,从而高估模型泛化能力。
python
# ---------------------------- 1) 数据加载与预处理 ----------------------------
def load_and_prepare_data(self):
"""
加载 bearing_features.csv,并完成:
- 选择特征列(排除 Filename/Label/RPM)
- 标签编码
- stratify 分层划分训练集/测试集
- 用训练集拟合 StandardScaler,再变换 train/test
"""
print("=" * 60)
print("第一步: 数据加载与预处理")
print("=" * 60)
try:
self.data = pd.read_csv(self.csv_file_path)
print(f"✓ 成功加载数据文件: {self.csv_file_path}")
print(f"✓ 数据形状: {self.data.shape}")
print(f"✓ 标签分布:\n{self.data['Label'].value_counts()}")
except FileNotFoundError:
print(f"错误: 找不到文件 {self.csv_file_path}")
return False
except Exception as e:
print(f"数据加载错误: {e}")
return False
# 特征列:把文件名、标签、转速这些"元数据"排除掉
# - Filename:只是索引,不该喂给模型
# - Label:监督学习的 y
# - RPM:在第一问里是从文件/文件名取的转速;这里作者选择不使用它做特征
# (也可以把 RPM 加进去做一个对照实验:看是否提升/是否引入泄漏)
self.feature_columns = [
col for col in self.data.columns if col not in ["Filename", "Label", "RPM"]
]
X = self.data[self.feature_columns]
y = self.data["Label"]
# 标签编码:例如 Normal->2,Inner_Ring_Fault->1(顺序由 LabelEncoder 规则决定)
y_encoded = self.label_encoder.fit_transform(y)
self.label_names = self.label_encoder.classes_
# 分层划分:保证 train/test 中各类比例基本一致
# 对竞赛/论文来说,这一步非常关键:否则小类可能在测试集中样本过少,评估方差会很大
self.X_train, self.X_test, self.y_train, self.y_test = train_test_split(
X, y_encoded, test_size=0.2, random_state=42, stratify=y_encoded
)
# 标准化:只用训练集拟合参数,避免信息泄漏;再用同样参数变换测试集
self.X_train_scaled = self.scaler.fit_transform(self.X_train)
self.X_test_scaled = self.scaler.transform(self.X_test)
print("✓ 数据预处理阶段完成!\n")
return True
模型的选择和模型背后的意义
模型选择是第二问的核心设计之一。代码定义了四个典型模型:逻辑回归(LR)、K近邻(KNN)、随机森林(RF)和支持向量机(SVM),它们分别代表了四种不同的归纳偏置与复杂度控制方式。逻辑回归是线性模型,若它都能取得不错效果,说明特征空间中四类可能具有较强线性可分性;KNN是基于局部邻域的非参数方法,它更依赖样本在特征空间中的"聚类结构",如果KNN表现好往往意味着同类样本在特征空间里确实形成紧密团簇;随机森林通过集成多棵决策树捕捉非线性特征交互,同时对异常点相对稳健,是一个很好的"强基线";SVM则在小样本、非线性边界场景下常常表现稳定,尤其当特征维度不高(本项目20维)时,RBF核可以提供足够的表达能力。重要的是,第二问并不预设"哪个模型最好",而是用同一套训练与调参流程公平比较,从而把讨论建立在可验证的实验结果上,而不是经验判断。
python
# ---------------------------- 2) 模型定义与超参 ----------------------------
def define_models_and_parameters(self):
"""
定义要对比的传统机器学习模型,以及各自的超参数搜索范围。
这里选择的四个模型代表了四种"归纳偏置":
- LR:线性可分/线性边界(配合正则化)
- KNN:基于距离的局部邻域投票
- RF:非线性树模型,能处理复杂交互,且对尺度相对不敏感
- SVM:最大间隔分类(线性核/高斯核)
通过 GridSearchCV 做一个小规模网格搜索,让对比更公平。
"""
print("=" * 60)
print("第二步: 模型定义与超参数设置")
print("=" * 60)
self.models = {
"Logistic Regression": {
"model": LogisticRegression(random_state=42, max_iter=1000),
"params": {"C": [0.1, 1, 10]},
},
"K-Nearest Neighbors": {
"model": KNeighborsClassifier(),
"params": {"n_neighbors": [3, 5, 7]},
},
"Random Forest": {
"model": RandomForestClassifier(random_state=42),
"params": {"n_estimators": [50, 100], "max_depth": [None, 10]},
},
"Support Vector Machine": {
"model": SVC(random_state=42),
"params": {"C": [0.1, 1, 10], "kernel": ["rbf", "linear"]},
},
}
print("✓ 已定义以下模型:")
for name in self.models.keys():
print(f" - {name}")
print("✓ 模型定义完成!\n")
# ---------------------------- 3) 训练与调参 ----------------------------
def train_and_optimize_models(self):
"""
对每个模型进行 GridSearchCV 超参搜索,指标使用 f1_macro。
选 f1_macro 的原因:
- 数据集中各类别样本数可能不均衡
- accuracy 容易被大类"撑高",而宏平均 F1 对每一类同等对待
"""
print("=" * 60)
print("第三步: 模型训练与超参数优化")
print("=" * 60)
for name, config in self.models.items():
print(f"\n正在训练 {name}...")
# n_jobs=1:作者刻意避免 Windows 下中文路径/并行引发的潜在编码问题
grid_search = GridSearchCV(
estimator=config["model"],
param_grid=config["params"],
cv=3, # 这里用 3 折,速度更快;也可以换成 5 折更稳
scoring="f1_macro",
n_jobs=1,
verbose=0,
)
grid_search.fit(self.X_train_scaled, self.y_train)
self.best_models[name] = grid_search.best_estimator_
print(f"✓ {name} 训练完成")
print(f" 最佳参数: {grid_search.best_params_}")
print(f" 交叉验证最佳得分: {grid_search.best_score_:.4f}")
print("\n✓ 所有模型训练完成!\n")
# ---------------------------- 4) 测试集评估 ----------------------------
def evaluate_models(self):
"""
在测试集上评估每个"最佳模型",并把结果写入 self.results。
"""
print("=" * 60)
print("第四步: 模型性能评估")
print("=" * 60)
for name, model in self.best_models.items():
# 测试集预测
y_pred = model.predict(self.X_test_scaled)
# 指标
accuracy = accuracy_score(self.y_test, y_pred)
f1_macro = f1_score(self.y_test, y_pred, average="macro")
self.results[name] = {"accuracy": accuracy, "f1_macro": f1_macro, "y_pred": y_pred}
print(f"\n评估 {name}:")
# classification_report 会输出每一类的 precision/recall/F1 与宏/微/加权汇总
print(
classification_report(
self.y_test, y_pred, target_names=self.label_names, digits=4
)
)
print("✓ 模型评估完成!\n")
# ---------------------------- 5) 汇总与"最佳模型"选择 ----------------------------
def create_performance_summary(self):
"""
把各模型的 accuracy / f1_macro 汇总成表,并用 accuracy 选一个"最佳模型"。
说明:
- 这里用 accuracy 选最优,而 GridSearch 用 f1_macro 调参,属于"训练阶段与汇总阶段
指标不完全一致"的做法。论文里可以讨论:如果要严格一致,最好统一使用 f1_macro
选最优模型。
"""
print("=" * 60)
print("第五步: 性能汇总")
print("=" * 60)
summary_data = []
for name, metrics in self.results.items():
summary_data.append(
{
"模型": name,
"准确率": f"{metrics['accuracy']:.4f}",
"宏平均F1": f"{metrics['f1_macro']:.4f}",
}
)
summary_df = pd.DataFrame(summary_data)
summary_df["准确率_数值"] = summary_df["准确率"].astype(float)
summary_df = summary_df.sort_values("准确率_数值", ascending=False).drop(
"准确率_数值", axis=1
)
print("模型性能汇总表:")
print(summary_df.to_string(index=False))
best_model_name = summary_df.iloc[0]["模型"]
print(f"\n最佳模型: {best_model_name}")
print(f" 准确率: {self.results[best_model_name]['accuracy']:.4f}")
return best_model_name
调参策略
调参策略使用的是 `GridSearchCV`,并且把评估指标定为 `f1_macro`。这背后体现的是**"诊断任务更关心各类均衡表现"的思想**:如果类别不均衡,单纯追求accuracy可能会牺牲少数类(例如某类故障样本少,但在工程上它恰恰重要)。宏平均F1会对每一类的F1同等加权,因此能更早暴露"某些故障根本分不出来"的问题。代码里交叉验证折数设为3折(`cv=3`),这是一个折中:折数越大评估越稳定,但训练时间越长;在竞赛节奏下,先用3折快速定位合理超参范围是可接受的。另一个工程细节是 `n_jobs=1`,注释里已经说明其意图:在Windows环境里,中文路径、临时文件、并行化有时会触发编码问题,尤其是在某些Anaconda/locale配置不一致时。把并行关掉不是算法层面的选择,而是为了保证"能稳稳跑完并产出结果",这在复盘里其实也是一个合理的工程取舍:稳定性优先于速度。
复盘
从整体流程看,第二问是:先把数据处理到一个"模型能公平比较"的状态(分层划分、标准化、编码),再用同一套调参框架对多模型做横向对比,最后用分类报告、混淆矩阵和特征分布图把结果解释回"故障诊断问题本身"。从数据到模型、从模型到解释的闭环,诊断关心各类均衡表现,所以用宏平均F1;诊断需要知道错在哪里,所以看混淆矩阵;诊断最终要能被解释,所以画特征分布并分析重叠区。
也正因为第二问做的是传统模型,它能把"特征是否靠谱"这件事先说清楚------如果第二问已经能在源域上得到稳定表现,那么第三问再引入迁移学习时,读者会更容易接受"困难来自域偏移,而不是特征/模型本身完全无效"。反之,如果第二问在源域上表现就很差,那复盘应该回到第一问:可能需要重新设计频域窗口、增加带通滤波或包络解调前的预处理、或引入更有判别力的时频特征,