☰
Python全栈式数据采集分析与可视化平台搭建实战
2026/10/20 11:43:43 网站建设 项目流程

简介:这是一套基于Python构建的全栈式数据采集分析与可视化平台源码包,涵盖网络爬虫抓取、多源异构数据存储、交互式前端界面以及深度学习手写数字识别模型,适合高校教学演示、毕业设计及个人实战开发。包内共2002个文件,压缩后约89.64MB,以Markdown文档和JavaScript文件为主,另含Python脚本、JSON配置、CSS样式及少量SQL、HTML和说明文档,分别对应数据说明、前端交互逻辑、爬虫与模型代码、界面样式及数据库结构等。平台技术栈覆盖Scrapy/BeautifulSoup、MySQL/MongoDB、React/Vue、ECharts、TensorFlow/PyTorch等主流工具,可帮助开发者快速理解数据应用全流程。目前已有73人学习浏览。资源附赠说明文档,目录结构清晰,便于按模块查阅,既能用于课堂讲解数据采集到可视化闭环,也可为二次开发和工程落地提供可复用基础。

1. 基于Python的全栈式数据采集分析与可视化平台:教学演示项目为什么值得认真搭

基于Python的全栈式数据采集分析与可视化平台,说白了就是把网络爬虫数据抓取、多源异构数据存储管理、交互式前端展示界面、深度学习手写数字识别模型这四块硬骨头,用一套代码编成一条完整的数据流水线。这类项目在课程设计、毕业设计和公司内训里出现频率极高,大家单拎出来都能写,难的是把五套技术串成能演示、能交差、能继续加功能的整体。

这个标题解决的是“五套技术各做各的”这个典型问题。爬虫抓完数据扔进 CSV,前端展示时再手工导一次,模型训练和网站完全脱节,演示的时候打开三个窗口来回切,观众看不出这是一个平台。把数据从网页一路流到浏览器画布上,才是这个标题真正的价值。

适合的人群很明确:要做课程设计或毕业设计的在校生、要给新人做全栈示范的团队导师、以及想快速搭一个数据采集分析 Demo 来验证想法的 Python 开发者。新手能照着把每一层跑通,熟手可以拿来当脚手架,替换自己的算法和数据源。

下面按我实际搭这类平台的顺序来写:先讲选型,再讲数据采集与存储,然后是模型和前端联调,最后是把五个模块踩过的坑一一拆开。保证你照着做完,不是只有截图能看,而是真能跑、能改、能现场演示的东西。

2. 技术栈选型与整体架构:五层选型理由和一条数据管道

2.1 五层技术选型与“为什么不选”

教学演示和实战开发场景里,选型的第一原则不是“哪个技术最潮”,而是“环境成本低、模块可替换、代码能讲清楚”。基于这个原则,这套平台我一般固定用下面这组组合。

技术层本方案选型选型理由不选主流替代方案的理由
爬虫抓取Scrapy内置并发调度、下载中间件、Item Pipeline,采集链路完整,教学讲起来清晰单用 Requests 要自己写重试、去重和管道,代码量翻倍
数据存储SQLite零配置、单文件数据库,整个库拷走就能跑演示,不怕现场没网MySQL/PostgreSQL 需要服务端,演示机器上一旦装不上就翻车
后端接口Flask路由直白、上手快,一个文件能讲清前后端交互FastAPI 的异步优势在本地演示场景体现不出来,反而增加理解成本
数据可视化PlotlyPython 端一行生成图表 JSON,前端直接渲染,交互式缩放拖拽免费自带纯 ECharts 需要写较多前端 JS,教学演示时容易把重点带到前端细节里
深度学习PyTorchtorchvision 内置 MNIST 数据集,动态图调试直观,导出 TorchScript 后 Flask 加载极简TensorFlow 版本接口变动大,新手装环境时容易被兼容性问题劝退

这五层之间通过结构化数据解耦。爬虫不知道前端长什么样,前端不关心模型内部结构,替换任何一层都不影响其他层。比如想把 SQLite 换成 MySQL,只需要改 WriteHub 的实现,Scrapy 的 Pipeline 和 Flask 的路由都不用动。

2.2 数据流设计与目录骨架

