从零搭建一个带 GUI 的机器学习建模系统:多模型切换、遗传算法优化与可视化实战复盘

目录

[1. 项目背景与开发目标](#1. 项目背景与开发目标)

[2. 项目整体架构设计](#2. 项目整体架构设计)

[3. 图形界面是如何设计的](#3. 图形界面是如何设计的)

[4. 多模型切换机制是怎么实现的](#4. 多模型切换机制是怎么实现的)

[5. 核心算法原理详解](#5. 核心算法原理详解)

[5.1 BP 神经网络:我为什么把它作为神经网络基线](#5.1 BP 神经网络:我为什么把它作为神经网络基线)

[5.2 CNN:为什么我把表格数据也做成了 1D-CNN](#5.2 CNN:为什么我把表格数据也做成了 1D-CNN)

[5.3 XGBoost:为什么它是这个系统里最重要的表格模型之一](#5.3 XGBoost:为什么它是这个系统里最重要的表格模型之一)

[5.4 遗传算法:为什么我要用启发式方法做超参数优化](#5.4 遗传算法:为什么我要用启发式方法做超参数优化)

[6. 参数优化模块是怎么工作的](#6. 参数优化模块是怎么工作的)

[7. 系统运行流程与实验结果展示](#7. 系统运行流程与实验结果展示)

[8. 我在开发中遇到的关键问题](#8. 我在开发中遇到的关键问题)

[8.1 GUI 不能被训练过程卡死](#8.1 GUI 不能被训练过程卡死)

[8.2 多模型接口不统一](#8.2 多模型接口不统一)

[8.3 不同模型的输入格式不一样](#8.3 不同模型的输入格式不一样)

[8.4 参数面板不能写死](#8.4 参数面板不能写死)

[8.5 依赖不完整不能让系统直接崩](#8.5 依赖不完整不能让系统直接崩)

[8.6 优化过程太耗时](#8.6 优化过程太耗时)

[9. 项目可改进方向](#9. 项目可改进方向)

[10. 总结](#10. 总结)

1. 项目背景与开发目标

我做这个项目的出发点其实很直接:很多机器学习项目算法能跑,但展示性不够。最常见的情况是,模型训练写在 Notebook 里,参数改动依赖手工改代码,结果看图也 scattered 在不同脚本中。对于课程设计、毕业设计,甚至项目演示来说,这种方式不够友好,也不够"系统化"。

所以我想做一个真正能交互的机器学习桌面系统,把数据导入、任务识别、模型切换、参数设置、启发式优化、训练控制、结果展示、模型保存和预测都放到一个 GUI 里。这样它既可以作为一个完整的工程作品,也能让我把"算法原理"和"软件实现"真正揉在一起,而不是只停留在某一层。

从当前代码结构来看,ml_gui_system 并不是典型的前后端分离项目,而是一个基于 PySide6 的单体桌面应用。这反而很适合这个题目:界面层、算法层、数据层、优化层都在一个 Python 进程里,通过信号槽和线程做解耦,减少了额外的网络通信复杂度,也更适合教学和演示。

我给这个系统设定的核心目标有四个:

  • 让用户不用改代码,就能在界面中切换不同模型。
  • 让优化算法不是"概念展示",而是真正参与超参数搜索。
  • 让结果展示不仅有数字,还要有图、有对比、有报告。
  • 让工程结构足够清晰,后续继续加模型、加优化器、加数据类型都不至于推倒重来。

2. 项目整体架构设计

我先把整个工程拆成了几个相对清晰的层次:ui 负责交互,models 负责模型封装,data 负责预处理,optimizers 负责启发式搜索,utils 负责配置、指标和可视化。这样做的好处是,我在加模型时基本不碰 GUI 主流程,在改界面时也不用去侵入模型实现。

从核心模块如下:

模块 路径 作用
应用入口 main.py 初始化 QApplication,处理高 DPI,启动主窗口
GUI 主界面 ui/main_window.py 负责界面布局、事件绑定、训练线程调度、结果更新
图表组件 ui/plot_widget.py 封装结果图、优化图、模型对比图三个标签页
通用控件 ui/widgets.py 封装参数表单、数据预览表、指标表、比较表、预测对话框
数据处理 data/data_processor.py 缺失值处理、编码、标准化、划分训练/测试集
模型层 models/*.py 封装 BP、CNN、XGBoost、SVM、RF、KNN 等模型
模型工厂 models/model_factory.py 动态注册和创建模型,按任务和依赖过滤
优化器层 optimizers/*.py 实现 GA、PSO、SA
配置中心 utils/config.py 统一维护模型参数 schema、搜索空间、样式和示例数据
指标与图表 utils/metrics.py、utils/visualization.py 评估与可视化
示例数据 sample_data/ 提供可直接演示的 CSV / Excel 数据和生成脚本

3. 图形界面是如何设计的

这次我在 GUI 设计上花的心思,甚至不比模型少。因为一个机器学习系统只要一上 GUI,就会立刻面临两个现实问题:控件多、状态多。如果布局没有设计好,用户会觉得系统"堆满了东西但不会用"。

我最后采用的是"顶部概览 + 中部左右分栏 + 右侧上下分割"的结构。顶部放项目概览,左边是控制面板,右边是结果区。左侧整体放进 QScrollArea,这样即使模型参数很多,界面也不会把下半部分挤没;右侧再用一个垂直 QSplitter 分成上半部分图表区和下半部分结果/日志区,保证笔记本屏幕上也能完整操作。

这部分布局核心代码就在 ui/main_window.py 里:

python 复制代码
main_splitter = QSplitter(Qt.Orientation.Horizontal)

left_scroll = QScrollArea()
left_scroll.setWidgetResizable(True)

right_splitter = QSplitter(Qt.Orientation.Vertical)

self.plot_tabs = PlotTabWidget()
right_splitter.addWidget(self.plot_tabs)

lower_tabs = QTabWidget()
lower_tabs.addTab(self._build_metrics_tab(), "指标结果")
lower_tabs.addTab(self._build_comparison_tab(), "模型比较")
lower_tabs.addTab(self._build_log_tab(), "训练日志")
right_splitter.addWidget(lower_tabs)

这段代码背后的思路很明确:左边解决"参数太多"的问题,右边解决"图表、指标、日志争空间"的问题。我没有继续用纯粹的纵向堆叠布局去硬塞内容,而是把"滚动"和"可拖动分割"都交给 Qt 的现成控件,这会让界面在不同分辨率下稳很多。

我把界面划成了几个固定职责区:

区域 主要控件 作用
顶部概览 QGroupBox + QLabel 显示数据规模、目标列、任务、模型、优化方式、状态
数据区 数据导入按钮、示例数据按钮、预览表、目标列选择 导入 CSV/Excel,选择目标列,显示任务建议
模型区 模型下拉框、说明标签、参数表单 选择模型并动态刷新参数
优化区 优化方式、目标指标、范围表格 配置 GA/PSO/SA 以及搜索空间
控制区 训练、停止、保存、加载、预测 管理训练生命周期
图表区 PlotTabWidget 显示结果图、优化曲线、模型对比图
底部标签页 指标表、比较表、日志 提供结构化结果与运行日志

为了让参数区不是写死的,我又做了一个通用的 ParameterFormWidget。它接收一个 schema,然后自动创建 QSpinBox、QDoubleSpinBox、QComboBox、QCheckBox 等控件:

python 复制代码
def set_schema(self, schema: list[dict[str, Any]]) -> None:
    self.schema = list(schema)
    self._clear_form()
    self.widgets.clear()

    for config in self.schema:
        widget = self._create_widget(config)
        self.widgets[config["name"]] = widget
        self.form_layout.addRow(config["label"], widget)

这个设计对我帮助很大。因为我后面新增模型时,不是去"新增一个参数面板类",而是给 config.py 加一段配置,界面层就能自动把它渲染出来。这个模式本质上是把界面做成了 配置驱动的动态表单

另外,从当前实现看,我还专门做了几个界面体验优化点:

  • 按钮和 Tabs 样式统一走 APP_STYLESHEET
  • 图表画布设置了最小宽高,避免被压缩得太难看
  • 模型说明区会提示模型适用任务和当前环境未显示的模型
  • 支持"恢复默认参数"
  • 支持"加载示例数据"
  • 通过 statusBar().showMessage() 和顶部状态区同步反馈运行状态

4. 多模型切换机制是怎么实现的

这个系统最核心的能力之一,就是"模型可以切换,而且切换后参数面板、训练流程、保存加载流程都还能工作"。如果这部分设计得不好,后面每加一个模型,主窗口都会变得更乱。

我最后用的是 统一接口 + 工厂模式 + 配置映射 的组合方式。

首先,我在 models/base_model.py 里定义了统一接口。所有模型都必须实现 fit、predict、save、load,并且统一带上 task_type 和 params:

python 复制代码
class BaseModel:
    model_name = "Base"
    supported_tasks = ("classification", "regression")

    def __init__(self, task_type: str, params: dict | None = None) -> None:
        self.task_type = task_type
        self.params = params or {}
        self.training_history: list[float] = []

然后我又在 models/sklearn_base_model.py 里做了一层通用封装,把绝大部分 sklearn 模型的重复逻辑抽掉了。像 SVM、RandomForest、DecisionTree、KNN、LogisticRegression、LinearRegression、NaiveBayes、AdaBoost 这些模型,本质上都是在声明:

  • 分类 estimator 是谁
  • 回归 estimator 是谁
  • 默认参数是什么
  • 是否需要少量 _prepare_params() 修正

这让模型层非常整洁。比如 SVM 的实现就很轻:

python 复制代码
class SVMModel(SklearnBaseModel):
    model_name = "SVM"
    estimator_cls_classification = SVC
    estimator_cls_regression = SVR
    default_params = {
        "C": 1.0,
        "kernel": "rbf",
        "gamma": "scale",
        "degree": 3,
        "epsilon": 0.1,
    }

    def _prepare_params(self):
        params = dict(self.params)
        if self.task_type == "classification":
            params["probability"] = True
            params.pop("epsilon", None)
        else:
            params.pop("degree", None)
        return params

真正把"多模型切换"串起来的,是 models/model_factory.py。我没有把模型类直接硬编码到主窗口里,而是先定义 MODEL_IMPORT_SPECS,再通过动态导入去装配模型:

python 复制代码
def _load_registry() -> tuple[dict[str, type[BaseModel]], dict[str, str]]:
    registry = {}
    unavailable = {}

    for model_name in MODEL_DISPLAY_ORDER:
        module_name, class_name = MODEL_IMPORT_SPECS[model_name]
        try:
            module = import_module(module_name)
            registry[model_name] = getattr(module, class_name)
        except Exception as exc:
            unavailable[model_name] = str(exc)

    return registry, unavailable

这个设计有两个工程上的好处。

第一,模型列表不再和 GUI 强耦合。MainWindow 只需要问一句 ModelFactory.list_models(task_type),就知道当前任务下应该显示哪些模型。

第二,我解决了一个很常见但很容易忽视的问题:可选依赖。比如当前仓库里虽然已经有 CNNModel、XGBoostModel、LightGBMModel 的代码,但如果运行环境里缺少 torch、xgboost 或 lightgbm,我不希望程序一启动就崩。所以我在工厂里把导入失败的原因记到 UNAVAILABLE_REASONS,界面上则提示"安装依赖后自动出现"。

这意味着从当前实现来看,模型切换逻辑并不是一堆 if...elif...else... 的堆砌,而是:

  1. config.py 声明模型参数和任务适配信息
  2. ModelFactory 负责动态装配
  3. MainWindow 根据任务刷新下拉框
  4. ParameterFormWidget 根据 schema 动态渲染表单

这种结构对扩展新模型特别友好。

我把当前系统中比较核心的模型角色概括成下面这张表:

模型 任务类型 底层实现 在系统中的角色
BP 分类 / 回归 MLPClassifier / MLPRegressor 神经网络基线模型
CNN 分类 / 回归 自定义 PyTorch 1D-CNN 深度学习演示模型
XGBoost 分类 / 回归 官方 XGBClassifier / XGBRegressor 强表格模型
SVM 分类 / 回归 SVC / SVR 中小样本边界建模
RandomForest 分类 / 回归 sklearn 随机森林 稳定的树模型基线
DecisionTree 分类 / 回归 sklearn 决策树 可解释、训练快
KNN 分类 / 回归 sklearn KNN 距离度量型模型
LogisticRegression 分类 sklearn 逻辑回归 分类经典基线
LinearRegression 回归 sklearn 线性回归 回归经典基线
NaiveBayes 分类 GaussianNB 轻量级分类基线
AdaBoost 分类 / 回归 sklearn AdaBoost 集成学习扩展
LightGBM 分类 / 回归 LGBMClassifier / LGBMRegressor 可选扩展模型

5. 核心算法原理详解

这一部分我不想只讲概念,而是想把"原理"和"当前代码里的实现方式"放在一起讲。因为算法真正有意思的地方,不只是它的数学形式,而是它落到工程里以后,输入输出究竟怎么流。

5.1 BP 神经网络:我为什么把它作为神经网络基线

在这个项目里,BP 神经网络并不是我手写的反向传播,而是封装了 sklearn 的 MLPClassifier 和 MLPRegressor。这其实是一个很实用的选择:对于课程设计和桌面建模系统来说,自己手搓一个完整的前馈网络训练器不是不行,但会把大量精力消耗在数值稳定性和训练细节上,反而削弱了系统整体性。

BP 神经网络的核心思想,是用多层线性变换加非线性激活去逼近复杂映射。对第 ll 层来说,前向传播可以写成:

如果是回归任务,我更关心的是均方误差:

参数更新遵循梯度下降或其变体:

在当前项目中,我的 BP 模型实现很直接:

python 复制代码
class BPModel(SklearnBaseModel):
    model_name = "BP"
    estimator_cls_classification = MLPClassifier
    estimator_cls_regression = MLPRegressor
    default_params = {
        "hidden_layer_sizes": 64,
        "learning_rate_init": 0.001,
        "batch_size": 32,
        "max_iter": 200,
        "activation": "relu",
        "alpha": 0.0001,
    }

    def _prepare_params(self):
        params = dict(self.params)
        hidden_value = params.get("hidden_layer_sizes", 64)
        params["hidden_layer_sizes"] = (int(hidden_value),)
        return params

这段代码说明了两个实现细节。

第一,我把 hidden_layer_sizes 做成了单值输入,然后在 _prepare_params() 里转成 tuple。这样界面上参数更简单,用户也更容易理解。

第二,我把 BP 放在了 SklearnBaseModel 体系里,而不是单独搞一套保存加载和预测逻辑。这让它在训练线程、模型工厂、结果展示里的行为和其他传统机器学习模型保持一致。

在这个系统里,BP 更像是一个"神经网络基线"角色。它适合中小规模表格数据,尤其适合展示"非线性拟合"能力。但它也有明显局限:

  • 对输入尺度较敏感,所以我必须在 DataProcessor 里做标准化
  • 参数比较多,容易受学习率、层宽、迭代次数影响
  • 对纯表格数据,它未必总能打赢树模型

也正因为如此,我才让它特别适合和 GA 配合:BP 的超参数空间比较典型,拿来做启发式优化展示很有代表性。

5.2 CNN:为什么我把表格数据也做成了 1D-CNN

很多人一看到 CNN,就会先想到图像。但从当前项目代码来看,我实现的是 表格数据 1D-CNN,而不是 2D 图像卷积。这是一个很有现实感的工程取舍:当前项目的数据入口是 CSV / Excel,系统里没有图像目录加载器,所以我需要一个能在表格数据上跑通的 CNN 版本。

对于一维卷积来说,一个卷积核在位置 tt 的输出可以写成:

如果把每一列特征看成序列上的一个位置,那么 1D-CNN 就可以在"特征维度"上学习局部组合模式。

我在 models/cnn_model.py 里定义的网络结构是:

python 复制代码
self.network = nn.Sequential(
    nn.Conv1d(1, conv_channels, kernel_size=kernel_size, padding=padding),
    nn.BatchNorm1d(conv_channels),
    nn.ReLU(),
    nn.Conv1d(conv_channels, mid_channels, kernel_size=kernel_size, padding=padding),
    nn.BatchNorm1d(mid_channels),
    nn.ReLU(),
    nn.AdaptiveAvgPool1d(1),
    nn.Flatten(),
    nn.Linear(mid_channels, hidden_dim),
    nn.ReLU(),
    nn.Dropout(dropout),
    nn.Linear(hidden_dim, output_dim),
)

这个结构很轻,但很完整:

  • 两层 Conv1d 负责提取局部模式
  • BatchNorm1d 提升训练稳定性
  • AdaptiveAvgPool1d(1) 把不同长度的特征图压成统一尺寸
  • 全连接层完成最终分类或回归

为了让表格数据能进入这个网络,我在训练前做了一个关键 reshape:

python 复制代码
X_tensor = torch.tensor(X, dtype=torch.float32).unsqueeze(1)

也就是说,我把输入变成了:

shape=[batch,1,feature_dim]shape=[batch,1,feature_dim]

这里的 1 不是图像通道,而是"单通道特征序列"。这和经典图像 CNN 不同,但对当前这个系统是合理的。因为我的目标不是做 SOTA,而是让系统在 tabular 数据场景下也能展示卷积网络的训练、可视化和参数优化流程。

我认为 CNN 在这个系统里的角色更偏"深度学习分支"的展示:

  • 它能补齐系统的模型谱系,不至于只有传统 ML
  • 它适合和 BP 做神经网络风格对比
  • 它能带出 QThread、epoch 进度、loss 日志这些更丰富的 GUI 交互

但我也必须诚实地说,从当前实现来看,它还不是图像任务的完整 CNN 系统。DataProcessor 里只是通过列名是否像 pixel 去提示"可扩展为 2D-CNN",并没有真正实现图像文件夹读取和 2D 卷积分支。这一点在后面的改进方向里我会再讲。

5.3 XGBoost:为什么它是这个系统里最重要的表格模型之一

如果说 BP 和 CNN 主要承担"神经网络分支"的角色,那 XGBoost 在这个系统里就是典型的"强表格模型"。

XGBoost 的核心思想,是以加法模型的方式逐轮叠加树模型:

每一轮都在优化下面这个目标函数:

其中正则项控制树复杂度:

这也是 XGBoost 在工程上很有优势的原因:它不是一味堆树,而是把损失下降和模型复杂度都纳入目标函数。

在我的代码里,XGBoostModel 最重要的部分是根据任务类型和类别数动态选择 objective:

python 复制代码
if self.task_type == "classification":
    n_classes = len(np.unique(y))
    if n_classes <= 2:
        return XGBClassifier(objective="binary:logistic", eval_metric="logloss", **common_kwargs)
    return XGBClassifier(
        objective="multi:softprob",
        eval_metric="mlogloss",
        num_class=n_classes,
        **common_kwargs,
    )

return XGBRegressor(objective="reg:squarederror", eval_metric="rmse", **common_kwargs)

这段实现我很喜欢,因为它体现了"系统型项目"该有的样子:模型不是写死在某一个任务上,而是根据当前数据自动切换到合适的目标。

另外我还在参数里固定了:

  • tree_method="hist"
  • n_jobs=1
  • verbosity=0

这几个细节其实都很工程化。hist 能让树构建更快,verbosity=0 保证日志不污染 GUI,n_jobs=1 则避免桌面端把 CPU 占满导致界面卡顿。

在这个系统里,我把 XGBoost 看成"表格数据的主力模型":

  • 对非线性和特征交互很敏感
  • 对缺失值和复杂分布通常更稳
  • 很适合与 RF、DT、BP 做横向比较
  • 超参数空间丰富,非常适合接入 GA 做优化展示

它的缺点也很明显:参数多,训练与调参成本高。如果没有统一的超参数优化模块,用户其实很难把它调到比较理想的状态。

5.4 遗传算法:为什么我要用启发式方法做超参数优化

我在这个系统里引入遗传算法,不是为了"看起来高级",而是因为它确实适合这个场景。

超参数优化和普通梯度下降不一样。像 hidden_layer_sizes、n_estimators、kernel_size、max_depth 这类参数,很多都是离散的,或者混合了离散与连续变量。对这类问题,GA 这种 无梯度、全局搜索倾向明显 的方法就很自然。

我在 optimizers/ga_optimizer.py 里实现的 GA,是一个比较标准但足够实用的版本。整体流程是:

  1. 随机初始化种群
  2. 评估当前种群的适应度
  3. 按分数排序
  4. 保留精英个体
  5. 锦标赛选择父代
  6. 交叉生成子代
  7. 做边界约束下的变异
  8. 重复迭代

核心循环代码是这样的:

python 复制代码
for generation in range(self.generations):
    scored_population = []
    for candidate in population:
        score = self.objective_func(candidate)
        scored_population.append((score, candidate))

    scored_population.sort(key=lambda item: item[0], reverse=self.direction == "max")
    current_best_score, current_best_params = scored_population[0]

    next_population = [copy.deepcopy(candidate) for _, candidate in scored_population[: self.elite_size]]
    while len(next_population) < self.population_size:
        parent_one = self._tournament_select(scored_population)
        parent_two = self._tournament_select(scored_population)
        child = self._crossover(parent_one, parent_two)
        child = self._mutate(child)
        next_population.append(child)

我这里最想强调两点。

第一,我没有把适应度写死为某一个值,而是让 TrainingWorker 提供 objective_func,再通过 metric_direction() 决定当前指标是该最大化还是最小化。也就是说:

只是对于回归任务,我并没有简单地把指标取负,而是让优化器显式支持 "min" 方向。这个设计更清晰,也更容易扩展。

第二,我的浮点参数交叉不是简单二选一,而是做了线性混合:

变异则是带边界约束的随机扰动:

这和代码里的 _crossover()、_mutate() 是一致的。

遗传算法在这个系统里承担的角色非常明确:它不是替代模型训练,而是一个"模型外层的搜索器"。它只负责找到更好的超参数组合,真正的训练和评估仍然由模型类本身完成。

从当前代码来看,我还实现了 PSO 和 SA。这说明系统在优化器设计上已经不是单一算法,而是预留了"优化器工厂"的结构。只是从工程重心看,当前版本最完整、最适合重点讲解的仍然是 GA。

6. 参数优化模块是怎么工作的

如果说上一节讲的是"GA 本身是什么",那这一节我更想讲"GA 在这个系统里是怎么接进训练流程的"。

我在 ui/main_window.py 的 TrainingWorker 里,把优化过程放进了后台线程,而不是放在主线程里直接跑。这样一来,超参数搜索即使会多次训练模型,也不会卡死界面。

更关键的是,我不是直接拿测试集调参,而是先在训练集内部再切一份验证集:

python 复制代码
X_sub_train, X_val, y_sub_train, y_val = train_test_split(
    X_train,
    y_train,
    test_size=0.2,
    random_state=42,
    stratify=stratify,
)

这一步很重要,因为如果直接用测试集搜索参数,测试集就失去"最终评估"的意义了。虽然这里还不是交叉验证,但至少守住了最基本的训练/验证/测试分离思路。

适应度函数则由训练线程动态构造:

python 复制代码
def objective_func(candidate_params: dict[str, Any]) -> float:
    merged_params = {**self.model_params, **candidate_params}
    candidate_model = ModelFactory.create(self.model_name, self.task_type, merged_params)
    candidate_model.fit(X_sub_train, y_sub_train, stop_callback=lambda: self._stop_requested)
    metrics = self._evaluate_model(candidate_model, X_val, y_val, class_labels)
    return extract_metric_value(metrics, self.objective_metric)

这段代码其实就是"把超参数优化问题翻译成模型训练问题"的关键桥梁:

  • 候选参数来自优化器
  • 模型实例来自工厂
  • 训练数据来自子训练集
  • 评价指标来自验证集
  • 返回值就是适应度

我还专门做了一步"基线模型训练",用于展示优化前后对比。也就是说,当用户选择 GA/PSO/SA 时,系统不是直接开始优化,而是先用原始参数跑一遍,得到 baseline 指标,再去搜索最优参数,最后再训练最终模型。这一点在教学和演示上非常有价值,因为用户能直观看到"优化到底有没有带来变化"。

config.py 来看,我已经给多个模型配置了搜索空间。比较有代表性的参数如下:

模型 待优化参数 搜索范围示例 优化目标
BP hidden_layer_sizes、learning_rate_init、batch_size、max_iter 16256、0.00050.03、16128、120500 分类最大化 Accuracy/F1,回归最小化 RMSE/MAE
CNN conv_channels、kernel_size、learning_rate、batch_size、epochs 864、25、0.00050.02、16128、10~60 同上
XGBoost n_estimators、max_depth、learning_rate、subsample 50400、210、0.010.3、0.51.0 同上
SVM C、degree/epsilon 0.120、25 或 0.01~0.5 同上
RandomForest n_estimators、max_depth、min_samples_split 50400、320、2~10 同上

我还处理了一个比较现实的问题:并不是所有模型都适合在当前阶段启用所有优化器。如果某个模型没有配置搜索空间,系统不会直接报错,而是通过 optimization_note 和界面提示告诉用户"当前模型暂未配置搜索空间,已跳过优化"。这点看起来小,但它极大提升了系统的鲁棒性。

7. 系统运行流程与实验结果展示

为了让系统可以开箱即用,我额外加了 sample_data/ 目录和 generate_sample_data.py。这三个示例数据不是随便拼的,而是故意设计成既能演示分类/回归,又能覆盖缺失值处理和类别特征编码。

从当前生成结果来看,示例数据大致如下:

数据集 形状 任务 特点
binary_classification.csv 420 × 10 二分类 8 个数值特征 + 1 个类别特征 + 二分类目标,约 111 个缺失值
multiclass_classification.csv 480 × 8 多分类 6 个数值特征 + 1 个类别特征 + 三分类目标,约 99 个缺失值
regression_demo.csv 420 × 8 回归 6 个数值特征 + 1 个类别特征 + 连续目标,约 86 个缺失值

这里我专门做了两件事。

第一,我故意加入了类别型特征,比如 feature_9、feature_7,这样能测试 ColumnTransformer + OneHotEncoder 的分支逻辑。

第二,我故意注入了少量缺失值,这样系统的缺失值填补流程就不是摆设,而是真正在示例数据上发生作用。

整个训练流程,按系统实际逻辑可以概括成这样:

  1. 用户导入 CSV/Excel,或点击"加载示例数据"
  2. 系统读取 DataFrame,自动填充目标列下拉框
  3. 根据目标列类型给出任务建议
  4. 用户选择任务类型后,模型列表自动过滤
  5. 用户切换模型后,参数表单与搜索空间自动刷新
  6. 点击"开始训练"后,DataProcessor.fit_transform() 完成预处理
  7. TrainingWorker 在后台线程中执行训练
  8. 如果启用了优化,先跑基线,再做搜索,再训练最终模型
  9. 主界面收到训练完成信号后,更新指标表、报告文本、图表和模型比较表
  1. 用户可以保存模型,或加载新数据执行预测

预测流程则相对简单:

  1. 加载已训练模型和对应的 processor
  2. 读取待预测文件
  3. transform_features() 做同样的预处理
  4. 模型预测
  5. 把 prediction 列拼回原始数据
  6. 在对话框中预览,并可导出 CSV

结果展示部分我也尽量做得"像系统,而不是像脚本"。utils/visualization.py 里我把结果图拆成了三类:

  • 分类任务:Accuracy、Precision、Recall、F1、混淆矩阵、ROC
  • 回归任务:MSE、RMSE、MAE、R²、真实值 vs 预测值、残差图
  • 优化任务:优化过程曲线、优化前后对比图

另外,ComparisonTable 会按任务类型生成结构化对比表,PlotTabWidget 则会在同一任务下累计生成模型对比柱状图。这说明这个系统不是"单次训练结果查看器",而是具备了初步的 多次实验对比能力。虽然目前对比结果只保存在内存里,但对演示来说已经足够实用。

8. 我在开发中遇到的关键问题

8.1 GUI 不能被训练过程卡死

这是我最早遇到的问题。只要把模型训练和 GA 搜索直接丢进主线程,界面就会立刻"假死"。所以我最终把训练逻辑放进了 TrainingWorker(QObject),再移动到 QThread 中运行。主界面只负责发起任务、接收信号和刷新结果。

这个决定的直接收益是:即便 CNN 训练多个 epoch,或者 GA 评估多个候选参数,界面仍然能响应停止按钮、进度条和日志刷新。

8.2 多模型接口不统一

如果每个模型都各写一套 fit/predict/save/load,主窗口很快就会充满分支逻辑。我后来用 BaseModel + SklearnBaseModel + ModelFactory 把这部分统一掉了。这样无论是 LogisticRegression 还是 RandomForest,主线程看到的都是同一套接口。

这件事的价值,不只体现在"代码优雅",更体现在扩展成本上。因为后面我新增模型时,主窗口几乎不需要改。

8.3 不同模型的输入格式不一样

BP、SVM、RF、XGBoost 这类模型喜欢二维表格输入;CNN 则要求 [batch, channel, feature_dim]。我没有让数据层去迁就某一个模型,而是让 DataProcessor 统一输出标准二维特征,再由 CNNModel 自己在 fit() 和 predict() 里做 unsqueeze(1)。

这是一种很典型的工程分层思路:数据层负责"通用预处理",模型层负责"特定输入重排"。

8.4 参数面板不能写死

如果参数控件全部写死在界面里,模型一多就会非常难维护。我后来把参数 schema 全部收到了 config.py,再用 ParameterFormWidget 动态渲染。这个改动虽然不涉及算法,但实际上是整个系统扩展性的关键。

8.5 依赖不完整不能让系统直接崩

这也是做桌面系统时很现实的问题。比如别人下载你的项目,未必第一时间就装好了 torch、xgboost、lightgbm。如果启动就报 ImportError,体验会很差。所以我在 ModelFactory 里做了动态装配和不可用原因记录,界面上则只显示当前环境真正可用的模型。

8.6 优化过程太耗时

GA 的本质决定了它会反复训练模型,这在桌面端尤其要小心。我做了几件事来控制成本:

  • 优化只在训练集内部再拆一个验证集,不重复动测试集
  • 搜索空间尽量收敛,不追求夸张的大范围
  • 对没有搜索空间的模型直接优雅跳过
  • 训练失败的候选参数直接记为差分数,不让整个优化中断

这些策略让系统在"能演示"和"别太慢"之间找到了一个平衡。

9. 项目可改进方向

从当前代码来看,这个系统已经能完成完整的表格建模闭环,但如果我要继续往下做,我最想改进的方向有这几个:

改进方向 当前状态 后续思路
2D-CNN 图像支持 仅有输入模式提示,没有真正图像管线 增加图像目录加载器与 2D 卷积分支
LightGBM 已有模型文件,依赖可选 正式加入 requirements 可选安装说明
贝叶斯优化 还未实现 在 optimizers/ 下新增统一接口
交叉验证 当前优化用单次验证集 引入 KFold / StratifiedKFold
实验记录 只有日志与内存中的比较结果 增加结果持久化与实验报告导出
配置管理 当前用 Python 字典 后续可迁移到 YAML / JSON
自动测试 当前没有测试脚本 为数据处理、模型工厂、优化器补单元测试

如果我继续做这个项目,我会优先做三件事:第一是补完整的图像数据工作流,第二是补实验管理,第三是把配置从 Python 常量表升级成外部可维护配置文件。这样项目就会从"一个完整的课程设计"进一步走向"一个更像产品原型的工具"。

需要源代码的,请联系作者,制作不易,请各位看官老爷点个赞!!!

相关推荐
一楼的猫2 小时前
茄子小说AI辅助智能写作助手:10倍速创作神器
人工智能·学习·机器学习·学习方法·ai写作·迁移学习·集成学习
懂AI的老郑2 小时前
人工智能手机的构建思路:从架构到实现
人工智能·智能手机·架构
gjhave2 小时前
强化学习论文(Double-DQN)
人工智能·机器学习
rADu REME2 小时前
rust web框架actix和axum比较
前端·人工智能·rust
Mark-Han2 小时前
AI产品的定价是一门玄学
人工智能
BizViewStudio2 小时前
GEO vs SEO vs SEM:2026 年品牌流量获取的三元格局分析
大数据·运维·网络·人工智能·ai
做个文艺程序员2 小时前
生产级 AI 服务:限流、鉴权与可观测性【OpenClAW + Spring Boot 系列 第6篇·终章】
人工智能·spring boot·后端
TDengine (老段)2 小时前
工业系统中的高级分析:超越工业实时数据库
大数据·数据库·人工智能·时序数据库·tdengine
OJAC1112 小时前
从“执行者”到“架构者”:AI 时代的职业重构与跃迁路径
人工智能·重构·架构