机器学习算法可视化系统:Flask+SQLite+Matplotlib实现常用模型训练与决策边界展示
2026/9/23 11:33:02 网站建设 项目流程

简介:这是一份Python机器学习期末项目源码包,面向高校学生与机器学习初学者,用于学习常用算法的可视化实现与系统搭建。资源围绕数据预处理、算法演示、模型对比、超参数调整与交互式体验等模块展开,借助Scikit-learn、Matplotlib及前后端框架,帮助理解线性回归、决策树、K-Means等算法原理。压缩包共108个文件,约5.6MB,包含Python后端脚本、Java/JSP前端页面、JavaScript交互逻辑、CSS样式、CSV数据文件、数据库相关文件及PPT文档,目录结构清晰,适合课程设计与答辩展示。目前已有306人学习下载,可作为期末项目参考、算法可视化入门练习或毕业设计基础框架。运行该系统后,读者能直观观察模型训练效果、比较不同算法性能,并借助配套文档和演示PPT完善项目汇报。

1. 期末项目为什么总卡在“做出来”和“讲明白”之间

很多人在机器学习期末项目上交过一份“能跑”的代码:数据集加载进来,调一下train_test_split,跑个随机森林,输出准确率,然后写一篇凑数的报告。分数往往不高,因为老师看的不只是准确率,而是你“是否真的理解了模型在干什么”。这个标题指向的常用机器学习算法可视化系统,核心思路就是把算法的决策过程变成看得见的图表和交互界面——你点一下按钮,模型训练完,曲线、决策边界、特征重要性全部渲染出来。这个项目最适合两类人:一是期末需要交一个完整系统、但不想只交一个IPython Notebook的人;二是想拿“有前端、有数据库、有文档”的项目去面试或考研复试展示的人。本文会直接给你一套能复现的落地方案,目录拆到文件级,代码贴到能跑。

2. 先立住理论再动手:可视化系统到底在可视化什么

2.1 算法内部原理可视化与结果可视化是两回事

做可视化系统之前,先分清两层:算法结果可视化是对模型输出做图表,比如分类报告、ROC曲线、特征重要性条形图;算法内部原理可视化则是展示模型在训练过程中“看到了什么”,比如决策树的分裂过程、K-Means的簇中心迁移、逻辑回归的决策边界变化。期末项目想拿高分,两类都要做。只做前者,系统看起来像个“报表生成器”;只做后者,又没有“数据库 + 前端”的完整度,容易被当成算法作业。

我给这个系统的定位是:算法训练的过程和结果都通过Web界面触发,训练日志写进数据库,图表由后端动态生成,前端只负责展示。这样数据流清晰,老师问起来你也能讲明白每一层在干什么。

2.2 技术选型:为什么是Flask + SQLite + Matplotlib而不是其他组合

常见的算法可视化项目有三种技术路线:纯Notebook(matplotlib直接出图)、桌面端(PyQt + matplotlib嵌入)、Web端(前后端分离或Flask模板渲染)。期末项目我推荐Flask + SQLite + Matplotlib/Scikit-learn组合,原因很实际:

  • Flask是Python生态的Web框架,你不需要额外学Node.js或Java,一个app.py就能把路由、请求处理、算法调用串起来。如果你用的是scikit-learn 1.2以上版本,也不需要担心和Flask线程模型冲突。
  • SQLite是文件型数据库,零配置,Python标准库sqlite3直接操作,不用装MySQL或PostgreSQL。期末项目要展示“有数据库”,SQLite完全够,而且交作业时不用让对方配数据库服务。如果你想用MySQL,注意Flask的create_engine连接的字符集需要显式设置为utf8mb4,否则中文标签写入会乱码。
  • MatplotlibAgg后端支持在无GUI环境下渲染图片,配合Flask的BytesIO直接把图返回给前端,不需要磁盘临时文件,避免多人提交作业时的路径冲突。

所以这个项目的核心链路是:前端表单选择算法和参数 → Flask路由接收请求 → 调用算法训练模型 → 用Matplotlib生成图表 → 图表转Base64嵌入页面,同时把训练记录写入SQLite。整个过程50行核心代码能跑通,但你要把它做“厚”,就得把数据库表设计、算法封装、异常处理都做好。下面我直接给你一套能复现的最小目录结构和数据表设计。

3. 落地路径:数据库设计、后端路由与前端交互

3.1 数据库表设计:不只存训练记录,还要存参数和指标