整个平台有两条数据链路。第一条是采集分析链路:Scrapy 多源抓取,Item Pipeline 清洗后交给统一存储层 WriteHub,写入 SQLite 和 CSV,Flask 从库里读取数据交给 Plotly 生成图表 JSON,前端渲染成交互面板。第二条是模型识别链路:PyTorch 训练手写数字识别模型,导出 TorchScript 文件,Flask 挂载模型提供识别接口,浏览器 Canvas 画板把手写图片转成 base64 传给后端,返回预测结果。

目录结构按模块划分,不按技术类型划分,这样每个模块都能独立演进。

project_root/ ├── app/ # 前端展示与接口层 │ ├── app.py # Flask 入口:页面路由与识别接口 │ ├── models/ # 训练好的模型文件 │ │ └── mnist_scripted.pt # TorchScript 导出格式,加载不依赖类定义 │ └── templates/ │ └── index.html # 交互式前端画板与图表页面 ├── spiders/ # Scrapy 爬虫目录 │ ├── multi_source_spider.py # 多源采集爬虫 │ └── pipelines.py # 清洗与入库管道 ├── storage/ │ └── write_hub.py # 统一存储层:SQLite / CSV / JSON 读写 ├── data/ # 运行时生成的数据目录 │ ├── demo.db # SQLite 主库 │ └── items.csv # CSV 导出副本,方便人工抽查 ├── train_mnist.py # 手写数字模型训练脚本 ├── requirements.txt └── run.sh # 一键启动脚本

Scrapy 自带的 scrapy.cfg、items.py 没有列进来,它们是框架自动生成的,不影响理解。核心思路是把业务逻辑放在 spiders、storage、app 这三层里,框架文件保持默认。

2.3 环境准备:先让空壳跑起来

动手写代码之前,先把虚拟环境锁好。依赖版本冲突是这个项目最常出问题的地方,后面避坑章节会专门讲,这里先养成好习惯。

python -m venv .venv source .venv/bin/activate # Windows 用 .venv\Scripts\activate pip install -r requirements.txt # 一次性安装全部依赖 python -c "import flask, scrapy, torch, torchvision, plotly; print('依赖 OK')"

requirements.txt 只需要写五个核心包:flask、scrapy、torch、torchvision、plotly。版本号我建议不要一开始就锁定,先把最新稳定版装通,跑完整个平台后再用pip freeze > requirements.lock把精确版本记录下来。PyTorch 在教学演示场景不需要 GPU 版,CPU 版就够跑 MNIST 训练和推理。

环境准备好之后,先不写任何业务代码,直接建好目录结构,确认 import 全部通过,再用python train_mnist.py这种空脚本占位,确保框架搭起来的工程能编译能导入。这一步能过滤掉绝大多数环境问题,后面每层开发都建立在“环境是好的”这个前提下。

3. 网络爬虫数据抓取与多源异构存储:Scrapy 多源采集到 WriteHub 统一读写层

3.1 Scrapy 工程化:item、spider、pipeline 一条线

爬虫层最常见的错误是只用 Requests 写脚本,抓完一个页面就结束。Scrapy 的价值在于把采集过程拆成可维护的组件,尤其是多源采集时,两个站点的解析逻辑互不干扰,清洗规则写在管道里统一下发。

# spiders/multi_source_spider.py import scrapy class MultiSourceSpider(scrapy.Spider): name = "multi_source" allowed_domains = ["quotes.toscrape.com", "books.toscrape.com"] start_urls = [ "https://quotes.toscrape.com/", # 名言站,列表页结构 "https://books.toscrape.com/", # 书籍站,结构完全不同 ] def parse(self, response): # 两个站点交给各自的解析函数,避免把不同结构揉在一个函数里 if "quotes" in response.url: yield from self.parse_quotes(response) else: yield from self.parse_books(response) # 统一的翻页处理,两个站点都用这个规则 next_page = response.css("li.next a::attr(href)").get() if next_page: yield response.follow(next_page, self.parse) def parse_quotes(self, response): for quote in response.css("div.quote"): yield { "source": "quotes", "title": quote.css("span.text::text").get(), "author": quote.css("small.author::text").get(), "content": quote.css("span.text::text").get(), "url": response.url, } def parse_books(self, response): for book in response.css("article.product_pod"): yield { "source": "books", "title": book.css("h3 a::attr(title)").get(), "author": book.css("p.price_color::text").get(), "content": book.css("p::text").get(), "url": response.url, }

