毕业设计选题里,“某某数据可视化系统”是最容易被低估的一类。乍一看它门槛不高:爬点数据、画几张图、套一个 Django 后台,好像答辩前一周拼一拼就能交。但真正做过的人都知道,这类项目翻车的点非常集中——数据源断供、爬虫被封、图表数据量一大就卡死、答辩时老师问一句“你这个系统得出了什么结论”,人就哑了。
这篇文章想聊的不是“怎么套模板交作业”,而是一个全国地铁数据可视化分析系统怎么才算做扎实:从数据获取、清洗入库、指标体系设计、可视化交互,到用 Django 把前后端串起来,再到把大模型和 Agent 用在“自然语言查数据”这种真正能加分的场景上。我会把技术选型的理由、容易踩的坑、可复制的代码都写清楚。
先给一个判断:这个项目的技术难点从来不在“画图”本身。ECharts 上手半小时就能出图,真正决定成绩高低的是三件事——数据管道的稳定性、分析维度的设计、以及你能不能讲清楚每一张图背后的业务结论。把这三件事做好,项目就是良以上;只堆图表不讲分析,最多及格。
下面按一个完整项目的推进顺序展开,你可以对照着自己动手实现。
1. 这个选题真正要解决的问题
很多人对这个题目的第一反应是“数据可视化”,于是把精力全砸在前端美化上,结果做出来一个花哨但空洞的仪表盘。老师翻两页就会问:你分析了什么?你的数据从哪来?这些指标说明了什么问题?
换个角度理解这个题目,它其实是一个小型的数据工程闭环:数据采集 → 清洗加工 → 结构化存储 → 多维聚合 → 可视化呈现 → 结论输出。可视化只是最后一环,前面每一环都可能出问题,而每一环出问题都会让最后一环变成空壳。
具体来说,一个合格的地铁数据分析系统至少要回答四类问题。第一类是规模类:全国有多少城市开通了地铁,总里程多少,线路数量如何分布。第二类是结构类:不同城市的线网密度、平均站间距、换乘站占比有什么差异。第三类是趋势类:某城市近几年线路扩张速度、客流量的季节性波动。第四类是对比类:一线城市和新一线城市在运营指标上的差距在哪里。
这四类问题对应四种不同的数据聚合方式和四种图表类型,也对应答辩时你要讲的四个结论。如果你的系统能把这四类问题都答清楚,那它就不是“画图作业”,而是一个有分析能力的系统。
另外要提醒一点:全国地铁的官方客流数据并不完全公开,很多城市只公布月度或年度总量,日粒度数据几乎拿不到。所以这个项目在数据层面必须做好两手准备——能用公开数据的地方用公开数据,拿不到的地方用符合真实分布的模拟数据,并且在论文和答辩中如实说明数据来源和模拟方法。这一点不丢人,反而是严谨的体现。老师最反感的不是数据不完美,而是你假装数据是真的。
2. 核心技术概念与整体架构
在动手之前,先把技术栈的职责边界划清楚,否则很容易写着写着把业务逻辑塞进模板里,最后没法维护。
2.1 Django 在系统里的角色
Django 在这个项目里承担三件事:数据模型定义、后台管理、以及给前端提供 JSON 接口。它的 ORM 特别适合做多维聚合查询,比如“按城市分组统计线路数量和总里程”这种需求,用annotate加Sum几行就写完了,比手写 SQL 更安全也更易读。
我推荐用 Django 而不是 Flask,理由很实际:毕设项目通常需要后台管理界面来维护数据,Django Admin 开箱即用,admin.py注册一下模型就有了增删改查页面,省下来的时间可以投入到数据分析和前端上。Flask 更轻,但后台、ORM、迁移工具都要自己搭,对本项目的收益不大。
需要强调的是,Django 只做数据服务,不要用它自带的模板引擎去渲染图表。图表渲染交给前端用 ECharts 完成,后端只返回 JSON。这样做的好处是前后端解耦,调试时你可以直接用浏览器或curl验证接口返回,定位问题快得多。
2.2 可视化链路拆解
整条链路我建议这样拆:
数据源(公开数据/模拟生成) ↓ pandas 清洗脚本(去重、补全、类型转换) ↓ Django ORM 入库(City / MetroLine / MetroStation / StationFlow) ↓ 聚合接口(按城市、按线路、按时间维度) ↓ ECharts 渲染(地图、折线、柱状、散点、热力)这条链路里,最容易出问题的是“清洗脚本”和“聚合接口”两段。清洗不干净,后面所有统计都是错的;聚合接口设计得不好,前端每画一张图就要发一次全量查询,数据量一大就卡。
2.3 大模型、多模态、Agent 的合理边界
标题里带了“大模型、多模态、Agent”,这是近几年毕设很喜欢加的词。但加词容易,落地难。我的建议是:只做一个真正跑得通的点,不要样样都碰。
比较靠谱的落地方式是“自然语言查询”。用户在输入框里问“北京和上海哪个城市地铁线路更多”,系统把这句话交给大模型,模型输出一个结构化的查询意图(JSON),后端根据这个意图调用对应的聚合接口,最后把结果返回并生成一句话总结。这个方案的好处是模型不直接碰数据库,安全性可控,出错也容易兜底。
Agent 可以理解为这个方案的进阶版:让模型在多步之间做决策。比如用户问“找出近三年客流增长最快的三个城市”,模型需要先调用“获取城市列表”,再对每个城市调用“获取年度客流”,最后排序。这就是典型的多步工具调用,用函数调用(Function Calling)机制可以实现。
多模态则要谨慎。比较现实的用法是让用户上传一张客流热力图截图,模型描述图中趋势;或者把生成的图表截图交给模型做文字解读。这个功能可以作为扩展点写进论文的“未来工作”,不建议作为核心功能,因为稳定性和成本都不好控制。
一句话总结边界:核心系统必须是确定性的,大模型只做自然语言到结构化查询的翻译层。这样即使模型不可用,系统依然能正常跑。
3. 环境准备与项目初始化
3.1 环境清单
下面是我建议的环境配置,具体版本请以你本机实际安装为准,本文重点演示通用思路,不锁定小版本号。
| 组件 | 建议版本 | 说明 |
|---|---|---|
| Python | 3.10 及以上 | 3.9 以下部分库兼容性差 |
| Django | 4.x LTS | 长期支持版,文档全 |
| pandas | 2.x | 数据清洗主力 |
| requests | 2.x | 采集公开数据用 |
| mysqlclient 或 psycopg2 | 与数据库匹配 | 生产建议 MySQL 或 PostgreSQL |
| ECharts | 5.x | 前端通过 CDN 或本地引入 |
数据库方面,开发阶段用 SQLite 就够,零配置起步快;但如果你要处理几十万条客流记录,建议换成 MySQL,聚合查询性能差别明显。切换时注意把DATABASES配置改掉,并重新执行迁移。
3.2 项目骨架搭建
先建虚拟环境,这是好习惯,避免污染系统 Python:
python -m venv venv source venv/bin/activate # Windows 用 venv\Scripts\activate pip install django pandas requests mysqlclient然后创建项目和核心应用:
django-admin startproject metro_system cd metro_system python manage.py startapp analysis在metro_system/settings.py里注册应用,并配置中文和时区:
# 文件路径:metro_system/settings.py INSTALLED_APPS = [ "django.contrib.admin", "django.contrib.auth", "django.contrib.contenttypes", "django.contrib.sessions", "django.contrib.messages", "django.contrib.staticfiles", "analysis", ] LANGUAGE_CODE = "zh-hans" TIME_ZONE = "Asia/Shanghai" USE_TZ = FalseUSE_TZ设为False是刻意的选择。数据分析和日期聚合场景下,时区转换很容易让“某天的数据”落到前一天,新手排查起来非常痛苦。如果项目不涉及跨时区业务,直接关掉更省心。这一步做错的表现是:明明导入了 1000 条记录,按日期分组统计却少了一部分,因为部分记录被归到了相邻日期。
4. 数据模型设计与数据入库
4.1 核心表结构
模型设计决定了后面能做什么分析。我建议至少四张表:城市表、线路表、站点表、客流表。下面给出可直接使用的模型定义。
# 文件路径:analysis/models.py from django.db import models class City(models.Model): name = models.CharField(max_length=64, unique=True, verbose_name="城市名称") province = models.CharField(max_length=64, blank=True, verbose_name="所属省份") open_year = models.IntegerField(null=True, blank=True, verbose_name="首条线路开通年份") class Meta: db_table = "t_city" verbose_name = "城市" def __str__(self): return self.name class MetroLine(models.Model): city = models.ForeignKey(City, on_delete=models.CASCADE, related_name="lines") line_name = models.CharField(max_length=64, verbose_name="线路名称") line_color = models.CharField(max_length=16, blank=True, verbose_name="线路标识色") start_station = models.CharField(max_length=64, blank=True, verbose_name="起点站") end_station = models.CharField(max_length=64, blank=True, verbose_name="终点站") station_count = models.IntegerField(default=0, verbose_name="站点数") length_km = models.FloatField(default=0.0, verbose_name="运营里程(公里)") class Meta: db_table = "t_metro_line" unique_together = ("city", "line_name") verbose_name = "地铁线路" class MetroStation(models.Model): line = models.ForeignKey(MetroLine, on_delete=models.CASCADE, related_name="stations") station_name = models.CharField(max_length=64, verbose_name="站点名称") is_transfer = models.BooleanField(default=False, verbose_name="是否换乘站") longitude = models.FloatField(null=True, blank=True, verbose_name="经度") latitude = models.FloatField(null=True, blank=True, verbose_name="纬度") class Meta: db_table = "t_metro_station" verbose_name = "地铁站点" class StationFlow(models.Model): station = models.ForeignKey(MetroStation, on_delete=models.CASCADE, related_name="flows") stat_date = models.DateField(verbose_name="统计日期") in_flow = models.IntegerField(default=0, verbose_name="进站客流") out_flow = models.IntegerField(default=0, verbose_name="出站客流") class Meta: db_table = "t_station_flow" indexes = [models.Index(fields=["stat_date"])] unique_together = ("station", "stat_date") verbose_name = "站点客流"这里有两个设计细节值得说明。第一,unique_together是必须加的,它能防止重复导入造成数据翻倍,这是数据导入最常见的坑。第二,客流表上加了stat_date索引,因为绝大多数聚合查询都会按日期过滤,没有索引时几十万条数据的分组统计会慢到无法接受。
建完模型执行迁移:
python manage.py makemigrations python manage.py migrate4.2 数据来源与模拟数据生成
真实数据可以来自各城市地铁官网公示、统计年鉴、开放数据平台。采集时注意遵守网站的使用条款,控制请求频率,加合理的User-Agent,不要对目标站点造成压力。
对于拿不到的日粒度客流,用模拟数据是合理做法。关键在于模拟要符合真实分布特征:工作日客流明显高于周末,早晚高峰有双峰,换乘站客流高于普通站,节假日有波动。下面这段脚本可以生成一段符合这些特征的样本数据。
# 文件路径:scripts/gen_mock_flow.py import os import random from datetime import date, timedelta import django os.environ.setdefault("DJANGO_SETTINGS_MODULE", "metro_system.settings") django.setup() from analysis.models import MetroStation, StationFlow # noqa: E402 def weekday_factor(d: date) -> float: """工作日高峰系数,周末客流下降。""" return 1.0 if d.weekday() < 5 else 0.72 def generate(station: MetroStation, base: int, days: int = 90): today = date.today() batch = [] for i in range(days): d = today - timedelta(days=i) factor = weekday_factor(d) noise = random.uniform(0.9, 1.1) # 换乘站客流显著高于普通站 transfer_boost = 1.8 if station.is_transfer else 1.0 in_flow = int(base * factor * noise * transfer_boost) out_flow = int(in_flow * random.uniform(0.92, 1.08)) batch.append( StationFlow( station=station, stat_date=d, in_flow=in_flow, out_flow=out_flow, ) ) StationFlow.objects.bulk_create(batch, ignore_conflicts=True) print(f"{station.station_name} 生成 {len(batch)} 条客流记录") if __name__ == "__main__": for st in MetroStation.objects.select_related("line")[:50]: generate(st, base=random.randint(8000, 30000))这段脚本的核心是weekday_factor和transfer_boost两个系数,它们让模拟数据在统计上有意义。如果随便用random.randint生成,产出的折线图会是一条毫无规律的噪音线,答辩时一眼就被看穿。批量插入必须用bulk_create,逐条save()在几千条以上会慢到让人怀疑人生。
5. 数据清洗与指标计算
入库之后不要急着画图,先做一轮质量检查。我一般跑这三个统计:总记录数、按城市的记录分布、以及空值和异常的占比。如果某城市记录数为零,说明采集环节漏了;如果某站点日客流出现负数或极端异常值,说明原始数据有问题。
清洗的关键动作有三个:
第一,类型统一。里程字段经常混着“23.5公里”“23.5km”“23.5”三种写法,入库前统一转成float。用 pandas 处理时,先做字符串清洗再astype(float),转换失败的记录单独记录到日志文件,不要静默丢弃。
第二,去重。同一城市同一线路可能被多个数据源重复采集,以(city, line_name)为键去重。站点级别以(line, station_name)为键。
第三,派生指标计算。原始数据只能告诉你“有多少站、多少公里”,真正有分析价值的是派生指标:
| 指标 | 计算方法 | 业务含义 |
|---|---|---|
| 平均站间距 | 线路里程 / (站点数 - 1) | 反映线路覆盖密度 |
| 换乘站占比 | 换乘站数 / 站点总数 | 反映线网互联程度 |
| 线网密度 | 总里程 / 城市面积 | 反映轨道交通发达程度 |
| 客流强度 | 日均客流 / 线路里程 | 反映运营效率 |
| 客流不均衡系数 | 高峰日客流 / 平峰日客流 | 反映客流波动特征 |
这些指标才是论文里“分析”部分的内容来源。只写“北京有 27 条线”是描述,写“北京线网密度是 XX 城市的 1.6 倍,反映其轨道交通在同等面积下的覆盖优势”才是分析。
6. 可视化接口与前端渲染
6.1 后端聚合接口
接口设计的原则是“前端要什么就返回什么,聚合在后端做完”。下面这个视图返回全国各城市的线路数和总里程,用于首页柱状图和地图。
# 文件路径:analysis/views.py from django.db.models import Count, Sum, Avg from django.http import JsonResponse from django.views.decorators.http import require_GET from .models import City, MetroLine @require_GET def city_summary(request): """全国城市地铁规模概览。""" rows = ( MetroLine.objects .values("city__name", "city__province") .annotate( line_count=Count("id"), total_km=Sum("length_km"), avg_station=Avg("station_count"), ) .order_by("-total_km") ) data = [ { "city": r["city__name"], "province": r["city__province"], "lineCount": r["line_count"], "totalKm": round(r["total_km"] or 0, 2), "avgStation": round(r["avg_station"] or 0, 1), } for r in rows ] return JsonResponse({"code": 0, "data": data}) @require_GET def line_flow_trend(request): """某线路近 30 天客流趋势,用于折线图。""" line_id = request.GET.get("line_id") if not line_id: return JsonResponse({"code": 400, "msg": "line_id 不能为空"}, status=400) rows = ( StationFlow.objects .filter(station__line_id=line_id) .values("stat_date") .annotate(total_in=Sum("in_flow"), total_out=Sum("out_flow")) .order_by("stat_date") ) data = [ { "date": r["stat_date"].strftime("%Y-%m-%d"), "inFlow": r["total_in"], "outFlow": r["total_out"], } for r in rows ] return JsonResponse({"code": 0, "data": data})注意line_flow_trend里对line_id做了非空校验并及时返回 400。这个细节看起来不起眼,但前端传空值时如果不拦截,filter(station__line_id=None)会静默返回空列表,前端就显示一张空白图,排查起来要多走好几个弯路。
路由配置:
# 文件路径:analysis/urls.py from django.urls import path from . import views urlpatterns = [ path("api/city-summary/", views.city_summary), path("api/line-flow/", views.line_flow_trend), ]# 文件路径:metro_system/urls.py from django.contrib import admin from django.urls import path, include urlpatterns = [ path("admin/", admin.site.urls), path("", include("analysis.urls")), ]6.2 前端 ECharts 渲染
前端建一个 HTML 页面,引入 ECharts,把接口数据喂给图表配置即可。
<!-- 文件路径:analysis/templates/dashboard.html --> <!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <title>全国地铁数据可视化分析</title> <script src="https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js"></script> <style> #bar { width: 100%; height: 480px; } </style> </head> <body> <div id="bar"></div> <script> const chart = echarts.init(document.getElementById('bar')); fetch('/api/city-summary/') .then(res => res.json()) .then(res => { const data = res.data || []; chart.setOption({ title: { text: '各城市地铁运营里程排行' }, tooltip: { trigger: 'axis' }, xAxis: { type: 'category', data: data.map(d => d.city) }, yAxis: { type: 'value', name: '里程(公里)' }, series: [{ type: 'bar', data: data.map(d => d.totalKm), itemStyle: { color: '#3b82f6' } }] }); }) .catch(err => console.error('加载失败', err)); window.addEventListener('resize', () => chart.resize()); </script> </body> </html>这里有个容易被忽略的点:window.addEventListener('resize', ...)这一行必须有。ECharts 画布尺寸是初始化时确定的,浏览器窗口变化时不监听resize,图表不会自适应,答辩演示时切换分辨率就会出现图表被裁切的情况。
7. 大模型自然语言查询接入
这是能给项目加分但也要谨慎处理的部分。核心原则前面说过:不让模型直接执行 SQL 连数据库。我的做法是让模型输出一个受限的查询意图对象,后端根据白名单映射到具体接口。
先定义意图结构:
{ "intent": "compare_city", "cities": ["北京", "上海"], "metric": "total_km", "top_k": 2 }再写一个解析函数。下面用伪代码风格展示调用逻辑,具体 SDK 请以你选用的模型服务文档为准,不要照抄不存在的接口。
# 文件路径:analysis/nl_query.py import json from django.db.models import Sum from .models import MetroLine ALLOWED_INTENTS = {"city_rank", "compare_city", "line_count"} ALLOWED_METRICS = {"total_km", "line_count"} def build_prompt(question: str) -> str: return ( "你是地铁数据分析助手。根据用户问题输出 JSON,字段包括 " "intent(取值为 city_rank/compare_city/line_count)、" "metric(取值为 total_km/line_count)、cities(城市名数组,可为空)。" "只输出 JSON,不要任何解释。用户问题:" + question ) def safe_resolve(intent_obj: dict): """把模型意图映射到受限查询,杜绝任意 SQL。""" intent = intent_obj.get("intent") metric = intent_obj.get("metric", "total_km") cities = intent_obj.get("cities") or [] if intent not in ALLOWED_INTENTS: return {"code": 400, "msg": "不支持的查询类型"} if metric not in ALLOWED_METRICS: return {"code": 400, "msg": "不支持的指标"} qs = MetroLine.objects.values("city__name").annotate( total_km=Sum("length_km") ) if intent == "compare_city" and cities: qs = qs.filter(city__name__in=cities) rows = sorted(qs, key=lambda r: r["total_km"] or 0, reverse=True) return { "code": 0, "data": [ {"city": r["city__name"], "totalKm": round(r["total_km"] or 0, 2)} for r in rows ], }这段代码有三个安全设计值得在论文里单独说明。其一,ALLOWED_INTENTS和ALLOWED_METRICS是白名单,模型输出任何不在名单内的值都会被拒绝。其二,查询走 ORM 而不是拼接字符串,天然免疫 SQL 注入。其三,模型只负责“翻译”,不负责“执行”,即使模型输出格式错误,最坏结果只是返回错误提示,不会破坏数据。
如果要做 Agent 版本,把safe_resolve拆成几个独立工具函数(查城市清单、查线路客流、城市对比),交给模型的函数调用机制去决定调用顺序。这样多步查询也有了,而且每一步都在你的控制范围内。
至于多模态,一个相对稳妥的扩展是:把生成的图表用canvas导出为图片,上传给支持视觉输入的模型,让它生成一段文字解读,附在图表下方。这个功能的定位是“辅助说明”,不要把系统的核心结论依赖在它上面。
8. 运行结果与效果验证
启动服务:
python manage.py makemigrations python manage.py migrate python manage.py createsuperuser python manage.py runserver 0.0.0.0:8000访问http://127.0.0.1:8000/admin/能看到四张表的管理页面,说明模型和迁移都正常。访问http://127.0.0.1:8000/api/city-summary/,如果返回{"code": 0, "data": [...]}并且data里有内容,说明数据入库和聚合都通了。访问仪表盘页面能看到柱状图,说明前后端联调成功。
排查顺序建议固定成这个流程:先查数据库有没有数据(python manage.py shell里MetroLine.objects.count()),再查接口返回(浏览器直接打开 API 地址),最后查前端控制台。三步能覆盖 90% 的问题。
如果接口返回 500,第一件事是看终端里的异常堆栈,而不是改前端代码。Django 的报错信息通常很明确,比如字段名写错、外键不存在、日期格式不匹配,照着堆栈改就行。
9. 常见问题与排查思路
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 页面图表空白 | 接口返回 404 或跨域 | 浏览器 Network 面板看请求 | 检查 URL 配置,开发期用同源访问 |
| 数据导入后翻倍 | 重复执行导入脚本 | 统计表记录数并与源文件比对 | 加unique_together,用ignore_conflicts=True |
| 日期分组结果错位 | 时区配置不一致 | 检查USE_TZ和数据库时区 | 统一USE_TZ=False或全程使用 UTC |
| 聚合统计很慢 | 缺少日期索引 | 查看查询执行计划 | 给stat_date加db_index |
| 外键删除报错 | on_delete策略冲突 | 看异常里的约束提示 | 用CASCADE或先清理子表 |
| 大模型返回非法 JSON | 提示词约束不足 | 打印原始返回内容 | 提示词加“只输出 JSON”,解析加try/except兜底 |
| 图表分辨率变化被裁切 | 未监听 resize | 调整浏览器窗口观察 | 绑定resize事件调用chart.resize() |
| 中文显示乱码 | 数据库字符集不是 utf8mb4 | 查表字符集配置 | 建库时指定 utf8mb4 并重建表 |
这里单独说一条:不要在生产库上直接跑导入脚本。所有批量写入先在测试库验证,确认记录数和预期一致再上线。如果脚本里包含delete()或update()全表操作,执行前必须备份,并且加--dry-run参数先看一眼影响范围。这类事故在毕设里很常见,一条写错的清理语句就能把几天的数据全部清空。
10. 最佳实践与答辩建议
项目能跑通只是及格线,能不能拿到高分取决于几个工程细节和表达方式。
代码组织上,把数据采集、清洗、分析、接口分成独立模块。scripts/放一次性脚本,analysis/放模型和视图,analysis/services/放聚合逻辑。视图函数只做参数校验和调用服务层,不要把几十行聚合逻辑全塞进视图,否则后期改一个指标要动十几个地方。
配置管理上,数据库密码、模型服务密钥这类信息不要写死在settings.py里然后连文件一起提交。用环境变量读取,或者建一个local_settings.py加进.gitignore。答辩时如果老师问起安全性,这一点能直接加分。
日志与可观测性上,给导入脚本和接口加上基本日志。至少记录三件事:导入了多少条、耗时多少、失败了哪些。数据量一上来,没有日志你根本不知道是 SQL 慢还是网络慢。
答辩表达上,我建议把讲解重心放在“分析结论”而不是“技术堆砌”。老师更想听的是:你从数据里发现了什么规律?北京和成都的线网结构差异说明了什么?为什么换乘站占比高的城市客流强度更大?能把这些讲清楚,比罗列十个技术名词有用得多。
最后补一个实用建议:把系统做成可演示的状态。准备一份“一键启动”的说明,数据库预先灌好数据,图表打开就有内容。答辩现场最怕的不是功能少,而是环境起不来——投影仪连着你的笔记本,python manage.py runserver报个依赖缺失的错,后面十分钟就在装包了。
如果时间充裕,可以再往下做两件事:一是把分析结果导出成报告(用pandas导出 Excel,或者生成 PDF);二是加一个简单的定时任务,每天自动增量更新客流数据。这两点都属于“工程完整度”的体现,成本不高但观感很好。