简介:基于Python/Django的人口普查可视化系统毕业设计源码包,面向软件工程、计科、人工智能等计算机相关专业学生与开发者,适用于毕业设计、课程设计、项目初期演示,也可供数据可视化与Web开发入门参考。压缩包内共215个文件,包含112个人口数据Excel表、后台源码、前端页面与样式脚本、数据库脚本及部署文档,另有字体、图片等界面资源,整体仅5.35MB,结构清晰,便于快速部署与二次修改。项目已通过Windows/macOS/Linux多平台运行验证,获导师认可并达到95分答辩评审成绩,目前已有240人学习下载。除可运行系统外,还可借助附带数据资料与源码理解人口普查数据的清洗、存储和可视化流程,并可在现有模块基础上扩展新功能;同时附带的部署文档可帮助快速搭建环境,适合课设、毕设或项目立项演示。
1. 人口普查可视化系统在做什么:一个毕设题目背后的完整工程链路
“人口普查可视化系统”这个题目,每年都有一批毕业生在做——数据量看起来大、页面看起来要有图表,但真正动起手来,卡住人的往往不是画图,而是数据从哪来、Django怎么把聚合统计吐给前端。这套基于 Python + Django 的方案,核心就三件事:把普查原始数据建好模型,用 ORM 写出按区县、年龄、性别分组的统计接口,再让 ECharts 把接口数据画成图。它适合想做完整全栈毕设、又不想在数据清洗和部署上耗太久的人。你要的不是炫技,而是一条能复现、能答辩、能部署的落地路径,下面这条链路就是照着这个目标走的。
2. 数据模型与数据准备:人口普查表怎么设计才不返工
2.1 从统计口径倒推模型字段
很多人一上来就急着建模型,结果做到可视化阶段发现缺字段、缺索引、维度对不上,只能回头改表。我一般会先把统计图想的指标列出来——按区县分组、按年龄分段、按性别对比、按户口性质看城乡结构。这些指标反推回去,就是模型里必须有的字段:区县、年龄、性别、户口性质、文化程度,再加一个唯一记录编号用于去重。
from django.db import models class CensusRecord(models.Model): GENDER_CHOICES = ( ('M', '男'), ('F', '女'), ) HOUSEHOLD_CHOICES = ( ('urban', '城镇'), ('rural', '乡村'), ) record_id = models.CharField('记录编号', max_length=32, unique=True) district = models.CharField('区县', max_length=50, db_index=True) town = models.CharField('乡镇/街道', max_length=50, blank=True, db_index=True) community = models.CharField('村/社区', max_length=80, blank=True) age = models.IntegerField('年龄', db_index=True) gender = models.CharField('性别', max_length=1, choices=GENDER_CHOICES) household_type = models.CharField('户口性质', max_length=10, choices=HOUSEHOLD_CHOICES) education = models.CharField('文化程度', max_length=20, blank=True) class Meta: db_table = 'census_record' indexes = [ models.Index(fields=['district', 'gender', 'age'], name='idx_district_gender_age'), ]这里有几个选型上的考虑。record_id 设成 unique,是为了导入 CSV 时做幂等去重,同一份文件重复导入不会产生脏数据。district、age、gender 都加了 db_index,因为后面可视化的聚合查询基本都围绕这三个字段分组筛选,索引有没有,查询速度可能差几十倍。gender 和 household_type 用 choices 限定合法值,比后端写 if 判断更省事,也从源头挡住脏数据。
2.2 创建项目与 App:把 Django 骨架先立起来
动手写模型之前,先把 Django 项目建好。常见做法是虚拟环境隔离依赖,然后依次执行创建命令:
python -m pip install django pandas django-admin startproject census_project cd census_project python manage.py startapp census创建好 app 之后别忘了两件事:一是把 census 加进 settings.py 的 INSTALLED_APPS,二是在 census/models.py 里把模型定义好,然后执行迁移。很多新手在这里翻车,迁移时报Table 'census_censusrecord' already exists,多半是之前手动建过同名的表,或者跑过migrate但模型后来改过字段。这时候不要急着删库,先python manage.py makemigrations census生成新的迁移文件,再migrate应用一次就好。
2.3 批量导入 CSV:pandas 读取加 bulk_create
数据资料里最常见的就是 CSV 或 Excel 格式的普查表。直接用 Django ORM 逐行 create 在数据量到几万行以后会非常慢,所以我一般写一个自定义管理命令,用 pandas 读取,再用 bulk_create 批量写入。
在 census/management/commands/ 目录下新建 import_census.py:
import pandas as pd from django.core.management.base import BaseCommand from census.models import CensusRecord class Command(BaseCommand): help = '导入人口普查CSV数据' def add_arguments(self, parser): parser.add_argument('--csv', required=True, help='CSV文件路径') def handle(self, *args, **opts): df = pd.read_csv( opts['csv'], encoding='utf-8-sig', dtype={'age': int}, usecols=['record_id', 'district', 'town', 'community', 'age', 'gender', 'household_type', 'education'] ) df = df.dropna(subset=['record_id', 'district', 'age']) objs = [ CensusRecord( record_id=row['record_id'], district=row['district'], town=row.get('town', ''), community=row.get('community', ''), age=int(row['age']), gender=row['gender'], household_type=row['household_type'], education=row.get('education', ''), ) for _, row in df.iterrows() ] CensusRecord.objects.bulk_create(objs, batch_size=2000, ignore_conflicts=True) self.stdout.write(self.style.SUCCESS(f'导入完成: {len(objs)} 条'))这段命令需要放到 census 项目的 management/commands 目录下,运行python manage.py import_census --csv data.csv即可。read_csv 里 encoding 写成 utf-8-sig 是为了兼容带 BOM 的文件;如果你拿到的是 GBK 编码的 CSV,把 encoding 改成 gbk 就行,这一步很关键,不然导入后全是乱码。dropna 会直接丢掉 record_id、district、age 为空的行,减少后续统计时出现None分组的概率。ignore_conflicts=True 配合 record_id 的唯一约束,重复导入时只会跳过已存在的行,不会整个报错。
3. Django ORM 统计查询:把聚合接口写成可视化的数据出口
3.1 values 加 annotate:按区县分组的两种写法
可视化的核心不是把原始数据全量吐给前端,而是让后端先算好聚合结果。Django ORM 里最常用的组合是values()加annotate():values 指定按哪个字段分组,annotate 生成统计列。
from django.db.models import Count from census.models import CensusRecord # 按区县统计人口总数 district_stats = ( CensusRecord.objects .values('district') .annotate(total=Count('id')) .order_by('-total') ) # 结果示例: [{'district': '云台区', 'total': 35890}, {'district': '临江区', 'total': 31244}, ...]这段查询翻译成 SQL 就是SELECT district, COUNT(id) FROM census_record GROUP BY district ORDER BY COUNT(id) DESC。要注意Count('id')和Count('*')的细微区别:如果你有外键字段可能为 NULL,Count 指定具体字段会跳过 NULL 行,Count('*') 则全算。普查记录里 record_id 和 id 都不会为空,两者差别不大,但如果后面你要统计有文化程度字段的人数,Count('education')出来的数字可能比总人数小,这是需要心里有数的事情。
3.2 按年龄段分桶:Case/When 的区间聚合
按年龄分段是人口普查可视化的经典需求。年龄是连续整数,不能直接按 18、30、45 一个个值分组,得先分桶。我第一次做时直接在 Python 里遍历每条记录做区间判断,几十万条数据跑了一分多钟,后来改成在数据库层面分桶,快了几个量级。
from django.db.models import Case, When, Value, CharField, Count from census.models import CensusRecord age_group = Case( When(age__lt=18, then=Value('0-17')), When(age__lt=30, then=Value('18-29')), When(age__lt=45, then=Value('30-44')), When(age__lt=60, then=Value('45-59')), default=Value('60+'), output_field=CharField(), ) age_gender_stats = ( CensusRecord.objects .annotate(age_group=age_group) .values('age_group', 'gender') .annotate(total=Count('id')) .order_by('age_group', 'gender') )这段代码的灵魂在 Case/When 的顺序。age__lt=18放在最前面,接着是age__lt=30,数据库按条件从上往下匹配,匹配到就停止。如果你把age__lt=60写在前面,那 25 岁的人也会被归进45-59,因为这条件提前被满足了。写这种区间分桶的 Case 时,条件必须从小到大排列,或者从大到小配合age__gte写,否则统计出来的数据会完全失真。这也是我在实际项目里最容易踩的坑,没有之一。
3.3 避免 N+1 查询:select_related 的正确姿势
当项目需要把普查记录和乡镇、职业等维度表关联起来展示时,就轮到 select_related 上场了。比如你在系统里维护了一张职业表 Vocation,人口信息表 PersonInfo 通过外键指向它:
from django.db import models class Vocation(models.Model): name = models.CharField(max_length=50) class PersonInfo(models.Model): name = models.CharField(max_length=100) vocation = models.ForeignKey(Vocation, on_delete=models.PROTECT, related_name='persons')如果做一个“每个职业对应多少人”的统计,新手最容易写成循环里每次取外键对象:
# 不要这样做: 会产生 N+1 查询 persons = PersonInfo.objects.all() for p in persons: print(p.vocation.name)这行代码在数据库层面会先查一次 PersonInfo,再对每条记录查一次 Vocation。假设有 5 万条 PersonInfo,就是 1 加 5 万次查询,页面不卡才怪。正确做法是:
persons = PersonInfo.objects.select_related('vocation').all() for p in persons: print(p.vocation.name)select_related 会生成一条带 LEFT JOIN 的 SQL,一次性把 Vocation 的数据查出来缓存。它只适用于 ForeignKey 和 OneToOne 这类单值关系,如果是多对多关系要改用 prefetch_related。做人口普查可视化时,只要模型里外键多于一个,我建议对查询条件里出现的所有外键都加上 select_related,这是让聚合接口不翻车的基本功。
4. 可视化接口与图表实现:ECharts 与 pyecharts 怎么落地
4.1 视图层返回 JSON:JsonResponse 与序列化
图表要画在浏览器里,前端就要拿到结构化数据。Django 里直接返回 JsonResponse 就行,不需要走 Django REST Framework 那么重的链路,毕设系统的图表接口用 DRF 反而增加学习成本。
from django.http import JsonResponse from django.db.models import Count from census.models import CensusRecord def district_gender_api(request): rows = ( CensusRecord.objects .values('district', 'gender') .annotate(total=Count('id')) .order_by('district', 'gender') ) data = [ {'district': r['district'], 'gender': r['gender'], 'total': r['total']} for r in rows ] return JsonResponse({'code': 0, 'data': data})这段接口把分组和聚合全部放在数据库层完成,Python 这边只做一次轻量的列表推导,返回给前端的结构是平铺的 JSON 数组。前端拿到后自己按 district 归类,或者后端直接做成嵌套结构都可以。我一般返回平铺结构,因为前端用 ECharts 时,经常需要同时按 district 和 gender 两条维度做 series,平铺结构反而更灵活。
4.2 服务端生成图表:pyecharts 快速出图
如果不想写前端代码,pyecharts 是性价比最高的选择。它把 ECharts 的配置封装成了 Python 类,服务端直接生成 HTML 片段返回给模板渲染。
from pyecharts.charts import Bar from pyecharts import options as opts from django.http import HttpResponse from census.models import CensusRecord from django.db.models import Count def district_bar_chart(request): rows = ( CensusRecord.objects .values('district') .annotate(total=Count('id')) .order_by('-total') ) bar = ( Bar() .add_xaxis([r['district'] for r in rows]) .add_yaxis('人口数', [r['total'] for r in rows]) .set_global_opts(title_opts=opts.TitleOpts(title='各区县人口分布')) ) return HttpResponse(bar.render_embed())这里用的是 render_embed 而不是 render,差别在于 render 会生成一个完整的独立 HTML 文件,render_embed 只返回图表容器和初始化脚本,可以直接嵌到你的 base.html 模板里。pyecharts 适合时间紧、要求“有图能看能答辩”的场景,但它的交互定制空间有限,如果导师要求联动筛选、钻取,还是得走前端 ECharts 方案。
4.3 前端异步加载:ECharts 的 Ajax 接入
前后端分离是更通用、也更像真实项目的做法。Django 只负责提供 JSON 接口,前端用 ECharts 拉数据画图。模板页面里放一个容器 div,然后写一小段脚本:
<div id="main" style="width: 100%; height: 480px;"></div> <script src="https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js"></script> <script> fetch('/api/district-gender/') .then(r => r.json()) .then(res => { if (res.code !== 0) return; const chart = echarts.init(document.getElementById('main')); const districts = [...new Set(res.data.map(d => d.district))]; const males = districts.map(d => res.data.find(x => x.district === d && x.gender === 'M')?.total || 0 ); const females = districts.map(d => res.data.find(x => x.district === d && x.gender === 'F')?.total || 0 ); chart.setOption({ tooltip: { trigger: 'axis' }, legend: { data: ['男', '女'] }, xAxis: { type: 'category', data: districts }, yAxis: { type: 'value' }, series: [ { name: '男', type: 'bar', data: males }, { name: '女', type: 'bar', data: females } ] }); }); </script>这段脚本里最坑的地方是性别字段的匹配。如果数据库 choices 里存的是M和F,接口返回的也是这两个字母,就必须和前端比对一致。有的项目在模型里把 choices 设成男/女,前端也按中文比,但导出的 CSV 里写的是male/female,导入时没做转换,最终接口数据和图表就全对不上。我会在写模型时统一用短代码存库、显示名称用 get_gender_display 去拿,避免前后端各翻译一遍。
5. 部署避坑指南:静态文件、数据库与 uWSGI 的五个常见问题
5.1 DEBUG=False 后静态文件突然 404
现象:本地runserver一切正常,一关 DEBUG、跑完collectstatic再启动服务,页面只剩 HTML,CSS、JS 全部 404,图表区域空白。
原因:Django 开发服务器在 DEBUG=True 时会自动托管静态文件,关掉 DEBUG 之后这个行为就被禁用了。项目没有配置 STATIC_ROOT 也没有用静态文件中间件,collectstatic 只是把各 app 的静态文件复制到了一个目录,并没有一个服务去处理/static/请求。
解决:安装 whitenoise,在 settings.py 里把它加进 MIDDLEWARE,并设置 STATIC_ROOT:
MIDDLEWARE = [ 'django.middleware.security.SecurityMiddleware', 'whitenoise.middleware.WhiteNoiseMiddleware', # 其余中间件... ] STATIC_ROOT = BASE_DIR / 'staticfiles'然后执行python manage.py collectstatic --noinput。白噪声中间件会直接托管 STATIC_ROOT 下的文件,不需要额外配 Nginx 的 alias,对毕设级别的并发量完全够用。如果部署在 Nginx 后面,也可以让 Nginx 直接 alias 到 staticfiles 目录,但这需要改两处配置,不如 whitenoise 省事。
5.2 CSV 导入中文乱码与数据库字符集
现象:用 pandas 读 CSV 导入后,页面上显示的中文全是乱码,区县名变成一堆问号,或者在 Django admin 里看到的是乱码,但在本地 IDE 里跑同样的代码没问题。
原因:有两层。第一层是 CSV 文件编码不统一,有的是 GBK,有的是 UTF-8 带 BOM;第二层是 MySQL 表或连接字符集不是 utf8mb4,中文写入时被截断或转码失败。
解决:导入时先用chardet检测文件编码,或者干脆写一个自动尝试逻辑:
import pandas as pd def read_csv_auto(path): for enc in ['utf-8-sig', 'gbk', 'gb18030']: try: return pd.read_csv(path, encoding=enc) except UnicodeDecodeError: continue raise ValueError('无法识别的文件编码')数据库层面,创建库时显式指定字符集:
CREATE DATABASE census_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;settings.py 里也要注意别让连接串覆盖掉字符集。建议把 OPTIONS 写进 DATABASES 配置,确保连接使用的字符集和表一致。这种乱码问题最玄学,因为同样的命令在一台机器上正常、到另一台就乱,多半就是文件编码和数据库字符集各管各的。
5.3 部署后访问返回 403 Bad Request
现象:runserver正常,部署到服务器后用 IP 访问,浏览器直接显示403 Forbidden,日志里写着Invalid HTTP_HOST header。
原因:这是 Django 自身的安全校验。DEBUG=False 时,ALLOWED_HOSTS 默认不接受 IP 和任意域名访问,HTTP 请求头里的 Host 不匹配就直接拒掉。
解决:在 settings.py 里把服务器地址加进去:
ALLOWED_HOSTS = ['127.0.0.1', 'localhost', '192.168.1.100']如果部署环境不固定,也可以写成ALLOWED_HOSTS = ['*'],但这就意味着任意 Host 都能访问你的站点,不建议公开项目这么做。我一般按实际部署的域名和 IP 逐个填,省心也安全。
5.4 数据量大时聚合查询爬行:索引与预统计表
现象:几十万条普查记录,按区县分组查询需要两三秒,前端图表加载时一直转圈。内存看起来也没爆,就是慢。
原因:分组字段没有索引,或者索引没有覆盖到查询的组合。Mysql 在做 GROUP BY 时如果字段无索引,就需要 filesort 和临时表,数据量一上来就是慢查询。
解决:先查看 Django 生成的 SQL 是否符合预期,最简单的方式是打印查询集:
qs = CensusRecord.objects.values('district', 'gender').annotate(total=Count('id')) print(qs.query)把打印出来的 SQL 放到数据库里EXPLAIN一下,看有没有用到索引。如果你的模型里 district 和 gender 都单列建了索引,但组合查询仍慢,那就按本章前面给的例子建一个联合索引:models.Index(fields=['district', 'gender', 'age'])。如果慢查询还是存在,说明聚合结果本身适合做预统计表——写一个定时任务,每天把分组统计结果算好存到一张统计表里,页面接口直接查统计表。
5.5 uWSGI 启动后进程内存高:进程数与线程配置
现象:用 uWSGI 跑 Django,启动后还没几个人访问,内存就吃掉了好几个 G,偶尔直接 OOM 把进程杀了。
原因:uWSGI 默认会根据 CPU 核数生成进程,每个进程都预加载一份 Django 代码和数据库连接池。如果机器只有 2G 内存却按 4 核生成 4 个进程,每个进程吃 600M 内存,很快就爆。
解决:按内存而非核数配置进程数,并发不高时小内存机器用单进程多线程就够了:
[uwsgi] http = :8000 chdir = /home/user/census_project module = census_project.wsgi:application master = true processes = 2 threads = 2 harakiri = 60 max-requests = 2000 vacuum = true进程数 2 线程数 2 的意思是每个进程开 2 个线程,总共能处理 4 个并发请求,对毕设演示完全足够。max-requests 设成 2000 是让每个进程处理够 2000 个请求后自动回收,防止内存泄漏积累。真空模式会在退出时清理 socket 和 pid 文件,跑过uwsgi --stop后不会残留僵尸文件。
6. 最后的进阶技巧:给聚合查询加一层缓存,把响应从秒级降到毫秒级
统计接口写好后,页面加载还是慢,不一定是查询本身慢,而是每次刷新都重复执行一遍同样的 GROUP BY。人口普查数据不是高频写入,一小时内可能都没有新数据进来,完全可以把统计结果缓存住。Django 自带了一个缓存框架,默认的 LocMemCache 就能用,不需要上 Redis 也能体感明显。
from django.core.cache import cache from django.http import JsonResponse from django.db.models import Count from census.models import CensusRecord def district_gender_api(request): cache_key = 'stat:district_gender' data = cache.get(cache_key) if data is None: rows = ( CensusRecord.objects .values('district', 'gender') .annotate(total=Count('id')) .order_by('district', 'gender') ) data = [ {'district': r['district'], 'gender': r['gender'], 'total': r['total']} for r in rows ] cache.set(cache_key, data, 60 * 10) return JsonResponse({'code': 0, 'data': data})这个写法里,第一次请求会走完整查询并缓存 10 分钟,之后的所有请求都直接从缓存取数据。对于毕设演示场景,你甚至可以缓存一天。要注意的是缓存键的粒度,如果你按区县、按年龄段、按性别各写一个接口,每个接口都得用不同前缀的键,不能图省事用一个固定键,否则两个接口会互相覆盖数据。另一个技巧是把数据更新时间写进键里,比如stat:district_gender:20250612,数据重新导入时改一下日期参数,缓存自然失效。这样数据更新后刷新页面就能看到新统计,不需要重启服务。
我现在写聚合接口都会顺手把缓存加上,同时把查询逻辑单独抽成函数,方便以后换 Redis 后端或者做预统计任务时直接复用。缓存不是银弹,它是把重复计算变成读内存,但组合上联合索引和预统计表,这一套组合拳打下来,秒级响应基本能压到几十毫秒。希望帮到你。
本文还有配套的精品资源,点击获取