注意 parse 方法用yield from把两种解析函数串起来,Scrapy 会把每个字典当成一个 Item 自动交给 Pipeline。这里故意把两个站点的字段统一成 source、title、author、content、url 五个键,多源异构的第一步就是在采集端做字段映射,而不是等入库后再处理。

# spiders/pipelines.py class NormalizePipeline: """清洗字段:去空白、补默认值,避免脏数据入库""" def process_item(self, item, spider): for key, value in item.items(): if value is None: item[key] = "" # 缺失字段给空串,SQLite 入库不抛错 elif isinstance(value, str): item[key] = value.strip().replace("\n", " ") return item

Pipeline 是 Scrapy 里最容易被忽略的组件。很多人喜欢在 spider 里顺手做字符串处理,但多源场景下每个站点的脏数据规则不一样,集中到 Pipeline 里才能统一治理。这个 NormalizePipeline 只做最基础的清洗,你可以在后面追加去重 Pipeline 或关键词过滤 Pipeline。

# settings.py 关键参数 BOT_NAME = "demo_platform" ROBOTSTXT_OBEY = True # 教学演示务必遵守 robots.txt,避免对目标站造成压力 DOWNLOAD_DELAY = 1.0 # 多站共用下载器,演示场景稳定性优先 CONCURRENT_REQUESTS = 4 # 并发调小,防止被目标站封 IP FEED_EXPORT_ENCODING = "utf-8" # 导出 CSV/JSON 时强制 UTF-8,中文不乱码 RETRY_TIMES = 2 # 网络抖动时自动重试两次

DOWNLOAD_DELAY 和 CONCURRENT_REQUESTS 是一对需要平衡的参数。演示场景我一般把延时设在 1 到 2 秒,并发控制在 4 到 8,这样抓几百条数据不会太慢,也不容易触发目标站的反爬机制。实战场景想提速,优先加代理中间件而不是盲目调高并发。

3.2 多源异构存储:WriteHub 统一读写层实现

多源异构的“异构”主要体现在三个层面:字段名不同、字段数量不同、字段类型不同。有的站有作者字段,有的站没有;有的 content 是字符串,有的嵌套着标签。统一存储层的职责就是把它们全部收拢成一张宽表。

# storage/write_hub.py import sqlite3, json, csv class WriteHub: """统一写入层:把不同源的 dict 数据落到 sqlite/csv/json""" def __init__(self, db_path="data/demo.db"): self.db_path = db_path self._init_db() def _init_db(self): conn = sqlite3.connect(self.db_path) conn.execute(""" CREATE TABLE IF NOT EXISTS items ( id INTEGER PRIMARY KEY AUTOINCREMENT, source TEXT NOT NULL, title TEXT, author TEXT, content TEXT, url TEXT, extra TEXT, -- 不固定字段统一塞进 JSON created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) """) conn.commit() conn.close() def to_sqlite(self, item): conn = sqlite3.connect(self.db_path) # extra 字段用 JSON 兜底,后续加新字段不用改表结构 common_keys = {"source", "title", "author", "content", "url"} extra = {k: v for k, v in item.items() if k not in common_keys} conn.execute( "INSERT INTO items (source, title, author, content, url, extra) " "VALUES (?, ?, ?, ?, ?, ?)", (item.get("source", ""), item.get("title", ""), item.get("author", ""), item.get("content", ""), item.get("url", ""), json.dumps(extra, ensure_ascii=False)) ) conn.commit() conn.close() def to_csv(self, item, path="data/items.csv"): # CSV 用于人工抽查,字段固定为统一列 fieldnames = ["source", "title", "author", "content", "url"] with open(path, "a", newline="", encoding="utf-8") as f: writer = csv.DictWriter(f, fieldnames=fieldnames) if f.tell() == 0: writer.writeheader() writer.writerow({k: item.get(k, "") for k in fieldnames})

WriteHub 的设计要点是 extra 字段。很多教学项目会不断往表里加列,加到后面表结构膨胀,前端查数据还要写一堆条件判断。把不稳定字段塞进 JSON 后,表结构稳定了,查询也不受影响。需要按不固定字段筛选时,SQLite 支持json_extract(extra, '$.某个字段'),照样能查。

Scrapy 的 Pipeline 里调用 WriteHub 很简单,在 spider 的引用里传一个全局实例,或者直接在 pipeline 的open_spider里初始化连接。注意连接不要每次请求都新建,量大的时候 SQLite 会频繁锁库。