很多人的“数据库设计”就是一张表存几条记录,老师打开一看觉得是充数的。我的做法是设计三张表:datasets(数据集元信息)、train_logs(训练记录)、model_params(每次训练的算法参数)。这样至少有三个好处:前端历史记录页能按时间、算法、数据集筛选;你可以写一段SQL统计“哪个算法在哪个数据集上平均准确率最高”;答辩时老师问“你存了哪些数据”,你能说出结构化字段,而不是说“存了一些记录”。

下面是我实际项目里用到的建表SQL,直接扔到schema.sql里:

-- schema.sql: 初始化数据库表结构 CREATE TABLE IF NOT EXISTS datasets ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, -- 数据集名称,如 iris / wine rows_count INTEGER, -- 样本数 features_count INTEGER, -- 特征数 target_column TEXT, -- 标签列名 created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS train_logs ( id INTEGER PRIMARY KEY AUTOINCREMENT, dataset_id INTEGER NOT NULL, -- 关联datasets表的ID algorithm TEXT NOT NULL, -- 算法名: decision_tree / knn / logistic_regression / svm / random_forest accuracy REAL, -- 测试集准确率 precision REAL, -- 宏平均精确率 recall REAL, -- 宏平均召回率 f1 REAL, -- 宏平均F1 train_seconds REAL, -- 训练耗时,单位秒 params TEXT, -- 算法参数字典的JSON字符串 created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (dataset_id) REFERENCES datasets(id) ); CREATE TABLE IF NOT EXISTS model_params ( id INTEGER PRIMARY KEY AUTOINCREMENT, log_id INTEGER NOT NULL, param_name TEXT NOT NULL, param_value TEXT, FOREIGN KEY (log_id) REFERENCES train_logs(id) );

建表逻辑说明:datasets表先入库,train_logs表记录每次训练的核心指标,model_params把参数拆成键值对存,这样前端要做“参数对比”功能时直接用WHERE param_name = 'max_depth'查询,不用去解析JSON。注意params字段我留了JSON字符串,这是为了方便展示原始参数快照;同时拆表是为了让老师看到你有“规范化设计”的意识。

这里有一个细节:train_seconds字段看起来简单,但很多人在项目里忽略它。期末答辩时老师问“你这个模型训练快不快”,你空口说“挺快”没有说服力。存下这个字段后,你可以直接画一条“不同算法训练耗时对比”的柱状图,这种细节项目里越多越好。

3.2 Flask后端:把算法调用封装成统一接口

后端我一般不用Flask-RESTful这样的扩展,因为期末项目路由不超过10个,原生Flask足够。核心是把“训练一个算法”封装成一个函数,路由只做参数接收和结果返回。下面这段是app.py的核心部分:

# app.py: Flask主入口与训练路由 from flask import Flask, request, jsonify, render_template import json import time import sqlite3 import pandas as pd from sklearn.model_selection import train_test_split from sklearn.metrics import accuracy_score, precision_score, recall_score, f1_score from algorithm_wrappers import get_model # 统一模型工厂函数 app = Flask(__name__) DB_PATH = "ml_visual.db" def get_db(): conn = sqlite3.connect(DB_PATH) conn.row_factory = sqlite3.Row return conn @app.route("/api/train", methods=["POST"]) def train(): """训练接口: 接收算法名、数据集名、参数字典,返回指标和图表的Base64编码""" data = request.get_json() algorithm = data.get("algorithm", "decision_tree") dataset_name = data.get("dataset", "iris") params = data.get("params", {}) # 1. 加载数据集和算法 X, y = load_dataset(dataset_name) model = get_model(algorithm, params) # 2. 划分训练测试集,固定随机种子保证可复现 X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.25, random_state=42, stratify=y ) # 3. 训练并记录耗时 start = time.time() model.fit(X_train, y_train) train_seconds = round(time.time() - start, 4) # 4. 评估 y_pred = model.predict(X_test) metrics = { "accuracy": round(accuracy_score(y_test, y_pred), 4), "precision": round(precision_score(y_test, y_pred, average="macro", zero_division=0), 4), "recall": round(recall_score(y_test, y_pred, average="macro", zero_division=0), 4), "f1": round(f1_score(y_test, y_pred, average="macro", zero_division=0), 4), "train_seconds": train_seconds } # 5. 生成图表Base64(后续单独函数实现) chart_b64 = generate_charts(model, X, y, algorithm) # 6. 写入数据库 save_train_log(dataset_name, algorithm, metrics, params) return jsonify({"code": 0, "metrics": metrics, "charts": chart_b64})

