简介:本资源是一套面向本科毕业设计与课程综合实践的医疗健康领域数据分析可视化系统,聚焦疾病数据挖掘与大屏展示全流程,适用于计算机、医学信息工程等专业学生开展项目实战与算法应用学习。系统采用Flask构建后端API服务,Vue实现前端响应式大屏,集成requests网络爬虫自动采集疾病相关公开数据,并基于随机森林算法完成疾病风险预测建模与特征重要性分析。压缩包共67个文件,含7个核心Python脚本(含爬虫、模型训练与接口逻辑)、7个Vue组件文件、7个JavaScript交互模块、5个JSON配置与数据文件、4个HTML页面及配套SQL数据库脚本、Word论文文档、部署调试说明与3张运行效果截图,整体仅1.32MB,结构清晰、开箱即用。目前已有43人学习下载,提供从数据获取、清洗、建模到可视化落地的完整闭环方案,特别适合需要快速验证机器学习与前后端联调能力的学习者。 这年头的数据可视化项目,单拿一个框架出来已经不太能打了。真正能让人眼前一亮的,往往是那种“爬虫把数据喂进来、算法把价值算出来、前端把结果摆上桌”的完整链路。这套基于Python Flask + Vue + 随机森林 + requests搭建的医疗疾病数据分析大屏系统,就是把这几块硬生生串成了一条线。很多人看到“医疗疾病”四个字觉得门槛高,其实拆开看,核心就三件事:数据从哪来、预测怎么做、大屏怎么画。这篇文章我就按实际落地的顺序,把选型逻辑、爬虫阶段的限流排坑、随机森林从训练到接口、前后端对接的隐藏问题全部摊开讲,适合正在做数据类项目作品、毕业设计,或者想体验一次全栈加机器学习全流程的开发者参考。
1. 选型逻辑:这套系统为什么锁定Flask+Vue+随机森林
1.1 后端轻量化选型:Python生态是出发点
先说一个总原则:当一个项目里既有数据采集、又有机器学习、又有Web服务时,语言统一带来的收益远超所有框架层面的微优化。爬虫用requests、数据处理用pandas、建模用scikit-learn,这些全是Python生态的东西。如果后端再选Python,整条数据管道可以用一套语言推理下来,不用在语言切换中反复维护上下文。
框架层面,Flask、Django、FastAPI是这个生态里最常被比较的三个。从落地感受来看:
| 框架 | 上手成本 | 适合场景 | 实际体验 |
|---|---|---|---|
| Flask | 低 | 轻量API、中小型数据服务、个人项目 | 灵活不强制,路由和蓝图结构清晰,快速出活 |
| Django | 中高 | 大型业务系统、后台管理复杂、需要内置Admin | 自带ORM/认证/Admin,但项目启动重,对大屏项目来说大量能力用不上 |
| FastAPI | 中 | 高并发API、需要自动生成OpenAPI文档 | 性能和文档优秀,但周边插件生态没有Flask老牌,学习曲线略陡 |
我在这套系统里选Flask,不是因为它比FastAPI或Django更“先进”,而是因为它在一个轻量级数据服务里恰好把灵活性和成熟度平衡得最好。数据大屏项目的特点是:接口数量不多,但每个接口背后都要做数据聚合、跨表查询、模型预测调用。Flask的蓝图机制可以按业务模块拆路由,SQLAlchemy可以方便地对接数据库,全部搞下来不会觉得自己在写一堆无意义的模板代码。
1.2 前端为什么不用React也不用纯HTML
大屏可视化最核心的痛点是:图表多、状态多、实时刷新需求多。用纯HTML加jQuery去写,一个页面里几十个图表实例的状态管理会迅速失控——你很难保证某个图表数据更新后,另一个关联卡片也跟着变化。这时候需要一个能响应式绑定数据的框架。
选择Vue而不是React,最重要的原因是渐进式。Vue的模板语法非常接近原生HTML,对于大屏这种以展示为主、交互逻辑相对固定的页面,学习和产出速度都很快。一个Vue组件里,模板负责结构、script负责数据逻辑、style负责样式,三个区域分得明明白白。对个人开发者来说,Vue的单文件组件模式比React“无模板、一切皆函数”的思路更容易在可视化项目中保持结构清晰。
另外一个实用因素是ECharts的配合度。大屏项目里ECharts几乎绕不开,而Vue项目里封装ECharts图表组件非常顺手:父组件通过props传入数据,子组件里watch数据变化再调用实例方法重绘。这套模式我在这篇文章后面会给出具体代码,它比在React useEffect里手动管理ECharts实例的生命周期要直观不少。
1.3 随机森林在医疗表格数据上的天然优势
医疗疾病数据通常是表格型数据:行列结构清晰,特征维度不高,样本量不算大。对这种数据结构,树模型天然比深度学习吃香。
选择随机森林,主要是这么几层考虑:
- 能捕捉非线性关系和特征交互。发病率不是简单的线性叠加,它和地区、年份、疾病类型、人口结构之间都存在复杂的交互影响。决策树本身就擅长按特征逐步切分空间,随机森林把多棵树集成起来,相当于在多个特征子空间中寻找稳定规律。
- 对数据缩放不敏感。神经网络和不少线性模型需要做标准化或归一化,树模型完全不用,特征是几百还是几千,都不影响分裂点的选择。这大大减少了机器学习流程里最容易出错的一个环节。
- 能给出特征重要性。医疗项目里,回答“到底什么因素对发病率影响最大”和回答“明年的发病率是多少”同样重要。随机森林的feature_importances_可以直接输出各特征的重要性分数,对后续的数据解释很有价值。
- 训练成本低,不用GPU。医疗数据往往是中小规模数据集,随机森林在普通CPU机器上几分钟内就能完成训练验证,对个人项目来说非常友好。
当然,随机森林不是万能的。如果数据量非常大、特征维度很高,或者要做细粒度时序预测,LightGBM和XGBoost通常表现更好。但作为这套系统里的基线模型,随机森林在稳定性和可解释性之间做到了最稳的平衡,这也是我最后定格为随机森林的原因。
1.4 整体链路:从爬虫到屏幕的技术栈分工
整个系统的运行链路可以用一句话概括:requests把公开数据抓下来,pandas清洗入库,scikit-learn训练随机森林模型,Flask把库存数据和模型预测结果包装成API,Vue前端通过axios请求API,最后用ECharts渲染到大屏上。各层职责非常清晰:
| 分层 | 技术 | 核心职责 |
|---|---|---|
| 数据采集 | requests + pandas | 请求目标站点、解析结构化字段、清洗缺失值 |
| 数据存储 | SQLite / MySQL | 持久化原始数据与特征数据,提供聚合查询 |
| 算法预测 | scikit-learn 随机森林 | 基于历史特征预测发病率/发病数,输出特征重要性 |
| 后端服务 | Flask + SQLAlchemy | 提供统计聚合接口、模型预测接口、统一响应格式 |
| 前端展示 | Vue + ECharts | 大屏布局、图表渲染、定时轮询、动态联动 |
这样分层之后,每个环节都可以独立替换。比如不想用随机森林了,训练一个XGBoost模型替换掉joblib文件,后端接口不用改;不想用ECharts了,换Chart.js同样可以对接。数据大屏项目最怕的就是多层耦合,按这条链路去解耦,后续扩展的想象空间会大很多。
2. 数据从哪来:requests爬虫的采集策略与429限流完整排坑
2.1 数据源评估与目标字段设计
医疗疾病数据的获取一定要先想清楚“能合法拿到什么”。我在这套系统里选择的是公开的公共卫生统计网站发布的年度疾病监测数据,这类数据通常以表格或JSON接口的形式公开,不需要登录,没有隐私字段,适合用于学习研究。
明确数据源后,下一步就是字段设计,这一步决定了后续建模和可视化的上限。我针对年度疾病监测场景设计了以下核心字段:
| 字段名 | 类型 | 说明 |
|---|---|---|
| report_year | INTEGER | 统计年份 |
| disease_name | TEXT | 疾病名称 |
| province | TEXT | 地区 |
| cases | INTEGER | 发病数/报告例数 |
| deaths | INTEGER | 死亡数 |
| incidence_rate | FLOAT | 发病率(每10万人口) |
| mortality_rate | FLOAT | 死亡率(每10万人口) |
| age_group | TEXT | 年龄组 |
| sex | TEXT | 性别 |
在库表结构上,使用如下DDL会清晰很多:
CREATE TABLE disease_stats ( id INTEGER PRIMARY KEY AUTOINCREMENT, report_year INTEGER NOT NULL, disease_name TEXT NOT NULL, province TEXT NOT NULL, cases INTEGER DEFAULT 0, deaths INTEGER DEFAULT 0, incidence_rate REAL DEFAULT 0.0, mortality_rate REAL DEFAULT 0.0, age_group TEXT, sex TEXT, UNIQUE(report_year, disease_name, province, age_group, sex) );注意最后的UNIQUE约束,这个非常关键。爬虫重复执行时很容易产生重复数据,加上唯一约束之后,后续用INSERT OR IGNORE或ON CONFLICT DO UPDATE处理就非常轻松,不用每次全表去重。
2.2 requests采集主流程与基础代码
目标站点一般有两种数据形态:要么是表格页面,要么是JSON接口。表格页面用requests获取HTML文本后,可以用pandas.read_html快速抽取表格;JSON接口则直接解析响应内容。大部分公共卫生数据发布平台都提供结构化接口,所以优先考虑直接解析JSON。
一个比较稳妥的基础采集逻辑大概是这样的:
import requests import pandas as pd import sqlite3 import random import time HEADERS = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Accept": "application/json, text/plain, */*", "Referer": "https://example-public-health-site.org/" } session = requests.Session() session.headers.update(HEADERS) def fetch_data(url, params): for attempt in range(1, 6): try: resp = session.get(url, params=params, timeout=10) if resp.status_code == 200: return resp.json() elif resp.status_code == 429: wait_time = int(resp.headers.get("Retry-After", 10)) + random.uniform(0, 3) print(f"触发限流,等待 {wait_time:.1f}s 后重试") time.sleep(wait_time) else: print(f"请求失败: {resp.status_code}") except requests.exceptions.RequestException as e: print(f"网络异常: {e}, 第 {attempt} 次重试") time.sleep(2 ** attempt) return None这里有几个容易被忽略的细节:
- 必须构建Session,而不是每次直接requests.get。Session会复用底层的TCP连接,对目标服务器更友好,也能维持必要的会话状态。
- 超时参数不能省。requests.get如果不加timeout,在网络异常时可能一直挂住,整个爬虫脚本就卡死了。
- 重试要有退避策略。第1次失败等2秒,第2次失败等4秒,指数增长,避免在服务端已经限流时继续高频冲击。
拿到原始JSON后,通过pandas进行字段过滤和数据清洗,然后写入数据库:
def parse_and_save(records, db_path="health_data.db"): df = pd.DataFrame(records) df = df.rename(columns={ "year": "report_year", "disease": "disease_name", "area": "province" }) # 只保留需要的列,防止脏字段 columns = ["report_year", "disease_name", "province", "cases", "deaths", "incidence_rate", "mortality_rate", "age_group", "sex"] df = df[[c for c in columns if c in df.columns]] df = df.dropna(subset=["report_year", "disease_name", "province", "cases"]) conn = sqlite3.connect(db_path) df.to_sql("disease_stats", conn, if_exists="append", index=False) conn.close() print(f"本次写入 {len(df)} 条记录")这里要注意pandas.to_sql默认不会做去重,所以前面建的UNIQUE索引在这里就开始起作用了。如果表结构里没有唯一约束,重复运行脚本会导致数据显示翻倍,后面还得返工。
2.3 429限流的完整排查链路
标题里出现了“429 too many requests”,这个坑在爬虫阶段几乎一定会遇到。我说一下我自己经历过的完整排查过程,直接给结论没有意义,把思路写清楚以后遇到同类问题才好举一反三。
现象:脚本连续爬到几百条记录之后,突然抛错,日志里出现类似“exceeded retry limit, last status: 429 too many requests”的信息,脚本反复重试仍然无济于事,甚至手动用浏览器访问同一个URL,有时也会看到“访问过于频繁”的提示。
第一步:确认是本机网络问题还是目标站限流。先用curl单独请求目标接口,看是否稳定返回200。如果curl稳定,说明是脚本触发了限流;如果curl也报429,说明目标站整体在限流,此时只能等待。
第二步:看响应头的限流元数据。很多限流接口会在响应头里反馈剩余配额:
Retry-After: 告诉你要等多少秒再试X-RateLimit-Limit: 单位时间窗口内允许的最大请求数X-RateLimit-Remaining: 当前剩余的请求额度
curl -I "https://example-public-health-site.org/api/data?year=2024"观察返回头,如果X-RateLimit-Remaining快速下降,说明限流策略是“固定窗口计数”,你的请求频率太高了。
第三步:确认限流维度。大多数站点的限流是按IP维度做,但也有的按User-Agent做。最简单的测试方法:把脚本里的User-Agent换成浏览器常用UA,如果限流现象立刻消失,说明服务端过滤的是UA;如果换UA仍然429,说明锁定的是IP维度。
第四步:检查自己的请求时间间隔分布。很多人写爬虫时虽然加了time.sleep,但写的是固定间隔,比如sleep(1)。这其实是一个很危险的写法:固定间隔会让请求呈现出明显的周期规律,服务端的限流算法很容易识别这种模式并返回429。正确做法是使用随机间隔。
我在这个项目里最终把请求策略改成了这样:
def polite_request(url, params=None, max_retries=4): interval = random.uniform(2.5, 5.0) time.sleep(interval) for attempt in range(max_retries): resp = session.get(url, params=params, timeout=10) if resp.status_code == 200: return resp.json() if resp.status_code == 429: retry_after = int(resp.headers.get("Retry-After", 15)) wait_time = retry_after + random.uniform(0, 5) print(f"429限流: 计划等待 {wait_time:.1f}s") time.sleep(wait_time) continue return None核心变化是两点:引入随机延迟,同时在收到429之后优先尊重服务端给出的Retry-After。
还有一个排查过程中的重要发现:有时候看到429,根本原因是某个请求的某个参数导致目标后端触发了WAF规则,而不是整体限流。这种情况的特点是:其他URL都正常,只有特定URL反复429。排查时可以打印出触发429的完整URL和参数,单独拿出来测试,如果不带某些参数就正常,那大概率是WAF对这组参数组合有拦截规则,需要调整传参方式或接入源更干净的数据。
最后,针对429的根治思路其实是别硬爬。很多公共卫生数据平台同时提供“数据导出”或“开放API”,优先使用官方渠道比写任何爬虫都稳。requests爬虫适合作为补充手段,而不是唯一数据来源。我在这套系统里做了双通道:优先尝试获取平台导出的CSV文件,缺失部分才用爬虫补抓。
2.4 医疗类数据采集的合规边界
做医疗相关项目,合规问题值得多花两分钟思考。我的原则是:只采集公开可访问的数据,不做逆向、不破解、不绕过任何访问控制,不采集可以与个人身份对应的数据。公共卫生统计网站上发布的疾病监测数据通常是统计汇总口径,不涉及个人隐私。但即便如此,我在项目里仍然做了这些事:
- 数据仅用于系统演示与学习,不对外提供任何原始数据的下载接口
- 爬虫脚本设置访问延迟,不冲击目标服务器,不给对方造成额外负载
- 数据库中的中间数据定期清理,前端展示的也只是一定范围内的聚合统计
这些措施本身不复杂,但它们决定了这个项目能不能站得住脚。医疗类的数据项目,技术能力是一方面,数据来源的干净程度是另一方面。
3. 随机森林模型落地:从特征工程到预测接口
3.1 建模目标与训练样本构造
爬下来的数据是年度维度的历史统计,那么模型预测什么?这里定为根据过去若干年的疾病统计特征,预测下一年的发病率或发病人数。
预测问题的核心在于样本构造。不能把原始表的每一行直接当作一个样本——某一行只是某一年、某个地区、某个疾病的一个统计快照,它本身不包含“过去信息”。为了让模型能够学到时间趋势,我用滑动窗口法构造特征:取连续三年(t-3、t-2、t-1)的数据,预测第t年的发病率。
例如,要预测2023年A省流感的发病率,就把2020年、2021年、2022年A省流感的相关指标作为特征,2023年的发病率作为标签。这样每生成一个训练样本,实际上就把三年历史信息压进了特征矩阵。
import pandas as pd def build_samples(df, window=3): samples = [] for (disease, province), grp in df.groupby(["disease_name", "province"]): grp = grp.sort_values("report_year") for i in range(window, len(grp)): past = grp.iloc[i - window: i] target = grp.iloc[i] feature = { "disease_name": disease, "province": province, "target_year": target["report_year"], "lag1_cases": past["cases"].iloc[-1], "lag2_cases": past["cases"].iloc[-2], "lag3_cases": past["cases"].iloc[-3], "lag1_incidence": past["incidence_rate"].iloc[-1], "lag2_incidence": past["incidence_rate"].iloc[-2], "lag3_incidence": past["incidence_rate"].iloc[-3], "avg_incidence": past["incidence_rate"].mean(), "target_value": target["incidence_rate"] } samples.append(feature) return pd.DataFrame(samples)这里我故意保留了disease_name和province这两个类别特征,而不是直接丢掉。模型需要学到“不同疾病在不同地区的发病基线不同”这个信息,类别特征处理得当的话,对预测精度的提升非常明显。
3.2 特征工程的几个关键处理
构建完原始特征矩阵后,下一步是做编码和类型转换。医疗数据的特征处理里,有以下几个点需要特别关注:
类别特征编码。
disease_name、province这类字段没法直接放进随机森林,需要用LabelEncoder或OneHotEncoder。对于树模型,LabelEncoder就够用了,因为决策树本质上做的是“按特征取值切分”,LabelEncoder不会引入额外的线性假设。但要注意编码映射一定要保存下来,后面预测接口用。缺失值处理。某些地区或某些年份的数据可能缺失,树模型本身能容忍一定缺失,但训练和预测时的处理必须一致。最稳妥的方式是用前向填充:用上一个有效值补最近一年的缺失值,这符合时间序列的直觉。
滞后特征名称必须规范。lag1_cases、lag2_cases这类字段,在训练和预测时必须完全一致。接口传参的时候如果稍有偏差,模型接收到的特征顺序就会错位,结果完全不可信。后面我会建议用pipeline把编码器和模型打包,避免这种问题。
3.3 训练、评估与简单调参
训练集和测试集的划分必须按时间顺序切,不能直接随机打乱。因为这是时间预测问题,用未来的数据去训练、过去的数据去测试,属于典型的数据泄漏,得到的评估指标会虚高到没有参考价值。
from sklearn.ensemble import RandomForestRegressor from sklearn.preprocessing import LabelEncoder from sklearn.model_selection import train_test_split from sklearn.metrics import mean_absolute_error, mean_squared_error, r2_score import joblib import pandas as pd df = pd.read_sql_query("SELECT * FROM disease_stats", conn) samples = build_samples(df) le_disease = LabelEncoder() le_province = LabelEncoder() samples["disease_code"] = le_disease.fit_transform(samples["disease_name"]) samples["province_code"] = le_province.fit_transform(samples["province"]) feature_cols = ["disease_code", "province_code", "target_year", "lag1_cases", "lag2_cases", "lag3_cases", "lag1_incidence", "lag2_incidence", "lag3_incidence", "avg_incidence"] samples = samples.sort_values("target_year") train_end = int(len(samples) * 0.8) train_df = samples.iloc[:train_end] test_df = samples.iloc[train_end:] X_train = train_df[feature_cols] y_train = train_df["target_value"] X_test = test_df[feature_cols] y_test = test_df["target_value"] model = RandomForestRegressor( n_estimators=200, max_depth=8, min_samples_leaf=3, random_state=42 ) model.fit(X_train, y_train) y_pred = model.predict(X_test) print("R2:", r2_score(y_test, y_pred)) print("MAE:", mean_absolute_error(y_test, y_pred)) print("RMSE:", mean_squared_error(y_test, y_pred, squared=False))对于树模型调参,我的经验是优先控制max_depth和min_samples_leaf,而不是一上来就堆n_estimators。n_estimators增大确实能降低方差,但到了一定数量后收益递减,训练耗时却线性增长。max_depth限制每棵树的复杂程度,min_samples_leaf限制叶子节点最少样本数,这两个参数对控制过拟合的作用最直接。
特征重要性也是一个很值得看的结果:
importance = pd.Series(model.feature_importances_, index=feature_cols) importance.sort_values(ascending=False).plot(kind="barh")对这个项目来说,通常会发现滞后1年的发病率(lag1_incidence)和疾病类别(disease_code)重要性最高,这符合直觉:某种疾病最近一年发病率高,下一年大概率也不会低到哪里去;不同疾病的基线差异本身就非常大。
3.4 模型序列化与Flask预测接口集成
训练完成后,要把模型、编码器一起保存下来,供Flask后端加载。这里一个常见的坑是:只保存模型,忘记保存编码器,导致预测接口收到disease_name时不知道往哪个编码映射,或者编码顺序不一致,预测结果完全乱掉。
正确的做法是把编码器和模型一起打包:
joblib.dump(model, "models/rf_model.pkl") joblib.dump(le_disease, "models/le_disease.pkl") joblib.dump(le_province, "models/le_province.pkl")Flask后端的预测接口实现:
from flask import Blueprint, request, jsonify import joblib import pandas as pd import numpy as np model_bp = Blueprint("model", __name__) model = joblib.load("models/rf_model.pkl") le_disease = joblib.load("models/le_disease.pkl") le_province = joblib.load("models/le_province.pkl") FEATURE_COLS = ["disease_code", "province_code", "target_year", "lag1_cases", "lag2_cases", "lag3_cases", "lag1_incidence", "lag2_incidence", "lag3_incidence", "avg_incidence"] @model_bp.route("/api/model/predict", methods=["POST"]) def predict(): data = request.get_json() try: disease_code = le_disease.transform([data["disease_name"]])[0] province_code = le_province.transform([data["province"]])[0] except ValueError: return jsonify({"code": 1, "msg": "未知的疾病或地区名称", "data": None}) row = [disease_code, province_code, data["target_year"], data["lag1_cases"], data["lag2_cases"], data["lag3_cases"], data["lag1_incidence"], data["lag2_incidence"], data["lag3_incidence"], data["avg_incidence"]] features = np.array([row]) pred = model.predict(features)[0] return jsonify({ "code": 0, "msg": "success", "data": { "pred_incidence": round(float(pred), 4) } })预测时如果传入了训练数据中没出现过的疾病名称,LabelEncoder的transform会直接抛ValueError,这里做了一层捕获并返回前端可读的错误信息,避免一个大白话的异常堆栈刷到浏览器控制台里。
4. Flask后端与Vue大屏对接:API设计、跨域和图表渲染
4.1 后端工程结构与统一响应格式
乱乱的Flask项目,一个app.py里塞几百行路由,这种结构在一个综合项目里完全不可维护。我在这套系统里用蓝图把路由拆开:
service-backend/ ├── app.py # 应用入口,注册蓝图 ├── config.py # 配置项:数据库路径、模型路径 ├── models/ │ └── db.py # SQLAlchemy模型 ├── blueprints/ │ ├── stats.py # 统计数据接口 │ ├── disease_api.py # 疾病查询接口 │ └── model_api.py # 模型预测接口 ├── services/ │ ├── data_aggregator.py # 聚合查询服务 │ └── model_service.py # 模型加载与预测 └── data/ └── health_data.dbAPI的响应格式必须统一,这一步省掉前端大量if else。我用的是最经典的{code, msg, data}结构。code为0表示成功,非0表示业务错误,msg是错误说明,data存放真正的数据。前端axios的响应拦截器直接判断code,不需要每个请求单独处理错误分支。
核心统计接口设计如下:
| 接口 | 方法 | 功能 | 返回数据 |
|---|---|---|---|
| /api/summary | GET | 总览指标 | 病例总数、死亡总数、疾病种类数、地区数 |
| /api/trend | GET | 历年发病趋势 | 年份序列 + 发病率/发病数序列 |
| /api/ranking | GET | 疾病/地区排名 | 前10位疾病/地区的发病数 |
| /api/model/predict | POST | 发病率预测 | 预测发病率 |
4.2 聚合SQL与统计接口实现
大屏首页需要展示“总病例数”“总死亡数”“疾病种类”“覆盖地区”这四个卡片,这些指标如果写四条SQL,虽然也能跑,但没必要。一个聚合查询就能拿全:
def get_summary(): row = db.session.execute( text(""" SELECT COUNT(CASE WHEN report_year = :max_year THEN 1 END) AS disease_count_total, SUM(cases) AS total_cases, SUM(deaths) AS total_deaths, COUNT(DISTINCT province) AS province_count, MAX(report_year) AS max_year FROM disease_stats """), {"max_year": max_year} ).fetchone() return { "total_cases": int(row.total_cases or 0), "total_deaths": int(row.total_deaths or 0), "disease_count": disease_count, "province_count": int(row.province_count or 0) }注意SUM(cases)在数据缺失时可能返回NULL,所以查询结果里要加or 0兜底,否则jsonify遇到None值会序列化成null,前端的卡片上可能显示“undefined”。
趋线接口的SQL要注意排序:
SELECT report_year AS year, SUM(cases) AS total_cases, SUM(deaths) AS total_deaths FROM disease_stats GROUP BY report_year ORDER BY report_year ASC前端折线图的数据顺序完全取决于这里有没有ORDER BY report_year。如果你漏了排序,MySQL或SQLite返回的顺序可能按主键或索引排列,前端图表就会出现一条乱序的“波浪线”。
4.3 跨域问题的两种解法
本地开发时,Vue跑在8080端口,Flask跑在5000端口,前端直接请求http://localhost:5000/api/summary必然触发跨域,浏览器会直接拦截响应。这个项目里我同时使用了两种方案,开发环境用Vue proxy,生产环境用Flask-CORS。
开发环境:Vue proxy配置。在vue.config.js中配置:
module.exports = { devServer: { port: 8080, proxy: { '/api': { target: 'http://127.0.0.1:5000', changeOrigin: true } } } }这样前端代码里写axios.get('/api/summary')时,Vue devServer会自动把请求转发到Flask的5000端口,浏览器的同源策略被devServer挡在外面,简单干净。
生产环境:Flask-CORS指定放行。打包部署时,前端静态文件由Nginx托管,后端单独跑在某个端口。如果域名不一致,仍然会跨域。这时可以用Flask-CORS:
from flask_cors import CORS CORS(app, resources={r"/api/*": {"origins": "*"}})注意,开发时如果已经用了Vue proxy,就不要再给后端加CORS了,否则会多一层无用的响应头。两个方案二选一,不要同时混用。
4.4 Vue大屏布局与ECharts渲染要点
大屏页面的布局,我用的是CSS Grid,而不是传统的flex大行。因为大屏页面的核心诉求是区块化、网格化,Grid可以非常自然地规划出复杂的行和列。
一个典型的大屏结构是:顶部一行放标题,中间主体分三列——左侧放排名和指标卡,中间放核心趋势大图,右侧放疾病分布图。我实际用的Grid定义大致这样:
.dashboard { display: grid; grid-template-columns: 1fr 2fr 1fr; grid-template-rows: auto 1fr auto; gap: 16px; height: 100vh; padding: 16px; background: #0f1923; color: #e5e7eb; }左侧和右侧原本都是窄列,所以右侧放柱状图或饼图,中间宽列放趋势折线图或预测结果展示。ECharts实例在Vue组件里挂载时,有一个很关键的坑:容器宽度在初始渲染时可能还没计算完成,此时初始化ECharts实例会拿到一个0宽度的容器,图表直接不显示。解决办法是在mounted钩子中使用$nextTick再初始化,或者给容器设置明确的高度和宽度。
每个图表封装成一个独立组件,比如TrendChart.vue:
<template> <div ref="chartRef" class="chart-container"></div> </template> <script> import * as echarts from 'echarts' export default { name: 'TrendChart', props: { chartData: { type: Object, required: true } }, data() { return { chart: null } }, watch: { chartData: { handler(newVal) { if (!this.chart) { this.initChart() } this.renderChart(newVal) }, deep: true } }, methods: { initChart() { if (this.chart) return this.chart = echarts.init(this.$refs.chartRef) }, renderChart(data) { this.chart.setOption({ animation: false, // 大屏数据更新频繁, 关闭动画避免卡顿 tooltip: { trigger: 'axis' }, xAxis: { type: 'category', data: data.years }, yAxis: { type: 'value', name: '发病数(例)' }, series: [{ name: '发病数', type: 'line', smooth: true, data: data.cases }] }) } } } </script>有一个细节值得单独说:animation: false。大屏项目里数据每隔十几秒就刷新一次,如果开着动画,每次刷新图表都要重新播放一遍过渡动画,低配机器上所有图表同时播放动画时会非常卡。实测下来,大屏页面的图表直接关闭动画,视觉效果并不受损,流畅度提升却非常明显。
axios请求的封装也很简单:
import axios from 'axios' const service = axios.create({ baseURL: '/api', timeout: 10000 }) service.interceptors.response.use( response => { const res = response.data if (res.code !== 0) { return Promise.reject(new Error(res.msg || '请求失败')) } return res.data }, error => { return Promise.reject(error) } )这样封装后,每个业务组件里调用接口时,拿到的直接就是data字段,不需要再解一层response.data.data,特别舒服。
5. 联调阶段的数据一致性问题与优化
5.1 日期字段排序错乱
联调时我遇到的第一类问题是前端排序与预期不符。折线图的X轴是年份,后端返回的接口数据经过了GROUP BY report_year ORDER BY report_year,但前端显示的年份还是错乱的。
排查后定位到问题:数据库里report_year是以TEXT类型存储的,排序时用的是字符串排序。比如2020、2021、2022看起来没问题,但如果年份数据里有1999和2000,字符串排序就会把1999排到2000后面吗?实际上字符串排序“1”在“2”前面,1999不会排错,但999这种不规范的字段就会出问题。更保险的做法是:在数据库和接口层全部用INT类型存年份,后端返回时再格式化成标准字符串。处理方案是清洗数据时强制做一次类型转换:
df["report_year"] = df["report_year"].astype(int)前端排序时也不要依赖字符串默认顺序,尽量用数值:
const sortedYears = data.years.sort((a, b) => a - b)5.2 模型预测值与历史值单位/量纲不一致
预测接口上线后第一次调用,返回的pred_incidence是一个0.5左右的值,但历史发病率在图表上显示的是48.6这样的数值,整整差了约100倍。花了一段时间排查,才发现问题出在数据清洗阶段:原始数据中发病率的单位是“每10万人口”还是“每1万人口”,我在不同年份的数据源里没有统一。
这种情况在医疗数据里非常常见,因为不同统计口径、不同发布平台可能用不同的分母。解决方式是在数据入库时做一个规范化:统一转成“每10万人口”的发病率。
df["incidence_rate"] = df["incidence_rate"].apply( lambda x: x * 10 if source_unit == "per_wan" else x )还有就是模型训练时如果对特征做了标准化,预测接口也要对输入特征做相同的标准化处理。我在最终方案里把所有预处理步骤都封装成一个transform_features函数,训练和预测共用同一份代码,从根上消除“训练时做了一步处理、接口调用时漏掉”的可能性。
5.3 大屏渲染卡顿
大屏页面初次加载时,我一度同时发起8个接口请求,每个请求返回几百条数据,然后所有图表同步渲染。结果就是页面加载卡顿,低配机器上风扇狂转。
优化思路是三层:
接口合并。把首页需要的指标卡数据、趋势数据、排名数据合并成2到3个聚合接口,减少HTTP请求的次数。前端一次拿到整个数据包,再分发到各个子组件。
关闭ECharts动画。前面已经提到,大屏场景下动画收益极低,但帧率损耗极大,直接设置
animation: false。数据聚合下推。排名图前端只需要前10名,就千万不要把几百个地区的数据全部返回,而是在SQL层用
LIMIT 10先过滤。大屏项目里,网络传输的数据量越小,渲染压力越小。
5.4 项目目录与部署建议
整个项目最终产物分两部分:后端Flask服务、前端Vue构建产物。本地开发时直接用python app.py启动Flask,npm run serve启动Vue devServer。生产部署我采用的是Nginx托管前端静态文件加反向代理后端接口的方式:
server { listen 80; server_name your-server-domain; root /var/www/dashboard/dist; index index.html; location / { try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:5000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }后端用gunicorn启动:
gunicorn -w 4 -b 127.0.0.1:5000 app:app如果你更喜欢容器化,用docker-compose把所有服务编排起来也行,但要注意Flask容器和前端Nginx容器共享同一个网络,前端的API地址要配置成后端服务名称,而不是localhost。
还有一个很实用的建议:爬虫脚本不要和Flask服务绑定在同一个进程里。爬虫偶尔会卡在网络请求上,如果把它放在Flask进程里,一个阻塞就可能拖慢所有API响应。最好让爬虫作为独立脚本或定时任务运行,数据写库后,Flask只负责读库和预测,两者解耦。
最后几个实操体会
如果只让我留一句话,就是:先跑通最小闭环,再装饰细节。这个项目我最初的版本甚至没有随机森林,只有爬虫、数据库和一个柱状图。等这条链路完全通了之后,模型、更多图表、更复杂的布局才逐步加进去。先让数据能从源端流到屏幕,项目的信心和价值感就完全不一样了。
另外一个小技巧:医疗数据的字段名很容易产生歧义,cases、deaths、incidence这些中英文混用,联调时特别容易闹笑话。建议在数据入库时就统一字段命名规范,后端API用snake_case,前端展示层再转换成中文标题,别小看这个细节,它能帮你省下大量对齐时间。
如果后续想往上扩展,可以试试给系统加上用户登录权限、定时爬取任务、图表下钻联动,或者把随机森林换成XGBoost做一组对比实验。但请记住一点:在这个项目里,算法只是其中一环,数据的准确性和前后端链路的稳定才是真正决定成败的部分。把最基础的数据链路做扎实,大屏才有底气摆上台面。
本文还有配套的精品资源,点击获取