3.3 运行与验证:怎么确认采集和入库都成功

运行爬虫的命令很基础,但我每次都会强调:跑完之后看的是入库数量,不是爬虫日志。

scrapy crawl multi_source -O data/items.json python -c " from storage.write_hub import WriteHub wh = WriteHub() rows = wh.query_count() # 需要自己在 WriteHub 里加一个 count 方法 print(f'SQLite 共入库 {rows} 条') "

WriteHub 里可以补一个极简的查询方法:def query_count(self): conn = sqlite3.connect(self.db_path); return conn.execute("SELECT COUNT(*) FROM items").fetchone()[0]。验证时关注三个数字:Scrapy 日志里的item_scraped_count、SQLite 的总行数、CSV 文件的行数。三者应该一致,不一致说明有数据在某个环节丢了。

翻车高发点是编码。爬虫抓回来的是 bytes,如果站点没声明 charset,Scrapy 默认按 ISO-8859-1 解码,中文直接变乱码。遇到这种情况,在 spider 的start_requests里给 Response 指定response.encoding = "utf-8",或者直接在 settings 里设DEFAULT_REQUEST_HEADERS带Accept-Charset头。判断有没有解对,直接看库里的 title 字段,而不是看终端打印。

4. 手写数字识别模型与交互式前端展示:PyTorch 训练到浏览器画板识别

4.1 训练与导出:为什么手写数字识别选 CNN 而不是全连接

手写数字识别是深度学习教学里最经典的案例,MNIST 数据集 28x28 像素,全连接网络也能跑到 94% 左右,但 CNN 能到 99%。差距来自卷积的局部感受野和参数共享:数字的笔画特征(横、竖、弧线)在哪个位置都成立,卷积把这种平移不变性直接编码进网络结构里。

# train_mnist.py import torch import torch.nn as nn from torchvision import datasets, transforms class DigitNet(nn.Module): def __init__(self): super().__init__() # 输入 1x28x28,两层卷积池化后变成 64x7x7 self.features = nn.Sequential( nn.Conv2d(1, 32, 3, padding=1), # -> 32x28x28 nn.ReLU(), nn.MaxPool2d(2), # -> 32x14x14 nn.Conv2d(32, 64, 3, padding=1), # -> 64x14x14 nn.ReLU(), nn.MaxPool2d(2), # -> 64x7x7 ) self.classifier = nn.Sequential( nn.Flatten(), nn.Linear(64 * 7 * 7, 128), nn.ReLU(), nn.Dropout(0.25), # 教学演示也值得加,防过拟合 nn.Linear(128, 10), ) def forward(self, x): return self.classifier(self.features(x)) def main(): transform = transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)), # MNIST 官方均值和标准差 ]) train_data = datasets.MNIST("data/mnist", train=True, download=True, transform=transform) train_loader = torch.utils.data.DataLoader(train_data, batch_size=64, shuffle=True) model = DigitNet() optimizer = torch.optim.Adam(model.parameters(), lr=0.001) criterion = nn.CrossEntropyLoss() for epoch in range(3): # CPU 演示 3 轮足够,几分钟跑完 model.train() for images, labels in train_loader: optimizer.zero_grad() loss = criterion(model(images), labels) loss.backward() optimizer.step() print(f"epoch {epoch + 1} loss={loss.item():.4f}") scripted = torch.jit.script(model) scripted.save("app/models/mnist_scripted.pt")

参数选择上,batch_size 64、学习率 0.001 的 Adam、3 个 epoch,是 CPU 教学场景的甜点区。调大 batch_size 到 128 能跑更快,但梯度更新次数变少,3 轮学不充分,准确率会掉 0.5 到 1 个百分点。epoch 加到 5 准确率还能提升,但演示现场等待时间太长就没必要了。

导出用torch.jit.script而不是直接torch.save(model.state_dict()),这是我从跑通到演示踩过坑之后固定下来的做法。state_dict 加载时需要重新实例化模型类并手动 load,脚本文件里类定义一改动就报错;TorchScript 把模型结构和权重打包成一个文件,Flask 端拿到文件直接加载,不需要 import 训练脚本里的任何东西。

4.2 Flask 挂载与 Plotly 交互面板:模型变成能点的页面