参数说明:test_size=0.25是常见默认值,但如果数据集样本很少(比如小于200条),建议改成0.3甚至0.4,否则测试集太小,指标波动很大,答辩时可能被问“为什么两次训练结果不一样”。stratify=y是分类任务必须加的,它保证训练集和测试集的类别比例和原数据集一致,不加的话如果数据分布不均衡,模型可能完全学不到少数类。zero_division=0是防止某个类别一个都没预测对时precision_score报错,这是新手最容易忽略的坑。

get_model是统一模型工厂,你在algorithm_wrappers.py里写一个字典映射就够了:

# algorithm_wrappers.py: 算法工厂函数 from sklearn.tree import DecisionTreeClassifier from sklearn.neighbors import KNeighborsClassifier from sklearn.linear_model import LogisticRegression from sklearn.svm import SVC from sklearn.ensemble import RandomForestClassifier def get_model(name, params={}): """根据算法名返回一个未训练的模型实例,从params中读取用户自定义参数""" default_params = { "decision_tree": {"max_depth": 5, "min_samples_split": 2}, "knn": {"n_neighbors": 5, "weights": "uniform"}, "logistic_regression": {"max_iter": 200, "C": 1.0}, "svm": {"kernel": "rbf", "C": 1.0, "gamma": "scale"}, "random_forest": {"n_estimators": 100, "max_depth": 5} } model_dict = { "decision_tree": DecisionTreeClassifier, "knn": KNeighborsClassifier, "logistic_regression": LogisticRegression, "svm": SVC, "random_forest": RandomForestClassifier } # 合并参数: 用户传入的覆盖默认值 merged_params = {**default_params.get(name, {}), **params} return model_dict[name](**merged_params)

逻辑说明:这个工厂函数解决了前后端参数传递的一个核心痛点——前端传来的params是一个JSON对象,比如{"max_depth": 3},你不可能为每个算法写一段单独的解析代码。这里用默认参数合并用户参数的方式,前端没传的就用默认值,传了就覆盖。这个设计也让“参数对比”功能变得容易:前端可以传不同max_depth训练两次,后端不需要改代码。注意SVCgamma="scale"是scikit-learn 1.0之后推荐的默认写法,手写gamma=0.1反而容易在新版本上过时警告。

3.3 前端交互:模板渲染和Fetch结合,不做前后端分离

期末项目做前后端分离(Vue + Flask API)会让工程复杂度翻倍,而且你没有必要为了“炫技”去引入Node.js构建链。我的建议是:用Flask的render_template渲染页面框架,数据交互用原生fetch调用后端API返回JSON,图表以Base64字符串嵌入<img>标签。这个方案不需要写一行JavaScript框架代码,但已经具备前后端联调的真实体验。

核心的train.html模板关键部分如下,这里只给核心片段,完整模板文件按你本地结构调整:

<!-- templates/train.html: 训练页面核心交互 --> <div class="row"> <div class="col-md-4"> <h5>选择数据集</h5> <select id="dataset" class="form-control"> <option value="iris">鸢尾花 (Iris)</option> <option value="wine">葡萄酒 (Wine)</option> <option value="breast_cancer">乳腺癌 (Breast Cancer)</option> </select> <h5 class="mt-3">选择算法</h5> <select id="algorithm" class="form-control"> <option value="decision_tree">决策树</option> <option value="knn">K近邻</option> <option value="logistic_regression">逻辑回归</option> <option value="svm">支持向量机</option> <option value="random_forest">随机森林</option> </select> <h5 class="mt-3">算法参数 (JSON格式)</h5> <textarea id="params" class="form-control" rows="4">{}</textarea> <button class="btn btn-primary mt-3" onclick="trainModel()">开始训练</button> </div> <div class="col-md-8"> <div id="metrics_result"></div> <div id="charts_result"></div> </div> </div> <script> async function trainModel() { const payload = { algorithm: document.getElementById('algorithm').value, dataset: document.getElementById('dataset').value, params: JSON.parse(document.getElementById('params').value || '{}') }; const response = await fetch('/api/train', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload) }); const result = await response.json(); if (result.code === 0) { // 渲染指标 let metricsHtml = '<table class="table table-bordered">'; metricsHtml += `<tr><td>准确率</td><td>${result.metrics.accuracy}</td></tr>`; metricsHtml += `<tr><td>精确率</td><td>${result.metrics.precision}</td></tr>`; metricsHtml += `<tr><td>召回率</td><td>${result.metrics.recall}</td></tr>`; metricsHtml += `<tr><td>F1</td><td>${result.metrics.f1}</td></tr>`; metricsHtml += `<tr><td>训练耗时</td><td>${result.metrics.train_seconds} s</td></tr>`; metricsHtml += '</table>'; document.getElementById('metrics_result').innerHTML = metricsHtml; // 渲染图表 let chartsHtml = ''; for (const [name, b64] of Object.entries(result.charts)) { chartsHtml += `<div class="chart-card"><h6>${name}</h6>`; chartsHtml += `<img src="data:image/png;base64,${b64}" class="img-fluid" />`; chartsHtml += '</div>'; } document.getElementById('charts_result').innerHTML = chartsHtml; } else { alert('训练失败: ' + result.message); } } </script>

这段代码说明三个要点:第一,JSON.parse用户输入的参数时要做try-catch,否则用户输入非法JSON时整个页面报错。第二,图表用Base64嵌入虽然传输体积大(一张图约50-100KB),但期末项目场景下完全够用,而且省去了静态文件清理逻辑。第三,如果你想把项目升级好看一点,可以接一个开源前端模板(比如AdminLTE或Bootstrap Admin),但我一般只推荐Bootstrap原生,因为引入的依赖越少,答辩部署时越省心。

前端还有一个“历史记录”页面,表格展示train_logs的内容,加一个“查看详情”按钮跳转到该次训练的完整参数和图表。“历史记录”页用SQL查询拼表格,不需要额外写接口,直接render_template传数据。

到这里,你已经有一个能跑通的最小系统了。但这一步还不够——你需要把算法层做厚。下面讲封装细节和图表生成,这部分是“可视化”这个关键词的核心承载。

4. 算法层封装:让每个算法都能“画出东西来”

4.1 决策树和KNN的可视化侧重点完全不一样

决策树的可视化核心是把树画出来,老师一眼能看出你用了什么分裂准则、哪些特征被选中。KNN的可视化核心是决策边界,展示不同k值下分类边界的平滑程度。所以你的generate_charts函数不能对所有算法一视同仁,否则“可视化”就是一句空话。

我的做法是:为每个算法定义图表生成函数,统一返回一个字典{图表名: Base64字符串}。决策树画树结构,KNN画决策边界,支持向量机画的是高维映射下的决策边界(用PCA降到2D后再画),逻辑回归画特征系数条形图,随机森林做特征重要性排序。下面这段是generate_charts的代码核心:

# chart_generator.py: 各类算法可视化图表生成 import matplotlib matplotlib.use("Agg") # 无GUI后端,必须写这行 import matplotlib.pyplot as plt import io import base64 import numpy as np from sklearn.tree import plot_tree from sklearn.decomposition import PCA from matplotlib.colors import ListedColormap def fig_to_base64(fig): """把matplotlib图像对象转成Base64字符串""" buf = io.BytesIO() fig.savefig(buf, format="png", dpi=100, bbox_inches="tight") buf.seek(0) b64 = base64.b64encode(buf.read()).decode("utf-8") plt.close(fig) return b64 def generate_charts(model, X, y, algorithm): """返回字典: 图表名称 -> Base64字符串""" charts = {} if algorithm == "decision_tree": # 绘制决策树结构 fig, ax = plt.subplots(figsize=(16, 8)) plot_tree(model, ax=ax, filled=True, rounded=True, feature_names=X.columns if hasattr(X, 'columns') else None, class_names=[str(c) for c in np.unique(y)], fontsize=10) charts["决策树结构"] = fig_to_base64(fig) elif algorithm == "knn": # 绘制决策边界(2D降维) charts["KNN决策边界"] = plot_decision_boundary(model, X, y) elif algorithm == "random_forest": # 特征重要性柱状图 fig, ax = plt.subplots(figsize=(10, 6)) importances = model.feature_importances_ feature_names = X.columns if hasattr(X, 'columns') else [f"特征{i}" for i in range(X.shape[1])] indices = np.argsort(importances)[::-1] ax.bar(range(len(importances)), importances[indices]) ax.set_xticks(range(len(importances))) ax.set_xticklabels([feature_names[i] for i in indices], rotation=45, ha="right") ax.set_title("Random Forest Feature Importance") charts["特征重要性"] = fig_to_base64(fig) return charts def plot_decision_boundary(model, X, y, resolution=0.02): """用PCA把特征降到2D然后画分类边界图,所有算法通用""" pca = PCA(n_components=2, random_state=42) X_2d = pca.fit_transform(X) # 重新训练模型(因为输入维度变了) from copy import deepcopy model_2d = deepcopy(model) model_2d.fit(X_2d, y) # 生成网格点 x_min, x_max = X_2d[:, 0].min() - 1, X_2d[:, 0].max() + 1 y_min, y_max = X_2d[:, 1].min() - 1, X_2d[:, 1].max() + 1 xx, yy = np.meshgrid(np.arange(x_min, x_max, resolution), np.arange(y_min, y_max, resolution)) Z = model_2d.predict(np.c_[xx.ravel(), yy.ravel()]) Z = Z.reshape(xx.shape) fig, ax = plt.subplots(figsize=(8, 6)) cmap_light = ListedColormap(['#FFAAAA', '#AAFFAA', '#AAAAFF']) cmap_bold = ListedColormap(['#FF0000', '#00FF00', '#0000FF']) ax.contourf(xx, yy, Z, cmap=cmap_light, alpha=0.8) scatter = ax.scatter(X_2d[:, 0], X_2d[:, 1], c=y, cmap=cmap_bold, edgecolor='k', s=60) ax.set_xlabel('PCA Component 1') ax.set_ylabel('PCA Component 2') ax.set_title('Decision Boundary (PCA-reduced)') fig.colorbar(scatter, ax=ax) return fig_to_base64(fig)

这段代码里有两个容易被忽略的坑。第一个是matplotlib.use("Agg")必须写在导入pyplot之前,否则在某些环境下会报RuntimeError: main thread is not in main loop。第二个是plot_decision_boundary里我用deepcopy(model)然后在2D数据上重新fit,因为PCA降维后的数据是新的特征空间,原本已训练好的模型期望的输入维度对不上,不能直接拿原始模型去predict。很多人在这里翻车,报维度错误后不知道怎么排查。

但重新fit有一个问题:模型的max_depthn_neighbors等超参数不变,但实际训练的数据变了,严格来说这不是同一个模型在原始数据上学习出来的决策行为。如果答辩时被问到,你可以直接说“这是为了在2D平面上展示算法的决策机制,所以仅基于降维后的数据重新拟合”,这个解释是合理的。

resolution=0.02这个参数控制网格密度。数据集特征值范围通常在-2到2左右,0.02意味着每个轴约200个网格点,两个轴就是40000个预测,对于KNN和决策树来说很快,但对于SVM可能会慢一些。如果觉得卡顿,调成0.05可以明显提速,代价是边界锯齿感更强。

4.2 数据集加载的细节:为什么Iris不够用

很多人的项目只内置了Iris数据集,但Iris只有3类、4个特征、150条样本,决策边界画出来非常理想化,答辩老师可能觉得太“玩具”。这个标题既然说“常用机器学习算法可视化系统”,建议内置3到5个数据集。load_dataset函数的做法如下:

# dataset_loader.py: 数据集加载模块 from sklearn.datasets import load_iris, load_wine, load_breast_cancer from sklearn.preprocessing import LabelEncoder import pandas as pd def load_dataset(name): """加载内置数据集,返回特征矩阵和标签向量""" if name == "iris": data = load_iris() elif name == "wine": data = load_wine() elif name == "breast_cancer": data = load_breast_cancer() else: raise ValueError(f"未知数据集: {name}") X = pd.DataFrame(data.data, columns=data.feature_names) y = data.target # 对于二分类数据集,把标签转为字符串提高可读性 if name == "breast_cancer": y = pd.Series(y).map({0: "malignant", 1: "benign"}).values return X, y

注意X用的是DataFrame,这样决策树的plot_tree能直接显示真实特征名,而不是X[0]X[1]这种无意义的名字。特征越多,柱状图的可读性就越重要。还有一个细节:二分类数据集的标签最好是benignmalignant这种语义化名称,决策边界图上会显示出来,比0和1要直观得多。

如果数据量不够,你可以自己加一个load_uploaded_data函数,支持用户上传CSV文件,这是后话。但基础版本有3个数据集已经能覆盖所有算法的展示需求。Iris适合展示多分类边界,Wine有13个特征适合看特征重要性图,Breast Cancer是二分类适合看ROC曲线。

4.3 ROC曲线和混淆矩阵:老师喜欢看但学生总忘做的两张图

我见过太多期末项目只做准确率一个指标。准确率在类别均衡的数据集上就是“数字够看”,答辩时没有任何讨论空间。你要做的是在每个分类模型训练完后,强制生成ROC曲线和混淆矩阵,这两个图能体现你理解“分类模型评估不只看准确率”。

混淆矩阵需要根据y_testy_pred生成,但当前generate_charts函数只接收了model、X、y,没有测试集数据。这是设计上的缺陷。我修正为:generate_charts增加一个evaluation_data参数,传入X_test, y_test, y_pred。核心代码改为:

# chart_generator.py: 增加混淆矩阵和ROC曲线 from sklearn.metrics import confusion_matrix, roc_curve, auc from sklearn.preprocessing import label_binarize import seaborn as sns def generate_evaluation_charts(y_test, y_pred, y_prob, dataset_name, algorithm): """生成模型评估图表:混淆矩阵和ROC曲线""" charts = {} # 1. 混淆矩阵 cm = confusion_matrix(y_test, y_pred) fig, ax = plt.subplots(figsize=(7, 6)) sns.heatmap(cm, annot=True, fmt='d', cmap='Blues', ax=ax) ax.set_xlabel('Predicted Label') ax.set_ylabel('True Label') ax.set_title(f'Confusion Matrix - {algorithm}') charts["混淆矩阵"] = fig_to_base64(fig) # 2. ROC曲线(二分类直接用,多分类用OvR平均) classes = np.unique(y_test) if len(classes) == 2: fpr, tpr, _ = roc_curve(y_test, y_prob[:, 1]) roc_auc = auc(fpr, tpr) fig, ax = plt.subplots(figsize=(7, 6)) ax.plot(fpr, tpr, color='darkorange', lw=2, label=f'ROC curve (AUC = {roc_auc:.2f})') ax.plot([0, 1], [0, 1], color='navy', lw=2, linestyle='--') ax.set_xlim([0.0, 1.0]) ax.set_ylim([0.0, 1.05]) ax.set_xlabel('False Positive Rate') ax.set_ylabel('True Positive Rate') ax.set_title(f'ROC Curve - {algorithm}') ax.legend(loc="lower right") charts["ROC曲线"] = fig_to_base64(fig) else: # 多分类: One-vs-Rest y_bin = label_binarize(y_test, classes=classes) fpr, tpr, roc_auc = {}, {}, {} fig, ax = plt.subplots(figsize=(8, 6)) for i, cls in enumerate(classes): fpr[i], tpr[i], _ = roc_curve(y_bin[:, i], y_prob[:, i]) roc_auc[i] = auc(fpr[i], tpr[i]) ax.plot(fpr[i], tpr[i], lw=2, label=f'Class {cls} (AUC = {roc_auc[i]:.2f})') ax.plot([0, 1], [0, 1], 'k--') ax.set_xlabel('False Positive Rate') ax.set_ylabel('True Positive Rate') ax.set_title(f'ROC Curve (OvR) - {algorithm}') ax.legend() charts["ROC曲线"] = fig_to_base64(fig) return charts

y_prob从哪里来?对于LogisticRegressionRandomForestClassifierpredict_proba直接可用;SVC需要设置probability=True才能在训练时计算Platt缩放的概率估计,这会降低训练速度;KNN的predict_proba返回的是近邻的类别比例。所以y_prob必须在路由中根据算法类型生成,不能全统一成model.predict_proba。建议在get_model中为SVC固定写probability=True,这样代码不会因为算法切换而崩溃。

这里有个极限情况:决策树和KNN在predict_proba时,如果某个叶子节点只有一个类别,概率就是0和1,ROC曲线会显得非常“硬”,远离对角线,这是正常现象。答辩老师如果追问,你回答“样本量较小导致概率估计不平滑”即可。

5. 避坑指南:数据库写入、图表乱码和训练超时

5.1 现象:浏览器里图表中文全部变成方框

这是Matplotlib可视化项目最高频的问题,没有之一。原因很简单:Matplotlib默认字体是英文系的DejaVu Sans,不包含中文字形。解决方法是全局设置中文字体,但不同系统的做法不同。我在项目里统一用以下配置:

# config.py: Matplotlib全局中文字体配置 import matplotlib matplotlib.use("Agg") import matplotlib.pyplot as plt plt.rcParams["font.sans-serif"] = ["SimHei", "Microsoft YaHei", "WenQuanYi Zen Hei"] plt.rcParams["axes.unicode_minus"] = False # 解决负号显示为方块的问题

注意:SimHei是Windows上的字体名称,如果你部署在Linux服务器上,不存在SimHei,要换成WenQuanYi Zen HeiNoto Sans CJK SC。我一般把这个配置放在单独的config.py里,在chart_generator.py顶部导入,避免到处改。另外,axes.unicode_minus = False必须设置,否则坐标轴的负号是Unicode字符,在图表里也会显示成方块。

5.2 现象:前端传的参数字符串带引号,模型训练报错

前端textarea里用户常会输入{"max_depth": "3"}——注意值是字符串"3",不是数字3。scikit-learn的DecisionTreeClassifier接收max_depth="3"会直接TypeError。解决思路有两个:一是在前端解析后做类型转换,二是后端统一处理。我一般选择后端处理,因为前端不容易控制所有算法参数的类型。

# app.py: 参数类型智能转换 def coerce_params(raw_params): """把参数字符串值转成合适的Python类型""" coerced = {} for k, v in raw_params.items(): if isinstance(v, str): # 尝试转int try: coerced[k] = int(v) continue except ValueError: pass # 尝试转float try: coerced[k] = float(v) continue except ValueError: pass # 尝试转bool if v.lower() in ("true", "false"): coerced[k] = v.lower() == "true" continue coerced[k] = v # 保持字符串 else: coerced[k] = v return coerced

这个函数解决了一个实际问题:用户输入{"max_depth": "5"}时,系统自动转成int;输入{"C": "0.1"}时转成float"kernel": "rbf"保持字符串。没有这个转换,用户的任何一个手误都会导致500错误,而你在答辩现场排查这个Bug会很狼狈。

5.3 现象:同时5个人访问系统,SQLite报database is locked

SQLite的database is locked错误在做Web应用时很常见,原因是SQLite只允许一个进程同时写入,多个请求同时INSERT就会冲突。解决方法是开启WAL模式并设置繁忙超时。

-- schema.sql: 初始化时执行 PRAGMA journal_mode=WAL; PRAGMA busy_timeout=5000;

第一行启用Write-Ahead Logging,读写可以并行但同一时刻仍只能有一个写事务;第二行设置等待时间5秒,如果5秒内拿不到写锁就报错而不是立即失败。这两个PRAGMA要在每次数据库连接时执行,我在get_db()函数里加一行:

def get_db(): conn = sqlite3.connect(DB_PATH, timeout=5) conn.row_factory = sqlite3.Row conn.execute("PRAGMA journal_mode=WAL") conn.execute("PRAGMA busy_timeout=5000") return conn

timeout=5是连接时的超时参数,和busy_timeout作用相似但层级不同:连接级超时管的是连接池等待,PRAGMA管的是单个连接内的事务等待。期末项目并发量不会高,但老师演示时如果多开浏览器标签页,很可能触发这个错误,提前加上就能避免现场翻车。

5.4 现象:SVM训练Iris数据集,前端等了两分钟没反应

SVM默认的SVC如果不设置probability=True还好,设置了之后要额外计算Platt概率校准,在大数据集上非常慢。更本质的问题是:你在get_model里为SVM写死了probability=True,但random_forestn_estimators=100在Wine数据集上也可能要训练几秒。前端Fetch没有超时控制,用户会觉得“系统卡死了”。

后端解决方案是设置训练超时的概念:路由记录开始时间,如果超过30秒还没训练完就返还超时错误。但scikit-learn的fit是同步阻塞的,你没法直接“打断”它。实际的做法是前端加加载提示和超时处理。

// 前端: 训练超时处理 const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 30000); try { const response = await fetch('/api/train', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload), signal: controller.signal }); clearTimeout(timeoutId); // 处理响应 } catch (err) { if (err.name === 'AbortError') { alert('训练超时,请降低算法复杂度或选择较小的数据集'); } else { alert('请求失败: ' + err.message); } }

超时时间我建议30秒。KNN在500条样本上是毫秒级的,但SVM在多分类Wine数据集上可能3-5秒,加上图表渲染和Base64编码读取,整体在1-3秒内完成。如果你的机器太老导致确实跑不动,建议在PPT里写“算法训练时间为XXX秒”,用train_logs表里的实际数据,不要写理论值。

5.5 现象:日志表里有训练记录,但历史页面取不到参数

这是表关联设计常见的坑。我在save_train_log函数里把params存成JSON字符串放在train_logs.params字段,又拆了model_params表。如果只在model_params里存参数而train_logs.params是空的,前端从model_params取数据会非常麻烦。更简单的做法是:train_logs.params存完整的JSON字符串,历史页直接json.loads就行,model_params表用于“按参数名查询”的场景,两者双写。

# app.py: 保存训练日志 def save_train_log(dataset_name, algorithm, metrics, params): conn = get_db() cur = conn.cursor() # 查询dataset_id cur.execute("SELECT id FROM datasets WHERE name = ?", (dataset_name,)) dataset_row = cur.fetchone() dataset_id = dataset_row["id"] if dataset_row else 1 # 兜底 # 插入train_logs cur.execute(""" INSERT INTO train_logs (dataset_id, algorithm, accuracy, precision, recall, f1, train_seconds, params) VALUES (?, ?, ?, ?, ?, ?, ?, ?) """, (dataset_id, algorithm, metrics["accuracy"], metrics["precision"], metrics["recall"], metrics["f1"], metrics["train_seconds"], json.dumps(params, ensure_ascii=False))) log_id = cur.lastrowid # 双写model_params for k, v in params.items(): cur.execute("INSERT INTO model_params (log_id, param_name, param_value) VALUES (?, ?, ?)", (log_id, k, json.dumps(v))) conn.commit() conn.close()

这个双写方案的优点是冗余:如果params{"max_depth": 3}model_params表里就能直接查param_value = "3"。注意json.dumps(params, ensure_ascii=False),如果参数值包含中文,不设置ensure_ascii=False会变成\uXXXX转义序列,虽然不报错但可读性很差。

6. 进阶用一个小习惯把项目推到“演示完还想再看一次”

项目做到这,你已经有表结构、算法封装、图表生成和历史记录了。但这个系统在答辩演示时有一个用户体验问题:每次都要手动选算法和参数、点训练,才能看到结果。老师只会等几秒,超过就烦了。我的习惯做法是加一个“一键对比实验”的功能:前端一个按钮,后端自动跑所有算法在同一个数据集上的效果,输出一张包含准确率、F1、训练耗时的横向对比表,并把图表全部渲染出来。

实现很简单,不需要新表,复用已有的/api/train接口逻辑,加一个/api/benchmark路由:

# app.py: 一键跑完所有算法的对比实验 @app.route("/api/benchmark", methods=["POST"]) def benchmark(): """在指定数据集上训练所有内置算法,返回对比指标表和所有图表""" data = request.get_json() dataset_name = data.get("dataset", "iris") results = [] all_charts = {} for algo in ["decision_tree", "knn", "logistic_regression", "svm", "random_forest"]: try: X, y = load_dataset(dataset_name) model = get_model(algo, {}) X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.25, random_state=42, stratify=y ) start = time.time() model.fit(X_train, y_train) y_pred = model.predict(X_test) acc = accuracy_score(y_test, y_pred) train_sec = round(time.time() - start, 4) results.append({ "algorithm": algo, "accuracy": round(acc, 4), "f1": round(f1_score(y_test, y_pred, average="macro", zero_division=0), 4), "train_seconds": train_sec }) charts = generate_charts(model, X, y, algo) charts.update(generate_evaluation_charts(y_test, y_pred, model.predict_proba(X_test), dataset_name, algo)) all_charts[algo] = charts save_train_log(dataset_name, algo, {"accuracy": acc, "precision": 0, "recall": 0, "f1": 0, "train_seconds": train_sec}, {}) except Exception as e: # 单算法失败不能中断整体对比 results.append({"algorithm": algo, "accuracy": 0, "f1": 0, "train_seconds": 0, "error": str(e)}) return jsonify({"code": 0, "results": results, "all_charts": all_charts})

注意代码里我保留了save_train_log的调用,这样基准测试的结果也进了数据库。另一点是单算法失败的容错:一个算法在某个数据集上可能因为参数不兼容而报错(比如logistic_regression在特征过多时max_iter不足),不能因为一个失败就整个接口500,失败的那项也要返回,前端用灰色背景展示“失败”,老师会认为你考虑了边界情况。

前端把这个benchmark渲染成对比表格,再用不同颜色的<svg>柱状图展示准确率。这种“一张表把所有算法打平”的呈现方式,比一个算法一个算法点训练要专业得多。

答辩时我通常会先展示benchmark,再根据老师感兴趣的算法进入单个算法的手动参数调节页面。你可以在PPT里放一张benchmark的截图,配上一句“我这里做了5种常用算法在3个数据集上的对比,你可以看到KNN在Iris上准确率最高而SVM训练耗时最长”——这种表述比空谈“我用了很多算法”扎实得多。做项目的过程里我也有过一个教训:把精力花在反复调整参数追求准确率,却没有先做benchmark对比框架,导致后期补对比功能时改动了很多接口签名。先把对比框架搭好,再往里填算法,效率会高很多。希望这个规划方式帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询