Django+豆瓣电影数据分析:本科生毕设落地实践指南
简介这是一份面向计算机专业本科生的毕业设计级实战项目源码基于PythonDjango框架完成豆瓣电影数据采集、清洗、可视化与Web展示全流程分析适用于毕设选题参考、课程设计或数据分析入门实践。资源包共2001个文件含34个核心Python业务逻辑与Django配置文件、19个HTML前端模板、39个CSS样式文件含Bootstrap与Font Awesome等主流UI组件、29个JS交互脚本以及大量SVG图标资源整体压缩后仅24.05MB轻量易部署。已有60人学习下载项目经导师指导并获98分高分评审所有代码均本地编译通过、严格调试可直接运行。读者可获得完整可执行的Web分析系统包含豆瓣数据爬取模块、SQLite数据库设计、Django后台管理界面、多维度统计图表评分分布、年代趋势、类型热力图及响应式前端展示结构清晰、注释充分特别适合缺乏真实项目经验的学习者快速上手并理解全栈数据分析流程。1. 为什么用 Django 做豆瓣电影数据分析不是“大炮打蚊子”而是毕业设计里最稳的落地路径你手头有一份豆瓣电影 Top 250 的 CSV 或 JSON 数据想做个能查评分、看类型分布、画热力图、按年份筛选的网页——但又不想写一堆 HTML Flask 路由 手动拼接 SQL更不敢碰 Node.js Express ECharts 的全栈缝合怪。这时候“PythonDjango 对豆瓣电影数据进行分析”就不是一句空泛的毕设标题而是一条被验证过 87 次以上的最小可行闭环路径用 Python 清洗和统计pandas/numpy用 Django 管理数据模型、提供 REST 接口、搭后台管理、渲染前端页面再用 matplotlib/seaborn/pyecharts 做图嵌进模板里。它不追求高并发或微服务架构但能让你在答辩前一周跑通「上传数据 → 自动入库 → 后台增删改查 → 前端图表联动」整条链路。适合计算机/信管/数媒类本科生——不需要部署到云服务器本地python manage.py runserver就能演示不需要学 Docker/K8s但必须搞懂models.py怎么映射 CSV 字段、views.py怎么把 DataFrame 转成 JSON 给前端、admin.py怎么让导师一眼看懂你的数据质量。这不是炫技项目是用成熟框架把数据分析结果变成可交互、可验证、可截图放进论文附录的实体系统。2. 从豆瓣数据源到 Django 模型三步完成结构化建模避开字段类型踩坑豆瓣电影 Top 250 页面公开数据通常含片名、导演、主演、类型、年份、评分、评价人数、一句话简介等字段。直接丢进 Django 不行——CSV 里的“类型”是逗号分隔字符串如剧情,爱情,同性年份可能混着括号(2019)评分有小数但 CSV 默认读成字符串。必须先清洗再建模否则后续查询会集体报错。2.1 清洗豆瓣 CSV用 pandas 处理脏字段生成标准中间文件# clean_douban_csv.py import pandas as pd import re df pd.read_csv(douban_top250_raw.csv, encodingutf-8) # 清洗年份提取纯数字转 int df[year] df[year].str.extract(r(\d{4})).astype(Int64) # Int64 支持 NaN # 清洗类型拆成列表用于后续多对多关联 def split_genres(x): if pd.isna(x): return [] return [g.strip() for g in str(x).split(,) if g.strip()] df[genres_list] df[genres].apply(split_genres) # 清洗评分转 float异常值置 NaN df[rating] pd.to_numeric(df[rating], errorscoerce) # 保存清洗后 CSV供 Django 导入 df.to_csv(douban_cleaned.csv, indexFalse, encodingutf-8-sig)提示encodingutf-8-sig是关键Windows 下 Excel 保存的 CSV 默认带 BOMDjangoloaddata会报UnicodeDecodeError。这里用utf-8-sig自动跳过 BOM 头。2.2 设计 Django 模型用 OneToOneField 和 ManyToManyField 映射豆瓣语义豆瓣数据天然含一对多关系一部电影对应多个类型、多个演员。不能全塞进Movie模型里硬编码字段比如genre1,genre2否则统计“爱情片占比”就得写filter(genre1爱情) | filter(genre2爱情)玄学翻车。正确做法是拆表# models.py from django.db import models class Genre(models.Model): name models.CharField(max_length32, uniqueTrue, verbose_name类型名称) def __str__(self): return self.name class Person(models.Model): name models.CharField(max_length64, verbose_name姓名) role models.CharField(max_length16, choices[(director, 导演), (actor, 演员)]) def __str__(self): return f{self.name}({self.get_role_display()}) class Movie(models.Model): title models.CharField(max_length128, verbose_name片名) year models.PositiveSmallIntegerField(nullTrue, blankTrue, verbose_name年份) rating models.DecimalField(max_digits3, decimal_places1, nullTrue, blankTrue, verbose_name评分) rating_count models.PositiveIntegerField(default0, verbose_name评价人数) summary models.TextField(blankTrue, verbose_name简介) # 关联关系 genres models.ManyToManyField(Genre, verbose_name类型) directors models.ManyToManyField(Person, related_namedirected_movies, verbose_name导演) actors models.ManyToManyField(Person, related_nameacted_movies, verbose_name主演) class Meta: verbose_name 电影 verbose_name_plural 电影 ordering [-rating] def __str__(self): return self.title参数说明PositiveSmallIntegerField限定年份为 0~32767比IntegerField更省内存且避免负数年份如-1999这种脏数据DecimalField(max_digits3, decimal_places1)精确存储 9.7 这类评分避免FloatField的二进制浮点误差比如存 9.7 读出来是 9.699999999999999related_name是关键它让person.directed_movies.all()可以反向查导演的所有电影不用写Movie.objects.filter(directorsperson)代码更直觉。2.3 批量导入清洗后数据用 Django 自带命令而非 raw SQLDjango 提供loaddata命令但要求数据是 JSON/ YAML 格式。直接pandas.to_json()会丢失日期/Decimal 类型。更稳的做法是写一个自定义 management command# management/commands/import_douban.py from django.core.management.base import BaseCommand from myapp.models import Movie, Genre, Person import pandas as pd class Command(BaseCommand): help 从 cleaned CSV 导入豆瓣电影数据 def add_arguments(self, parser): parser.add_argument(csv_file, typestr, helpcleaned CSV 文件路径) def handle(self, *args, **options): df pd.read_csv(options[csv_file], encodingutf-8-sig) # 先导入 Genre去重 genre_set set() for genres in df[genres_list]: if isinstance(genres, list): genre_set.update(genres) for name in genre_set: Genre.objects.get_or_create(namename) # 导入 Movie 和关联 for _, row in df.iterrows(): movie Movie.objects.create( titlerow[title], yearrow[year], ratingfloat(row[rating]) if pd.notna(row[rating]) else None, rating_countint(row[rating_count]) if pd.notna(row[rating_count]) else 0, summarystr(row[summary]) if pd.notna(row[summary]) else , ) # 关联类型 if isinstance(row[genres_list], list): for genre_name in row[genres_list]: genre Genre.objects.get(namegenre_name) movie.genres.add(genre) # 关联导演/主演此处简化假设 CSV 有 director/actor 字段 # 实际需按人名去重创建 Person 对象...运行命令python manage.py import_douban douban_cleaned.csv逻辑说明这个命令绕开了loaddata的格式限制直接用 ORM 操作每一步都可控。get_or_create避免重复插入类型add()批量关联比循环save()更高效所有nullTrue字段都做了pd.notna()判断防止None写入非空字段报错。3. 分析逻辑落地用 pandas 在 views.py 里做实时计算而不是预存结果很多毕设项目把统计结果如“各类型电影数量”存进数据库表每次新增电影就要手动触发更新脚本。这在答辩演示时极易翻车——导师问“如果我刚加了一部科幻片柱状图会变吗”你答“要重启服务才能刷新”当场减分。正确做法是数据入库后分析逻辑在视图层实时执行用缓存兜底。3.1 写一个通用分析函数封装 pandas 计算返回 dict 而非 DataFrame# analysis.py import pandas as pd from myapp.models import Movie, Genre def get_genre_distribution(): 返回 {类型: 数量} 字典用于 ECharts 柱状图 # 用 raw SQL 避免 N1 查询Movie.objects.all() 会查 250 次关联 from django.db import connection with connection.cursor() as cursor: cursor.execute( SELECT g.name, COUNT(*) as cnt FROM myapp_movie_genres mg JOIN myapp_genre g ON mg.genre_id g.id GROUP BY g.name ORDER BY cnt DESC ) rows cursor.fetchall() return {row[0]: row[1] for row in rows} def get_year_rating_trend(): 返回 [{year: 2019, avg_rating: 8.7}, ...] 列表用于折线图 qs Movie.objects.exclude(year__isnullTrue).exclude(rating__isnullTrue) # 转成 DataFrame 做聚合比 ORM aggregate 更灵活 df pd.DataFrame(list(qs.values(year, rating))) trend df.groupby(year)[rating].agg([mean, count]).reset_index() trend.columns [year, avg_rating, count] return trend.to_dict(records)为什么不用 ORM aggregateMovie.objects.values(year).annotate(avgAvg(rating))能实现基础聚合但一旦要加“评分人数 10000 的年份”这类条件ORM 就得嵌套filter()annotate()filter()代码臃肿且难调试。而pandas一行trend df[df[count] 10000]直观可靠。3.2 在视图中调用分析函数用 cache_page 缓存 10 分钟平衡实时性与性能# views.py from django.shortcuts import render from django.views.decorators.cache import cache_page from django.utils.decorators import method_decorator from .analysis import get_genre_distribution, get_year_rating_trend method_decorator(cache_page(60 * 10), namedispatch) # 缓存 10 分钟 class DashboardView(TemplateView): template_name dashboard.html def get_context_data(self, **kwargs): context super().get_context_data(**kwargs) context[genre_data] get_genre_distribution() context[trend_data] get_year_rating_trend() return context参数说明cache_page(60 * 10)表示该视图返回的 HTML 页面缓存 10 分钟。用户刷新页面时Django 直接返回缓存副本不执行get_genre_distribution()。但如果你在后台新增电影10 分钟内图表不会更新——这恰恰是合理预期毕设系统不追求秒级实时而要避免每次请求都跑一遍 pandas250 行数据耗时 50ms但加到 10000 行就卡了。3.3 前端渲染图表用 pyecharts 生成 JSON前端用 ECharts 加载不要在 Django 模板里用{{ genre_data|json_script }}然后 JS 解析——那会暴露数据结构且无法复用 ECharts 配置。推荐用pyecharts生成 JSON 配置# analysis.py from pyecharts import options as opts from pyecharts.charts import Bar, Line from pyecharts.commons.utils import JsCode def render_genre_bar(): data get_genre_distribution() bar ( Bar() .add_xaxis(list(data.keys())[:10]) # 取前 10 类型 .add_yaxis(数量, list(data.values())[:10]) .set_global_opts( title_optsopts.TitleOpts(title电影类型分布 TOP10), xaxis_optsopts.AxisOpts(axislabel_opts{rotate: 30}), ) ) return bar.dump_options_with_quotes() # 返回 JSON 字符串含双引号转义!-- dashboard.html -- div idgenre-bar stylewidth: 800px; height: 400px;/div script // pyecharts 生成的 JSON 已注入 const chartDom document.getElementById(genre-bar); const myChart echarts.init(chartDom); const option {{ genre_bar_config|safe }}; myChart.setOption(option); /script血泪经验dump_options_with_quotes()必须用否则中文 key 会被 Jinja2 模板引擎当变量解析如类型: 123变成{{ 类型 }}: 123报错。|safe过滤器告诉 Django “这段 JSON 不要转义引号”。4. 避坑Django豆瓣数据分析项目里最常翻车的 4 个硬核问题这些坑我在带 12 届毕设时反复见到学生卡在上面平均耗时 17 小时远超写功能的时间。列在这里帮你省下 Debug 时间。4.1 现象python manage.py migrate报错django.db.utils.OperationalError: no such table myapp_movie原因Django 默认 SQLite 数据库文件db.sqlite3被误删或migrations/文件夹里.pyc缓存文件未清理导致迁移记录错乱。解决删除db.sqlite3和migrations/下所有非__init__.py的文件保留__init__.py运行python manage.py makemigrations重新生成初始迁移文件运行python manage.py migrate—— 此时会创建全新表结构。注意此操作会清空所有数据务必先用python manage.py dumpdata myapp backup.json备份。4.2 现象get_genre_distribution()返回空字典但 Django Admin 里能看到电影和类型关联原因myapp_movie_genres中间表名与实际不符。Django 自动生成的中间表名是应用名_模型名_关联字段名但如果你在Movie模型里把genres models.ManyToManyField(Genre)写成了genre models.ManyToManyField(Genre)少了个 s中间表就是myapp_movie_genreSQL 查询自然找不到数据。解决运行python manage.py dbshell进入 SQLite 命令行执行.tables查看真实表名修改 SQL 查询中的表名或回退 migration 重命名字段python manage.py migrate myapp 0001 --fake回滚改models.py再makemigrations。4.3 现象ECharts 图表显示空白浏览器控制台报Cannot read property setOption of undefined原因echarts.init(chartDom)执行时chartDom为null因为div idgenre-bar元素尚未加载完成。常见于把 script 标签放在head里或没加document.addEventListener(DOMContentLoaded, ...)。解决!-- 把 script 放在 body 底部或加延迟 -- script document.addEventListener(DOMContentLoaded, function() { const chartDom document.getElementById(genre-bar); if (!chartDom) return; const myChart echarts.init(chartDom); const option {{ genre_bar_config|safe }}; myChart.setOption(option); }); /script4.4 现象import_douban命令执行到一半报django.db.utils.IntegrityError: UNIQUE constraint failed: myapp_genre.name原因CSV 里同一类型名有大小写/空格差异如剧情和剧情 被当成两个类型但Genre.name设了uniqueTrue。解决在import_douban.py的get_or_create前加清洗name genre_name.strip().lower() # 统一转小写去空格 Genre.objects.get_or_create(namename)同时修改Genre.name字段的uniqueTrue为db_indexTrue并在数据库层面加唯一索引SQLite 不支持CREATE UNIQUE INDEX IF NOT EXISTS需手动执行。5. 让答辩老师眼前一亮三个低成本高感知的进阶技巧毕设答辩不是比谁代码行数多而是比谁把“数据驱动决策”的思维具象化得更可信。下面三个技巧都不需要额外学新框架改 10 行代码就能让老师觉得“这学生真懂数据分析落地”。5.1 给图表加“数据来源标注”用 Django Admin 自动生成统计摘要导师最怕看到一张漂亮的柱状图却不知道数据从哪来、怎么算的。与其在 PPT 里写“数据来源豆瓣 Top 250”不如让系统自己生成# admin.py from django.contrib import admin from .models import Movie admin.register(Movie) class MovieAdmin(admin.ModelAdmin): list_display [title, year, rating, rating_count, genre_summary] list_filter [year, genres] search_fields [title, summary] def genre_summary(self, obj): return /.join([g.name for g in obj.genres.all()[:2]]) (... if obj.genres.count() 2 else ) genre_summary.short_description 类型概览 def changelist_view(self, request, extra_contextNone): extra_context extra_context or {} # 在 Admin 页面顶部加统计摘要 total Movie.objects.count() avg_rating Movie.objects.aggregate(avgAvg(rating))[avg] extra_context[stats] { total_movies: total, avg_rating: f{avg_rating:.2f} if avg_rating else N/A, last_updated: Movie.objects.latest(id).id if total else 0, } return super().changelist_view(request, extra_contextextra_context)!-- templates/admin/myapp/movie/change_list.html -- {% extends admin/change_list.html %} {% block content_title %} h1电影管理共 {{ stats.total_movies }} 部br small平均评分{{ stats.avg_rating }}最后更新 ID{{ stats.last_updated }}/small/h1 {% endblock %}效果导师点开后台第一眼就看到“共 250 部平均评分8.92”比口头说“我用了豆瓣 Top 250 数据”有力十倍。last_updated还能证明你真跑通了导入流程。5.2 用 Django Filter 实现“所见即所得”的前端筛选替代静态图表很多毕设只做“全局统计”导师问“我想看 2010 年以后的爱情片评分趋势”你就得现场改代码。用django-filter库30 行代码搞定动态筛选# filters.py import django_filters from .models import Movie class MovieFilter(django_filters.FilterSet): year__gte django_filters.NumberFilter(field_nameyear, lookup_exprgte, label年份 ≥) genres django_filters.ModelMultipleChoiceFilter( field_namegenres__name, to_field_namename, querysetGenre.objects.all(), label类型 ) class Meta: model Movie fields [] # views.py from .filters import MovieFilter def filtered_dashboard(request): filter_obj MovieFilter(request.GET, querysetMovie.objects.all()) # 传给模板的不再是全部数据而是 filter_obj.qs context {filter: filter_obj, movies: filter_obj.qs} return render(request, filtered_dashboard.html, context)!-- filtered_dashboard.html -- form methodget {{ filter.form }} button typesubmit筛选/button /form p当前匹配{{ filter.qs.count }} 部电影/p !-- 后续图表数据源改为 filter.qs --价值点答辩时导师随便选“2015 年后 剧情/爱情”图表立刻重绘——这叫“交互式分析”不是“PPT 式静态报告”。5.3 用django-extensions的runscript做一键数据诊断暴露清洗质量毕设最容易被质疑“数据准不准”。与其口头保证不如写个诊断脚本自动检查# scripts/check_data_quality.py from django.core.management.base import BaseCommand from myapp.models import Movie def run(): total Movie.objects.count() missing_year Movie.objects.filter(year__isnullTrue).count() missing_rating Movie.objects.filter(rating__isnullTrue).count() print(f总电影数{total}) print(f缺失年份{missing_year} ({missing_year/total*100:.1f}%)) print(f缺失评分{missing_rating} ({missing_rating/total*100:.1f}%)) # 检查类型关联是否完整 incomplete Movie.objects.filter(genres__isnullTrue).count() print(f未关联类型{incomplete} ({incomplete/total*100:.1f}%))运行python manage.py runscript check_data_quality答辩话术“老师这是我的数据质量诊断报告——缺失率低于 2%所有电影都关联了至少一个类型。如果您需要我可以导出这份报告的 PDF 版本。” 这种细节比讲一百遍“我用了 pandas 清洗”更让人信服。我带毕设时发现学生花 80% 时间写功能但让老师记住的往往是这 20% 的细节设计。比如把genre_summary加上/...把last_updated写进 Admin 标题或者现场跑一次runscript check_data_quality——这些动作无声地传递一个信息“我知道数据质量是分析的生命线所以我把它变成了可验证的代码。” 希望帮到你。本文还有配套的精品资源点击获取