模型训练完,下一步是让浏览器里画一个数字,后端识别并返回结果。这里用 Flask 提供两个接口:一个返回采集数据的 Plotly 图表 JSON,一个接收手写图片 base64 返回预测结果。

# app/app.py import os, base64, io, json import torch from flask import Flask, render_template, request, jsonify from PIL import Image import plotly.graph_objects as go app = Flask(__name__) _model = None MODEL_PATH = os.path.join(os.path.dirname(__file__), "models", "mnist_scripted.pt") def get_model(): global _model if _model is None: _model = torch.jit.load(MODEL_PATH, map_location="cpu") _model.eval() # 固定 dropout 和 batchnorm 行为 return _model @app.route("/") def index(): return render_template("index.html") @app.route("/api/recognize", methods=["POST"]) def recognize(): data = request.get_json() image_b64 = data.get("image", "") if not image_b64: return jsonify({"error": "empty image"}), 400 # 前端传的是白底黑字的 PNG,转换成 MNIST 训练样本的黑底白字 img = Image.open(io.BytesIO(base64.b64decode(image_b64))).convert("L") img = img.resize((28, 28), Image.Resampling.LANCZOS) tensor = torch.tensor(list(img.getdata()), dtype=torch.float32).view(1, 1, 28, 28) / 255.0 tensor = 1.0 - tensor with torch.inference_mode(): logits = get_model()(tensor) pred = int(logits.argmax(dim=1).item()) prob = float(torch.softmax(logits, dim=1).max().item()) return jsonify({"prediction": pred, "confidence": round(prob, 4)}) @app.route("/api/chart") def chart(): # 从 WriteHub 读取数据,Plotly 生成图表 JSON,前端直接渲染 import sqlite3 conn = sqlite3.connect(os.path.join(os.path.dirname(__file__), "..", "data", "demo.db")) rows = conn.execute("SELECT source, COUNT(*) FROM items GROUP BY source").fetchall() conn.close() fig = go.Figure(data=[go.Bar(x=[r[0] for r in rows], y=[r[1] for r in rows])]) return jsonify(json.loads(fig.to_json()))

代码里有个细节:get_model()用全局变量缓存模型对象。Flask 默认多线程处理请求,如果每次请求都torch.jit.load,磁盘 IO 和模型初始化耗时会拖垮接口,并发一上来甚至可能内存暴涨。全局变量加载一次后,所有线程共享推理,CPU 下单个请求稳定在 20 到 50 毫秒。

前端页面核心是一个 Canvas 画板加一小段图表渲染逻辑。画板的处理是整个识别率的关键,不能直接把 280x280 的画板缩到 28x28 就提交,必须做包围盒裁剪和居中。

// templates/index.html 内部 script function canvasToDigitData() { const src = document.getElementById("board"); const ctx = src.getContext("2d"); const imageData = ctx.getImageData(0, 0, 280, 280); // 求手写笔迹的包围盒,背景为白色,笔迹为深色 let minX = 280, maxX = 0, minY = 280, maxY = 0; for (let y = 0; y < 280; y++) { for (let x = 0; x < 280; x++) { const gray = imageData.data[(y * 280 + x) * 4]; if (gray < 128) { if (x < minX) minX = x; if (x > maxX) maxX = x; if (y < minY) minY = y; if (y > maxY) maxY = y; } } } if (maxX < minX) return ""; // 空画板直接返回,不发请求 // 裁剪笔迹区域,等比缩放后居中贴到 28x28 画布 const srcCanvas = document.createElement("canvas"); const w = maxX - minX + 1, h = maxY - minY + 1; srcCanvas.width = w; srcCanvas.height = h; srcCanvas.getContext("2d").drawImage(src, minX, minY, w, h, 0, 0, w, h); const dstCanvas = document.createElement("canvas"); dstCanvas.width = 28; dstCanvas.height = 28; const dstCtx = dstCanvas.getContext("2d"); dstCtx.fillStyle = "#ffffff"; dstCtx.fillRect(0, 0, 28, 28); const scale = 20 / Math.max(w, h); dstCtx.drawImage(srcCanvas, (28 - w * scale) / 2, (28 - h * scale) / 2, w * scale, h * scale); return dstCanvas.toDataURL("image/png").split(",")[1]; }

这段 JS 做了三件事:找笔迹边界、等比缩放、居中贴到 28x28。MNIST 的训练样本本身就是“数字大致在中间、占画布约八成”的分布,前端预处理越接近这个分布,识别率越高。这也是在没有做任何数据增强时,让识别率从惨不忍睹到像样的最关键一步。

4.3 联调验证:画一个数字看整条链路

启动命令和验证流程都不复杂,但顺序有讲究。

cd project_root python train_mnist.py # 训练并导出模型 python app/app.py # 启动 Flask,默认端口 5000

浏览器打开http://localhost:5000,会看到两个区域:上半部分是采集数据的柱状图,下半部分是手写画板。先画一个 7,点识别,预测结果和置信度会显示在画板旁边。然后清空,再画一个 3,检查置信度是否正常波动。

这一步如果识别失败,先别怀疑模型,用浏览器开发者工具看 Network 面板里/api/recognize的请求和响应。常见情况是 base64 数据没传全、画板在白色背景上画白色笔迹、或者 Canvas 转出来的是空图。模型本身在 MNIST 测试集上有 99% 准确率,前端问题占九成。

5. 避坑与排查:数据管道、模型服务与前端联调的 5 个真实踩坑

5.1 入库全是乱码:SQLite 里查出来是“锟斤拷”

现象:爬虫抓取时终端打印中文正常,进 SQLite 后查出来全是锟斤拷这类乱码,CSV 导出更是没法看。

原因:这个坑通常不在 SQLite,而在爬虫解码阶段。目标站返回的 HTTP 头没有声明 charset,或者声明了ISO-8859-1,Scrapy 按该编码解码,中文 bytes 被错误解析。另一种情况是json.dumps写文件时没有加ensure_ascii=False,中文被转成\uXXXX转义序列,写入数据库后看起来像乱码。

解决:在 spider 里显式声明编码。Scrapy 的 Response 对象有encoding属性,直接设置为utf-8。WriteHub 的to_sqlite里存 JSON 时统一加ensure_ascii=False。最关键的是验证手段:入库后立刻用SELECT title FROM items LIMIT 5抽查,不要只在终端看日志。

# 在 spider 的 parse 开头加一行 response.encoding = "utf-8" # 强制按 UTF-8 解码,覆盖 HTTP 头的错误声明

5.2 多源字段漂移:有的站有作者,有的站没有,入库直接报错

现象:单跑名言站一切正常,加了书籍站之后 Pipeline 报KeyError或 SQLite 插入失败,提示列数不匹配。

原因:两个站的字段天然不一致,名言站有 author,书籍站只有价格,spider 里某个字段取不到None,而 Pipeline 或 SQLite 插入语句写死了字段列表。这就是多源异构最典型的“schema 漂移”。

解决:在采集端统一映射五个核心字段,用.get()加默认值兜底;WriteHub 的建表语句里加extra字段接收所有非核心字段。这条原则要贯彻到每一层,前端展示时也只读取核心字段,需要看扩展字段再从 extra 里取。

# NormalizePipeline 里加一段兜底逻辑 required = ["source", "title", "author", "content", "url"] for field in required: item.setdefault(field, "") # 缺失字段统一给空串,不抛异常

5.3 模型接口一并发就崩:单测好好的,连续画两个数字就 500

现象:本地用测试脚本调/api/recognize正常,浏览器里画完一个数字识别成功,紧接着画第二个就报 500,刷新后偶尔恢复。

原因:Flask 的 dev server 默认支持多线程,而你的代码里如果在每个请求里都执行torch.jit.load,频繁加载模型会导致资源竞争和内存碎片。更隐蔽的是,有的新手把模型加载写在了请求函数内部,每次推理都重新创建计算图。

解决:模型加载放到全局变量里,进程启动时加载一次,之后所有请求复用。推理外面套torch.inference_mode(),不追踪梯度,内存占用和耗时都会降下来。

# 不要在每个请求里 load 模型 def recognize(): model = torch.jit.load(MODEL_PATH) # 错误写法:高并发下必崩 ...

5.4 Canvas 手写体识别率不足 50%:测试集 99%,浏览器写个 7 识别成 2

现象:MNIST 测试集准确率 99%,前端画板随便写一个数字,识别率不到一半,越想写端正越不对。

原因:模型学的是 28x28 黑底白字、数字居中占 70% 面积、笔画粗细均匀的 MNIST 风格。浏览器画板是白底黑字、笔画可能偏细、数字可能偏左偏小,直接缩放提交等于给模型看了一类完全没训练过的样本。

解决:前端必须做完整预处理。按 4.2 节的方式先求包围盒、裁剪、等比缩放、居中贴画布,后端再做一次反色。笔画粗细也很关键,如果画板笔迹太细,建议把lineWidth从 2 调到 5 到 8,让笔迹宽度接近 MNIST 训练样本的笔画比例。

# 后端反色一行搞定 tensor = 1.0 - tensor # 白底黑字 -> 黑底白字

5.5 依赖版本冲突:装完 Scrapy 后 PyTorch import 报错

现象:先装了 PyTorch 再装 Scrapy,装完以后import torch报illegal instruction或numpy相关错误,Flask 也起不来。

原因:pip 在安装新包时会自动升级依赖,Scrapy 的依赖 lxml、cryptography 等会拉高 numpy 版本,而 PyTorch 内置的 numpy 二进制接口不兼容新版本。Python 3.12 上 Scrapy 的某些依赖还会触发编译错误,环境直接报废。

解决:所有依赖用同一个 requirements.txt 一次装完,不用 pip 单独补装。PyTorch 安装显式指定 CPU 源。装完后用一条 import 命令全部验证,再跑训练脚本和爬虫脚本。如果已经翻车,最快的后悔药是删掉虚拟环境重建,不要试图手动修。

pip install torch torchvision --index-url https://download.pytorch.org/whl/cpu python -c "import flask, scrapy, torch, torchvision, plotly; print('ok')"

6. 演示现场不翻车的三个习惯:自检脚本、数据快照与启动顺序

教学演示和实战项目最大的区别是:你只有一次展示机会,没有时间在台上装环境。我的习惯是写一个自检脚本,把所有模块的状态一次性检查完,再决定要不要上场。

# check_platform.py import os, sqlite3 errors = [] # 1. 检查数据库和采集数据 db_path = "data/demo.db" if not os.path.exists(db_path): errors.append("数据库不存在,先跑爬虫采集") else: count = sqlite3.connect(db_path).execute("SELECT COUNT(*) FROM items").fetchone()[0] if count < 100: errors.append(f"数据量偏少:{count} 条,演示可能不够丰满") # 2. 检查模型文件 if not os.path.exists("app/models/mnist_scripted.pt"): errors.append("模型文件不存在,先跑 train_mnist.py") else: import torch torch.jit.load("app/models/mnist_scripted.pt", map_location="cpu") # 3. 检查端口占用 import socket s = socket.socket() try: s.bind(("127.0.0.1", 5000)) except OSError: errors.append("5000 端口被占用,关掉其他服务再启动") if errors: print("自检未通过:") for e in errors: print(" -", e) else: print("自检全部通过,可以放心演示")

自检脚本的价值不只是演示前跑一遍,它本身就是项目质量的一部分。团队接手时跑一次自检就知道环境全不全,比读 README 快得多。

第二个习惯是数据快照。采集环节抓完一批数据后,把demo.db复制一份存为data/demo_snapshot.db,演示前用快照覆盖工作库。这样现场网络断了、目标站改版了、爬虫被封了,都不影响你展示前端和模型。爬虫在演示中只是锦上添花,不要让它成为风险的唯一来源。

第三个习惯是固定启动顺序。先跑自检,再跑训练脚本检查模型存在性,最后启动 Flask。我在启动脚本里加了一句“模型缺失才训练”,避免每次启动都等训练。

#!/bin/bash set -e python check_platform.py [ -f app/models/mnist_scripted.pt ] || python train_mnist.py cd app && flask run --host 0.0.0.0 --port 5000

演示现场的排查顺序也要固定:先看浏览器 Network 面板确认请求有没有发出去,再看 Flask 日志确认路由有没有被命中,最后看数据库有没有数据。我经历过一次模型文件忘拷到演示机器、现场发现画板识别一直报错的情况,就是因为没跑自检脚本,直接启动 Flask 导致。从那以后,自检成了我交付任何平台类项目的第一道工序。

这套全栈式数据采集分析与可视化平台,每一层都是可以独立替换的模块。爬虫换数据源,存储换数据库,模型换更深的网络,前端换 ECharts,都不影响其他层工作。把它当成脚手架,后面想加文本分类、情感分析、数据大屏,都在这个骨架上长肉。希望帮到你。

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

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

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

立即咨询