简介:这是一份基于Python的B站用户行为分析系统设计与实现文档,面向需要完成大数据分析类课程设计、毕业设计或研究社交媒体用户行为的学生与开发者。内容围绕B站UP主行为与用户观看偏好展开,涵盖视频类型、视频标签、粉丝数、获赞数、互动与一键三连等指标,通过柱状图、折线图等可视化方式呈现,并融入数据清洗、预处理、数据挖掘及可视化工具的应用,能够为理解UP主影响力与内容推荐策略提供参考。资源包为1个docx文件,整体大小1.05MB,文档包含中英文摘要、目录、研究背景与意义、开发技术介绍等完整结构,便于直接阅读与借鉴。已有432人学习下载。借助这份文档,读者可快速把握系统整体架构与实现思路,作为撰写论文、制作答辩PPT或设计同类系统的参考资料。
1. 为什么要给 B 站 UP 主做一套行为分析系统
做数据分析的人大概都经历过这种尴尬:手里有一堆 B 站接口抓回来的 UP 主数据,却在如何真正“用起来”上卡住。单看粉丝数、播放量没有意义,真正有价值的命题是——这位 UP 主擅长什么内容、粉丝喜欢在什么时间看他、哪类视频最容易引发三连。把这几个问题拆开,就是一套用户行为分析系统的核心:以 UP 主为主体,把视频分类、标签、发布时间、互动数据变成可对比、可下钻的指标。
这套系统选型很明确:Python 做数据处理,Django 做 Web 框架,MySQL 做存储,前段图表交给 ECharts。技术栈不算新,但组合起来正好覆盖从「数据接入—指标建模—可视化展示」的完整链路。适合的人群有两类:一是要做毕业设计或课程项目的学生,拿它可以快速搭出完整的前后端项目;二是在做内容运营、平台分析的一线工程师,可以把里面的分析思路直接移植到自己的数仓看板中。下面按我自己拆项目的顺序,把设计要点、实现细节和排障经验展开讲。
2. 分析指标体系与数据模型设计
2.1 从用户行为到指标:先定维度再写代码
B 站的数据看似庞杂,真正落到分析层面,无非是「主体—行为—内容」三个维度的交叉。UP 主是主体,粉丝数、获赞数、播放量是状态指标;发布时间、投稿频率是行为指标;视频分区、标签是内容属性。这套系统把分析拆成五个模块:UP 主分析、用户分析、综合分析、多维分析、排名分析,本质就是对这三类维度做不同粒度的聚合。
在设计时我先确定了几个核心口径。UP 主分析关注的是个体:这位 UP 主最常发什么分区、什么标签,一周内哪几天活跃,粉丝增长和播放总量之间的关系。用户分析则反过来,把“人”作为主体,统计哪种视频类型最容易引发互动、分享、收藏三连。综合分析是对平台整体的观察:按小时、星期、月份统计视频发布量,观察平台内容供给的节奏。多维分析和排名分析则把多个指标叠加,找出头部 UP 主的共性特征。
这里有一个容易被忽略的点:所有指标必须先定义清楚计算逻辑,再写代码。比如“获赞数”是按 UP 主汇总还是按视频汇总?如果一位 UP 主有 100 条视频,总播放量是这 100 条之和,还是只统计最近 30 天?口径不一致,后面所有图表都是错的。我的做法是先画一遍指标字典表,把指标名称、计算公式、聚合粒度、时间范围写清楚,再进入数据库设计。
2.2 Django ORM 建表:多对多关系与冗余字段
数据库设计上,核心涉及三张业务表:UP 主信息表、视频信息表、用户行为表。UP 主表和视频表是一对多关系,视频表和标签表是多对多关系。MySQL 中多对多关系要建中间表,Django ORM 里用ManyToManyField可以自动生成,但建议手动建中间表,方便后续在中间表上加权重字段或时间维度。
# models.py from django.db import models class UpMaster(models.Model): mid = models.CharField(max_length=50, unique=True, verbose_name="B站UID") name = models.CharField(max_length=100, verbose_name="UP主昵称") fans = models.IntegerField(default=0, verbose_name="粉丝数") likes = models.IntegerField(default=0, verbose_name="获赞数") views = models.IntegerField(default=0, verbose_name="总播放数") reads = models.IntegerField(default=0, verbose_name="阅读数") created_at = models.DateTimeField(auto_now_add=True) class Tag(models.Model): name = models.CharField(max_length=50, unique=True) class Video(models.Model): bvid = models.CharField(max_length=20, unique=True, verbose_name="视频BV号") title = models.CharField(max_length=300, verbose_name="视频标题") zone = models.CharField(max_length=50, verbose_name="分区") pub_time = models.DateTimeField(verbose_name="发布时间") play = models.IntegerField(default=0) like = models.IntegerField(default=0) coin = models.IntegerField(default=0) favorite = models.IntegerField(default=0) share = models.IntegerField(default=0) up = models.ForeignKey(UpMaster, on_delete=models.CASCADE, related_name="videos") tags = models.ManyToManyField(Tag, through="VideoTagRelation")这段代码的关键设计有三处。mid和bvid都加了unique=True,这是为了防止爬虫重复写入脏数据;UpMaster表把粉丝、获赞、总播放、阅读数值冗余进来,避免每次分析都去 JOIN 视频表做聚合,查询性能会好很多;VideoTagRelation作为手动中间表,后续如果要对标签加权重、加统计时间范围,直接在这个表上扩展字段就可以。
视频和标签的多对多选择through参数手动管理中间表,而不是直接让 Django 自动生成,是因为后续做「最受欢迎的标签 TOP20」时,需要按标签维度 GROUP BY 再聚合播放、三连数据,有中间表在 SQL 写起来更直观。
2.3 数据采集策略与清洗规则
数据来源有两种常见路径:一种是用 B 站开放接口抓 UP 主投稿列表、视频详情、用户行为数据;另一种直接使用已有的公开数据集。接口方式要注意频率控制,B 站接口有风控,短时间高频请求会触发验证码,我会在采集脚本里加随机延时和代理池切换。
数据清洗的核心是去重和空值处理。视频表按bvid去重,UP 主表按mid去重。发布时间字段统一转为datetime类型,分区字段做映射——B 站分区 ID 和名称不一致时,需要维护一张映射表。数值字段如果接口返回"-"或空字符串,统一转0,避免后续聚合时类型报错。
3. 后端聚合逻辑与 Django 视图实现
3.1 按时间维度聚合发布量:从原始数据到图表数据
综合分析界面要求支持按时、按周、按月三个维度查看视频发布量。这个功能的本质是把视频表的pub_time字段按照不同的时间粒度分组统计。Django ORM 没有直接提供date_trunc,但可以通过extract或Trunc来实现。
# views.py from django.db.models.functions import TruncHour, TruncWeek, TruncMonth from django.db.models import Count def publish_trend(request): unit = request.GET.get("unit", "hour") trunc_map = { "hour": TruncHour("pub_time"), "week": TruncWeek("pub_time"), "month": TruncMonth("pub_time"), } trunc_func = trunc_map[unit] result = ( Video.objects .annotate(period=trunc_func) .values("period") .annotate(total=Count("id")) .order_by("period") ) data = [{"period": str(item["period"]), "count": item["total"]} for item in result] return JsonResponse({"code": 0, "data": data, "unit": unit})这段代码的逻辑是:通过TruncHour把pub_time截断到小时,再按截断后的时间分组计数。用户切换「按小时、按周、按月」时,前端传一个unit参数,后端在trunc_map中选择对应的截断函数,其他地方完全复用。这样一个视图就能支撑三个时间粒度,不需要写三个接口。
要注意的是TruncWeek的起始日,Django 默认一周从周日开始,而国内业务习惯周一作为一周起点。如果不处理,周一和周日的数据会被分到不同的周里,展示出来会有偏差。解决方式是在配置里设置WEEK_START,或者在查询前对日期做偏移。
3.2 榜单计算与 TopN 过滤
排名分析涉及五个维度:粉丝量排名、播放总量排名、投稿数量排名、阅读量排名和“最有实力 UP 主”排名。前四个是单指标排序,最后一个需要综合评分。综合评分没有标准答案,我采用的方案是「播放总量 + 获赞总数 + 粉丝数」三个指标做归一化后加权求和。
from django.db.models import Sum, F def rank_upmaster(request): rank_type = request.GET.get("type", "fans") # 各UP主视频指标汇总 agg_data = ( Video.objects .values("up_id") .annotate( total_play=Sum("play"), total_likes=Sum("like"), video_count=Count("id") ) ) result = [] for item in agg_data: up = UpMaster.objects.get(id=item["up_id"]) fans = up.fans or 0 total_play = item["total_play"] or 0 total_likes = item["total_likes"] or 0 # 分数归一化:每个指标除以所有UP主中的最大值 score = ( 0.4 * total_play / max_play + 0.3 * total_likes / max_likes + 0.3 * fans / max_fans ) result.append({ "name": up.name, "fans": fans, "score": round(score, 4) }) result.sort(key=lambda x: x["score"], reverse=True) return JsonResponse({"code": 0, "data": result[:20]})这个「最有实力」排名的计算思路可以作为模板复用到其他场景。归一化时没有用 Z-Score 而是用「除以最大值」,好处是指标量纲统一到 0~1 之间,且不受数据分布影响,缺点是对离群值敏感。如果出现某个头部 UP 主播放量是第二名几十倍的情况,其他人的分数会被压到很低,这种情况下建议改用对数归一化,即对每个指标取log1p后再做除法。代码逻辑上,先把视频表按up_id聚合出各 UP 主的播放、点赞和投稿数,再关联 UP 主表的粉丝数计算综合分,最后排序取前 20。
3.3 用户行为分析:互动、分享与三连的统计口径
用户分析模块要回答的问题是:哪类视频最容易被互动、被分享、被收藏和点赞。这里的数据来源是视频的互动数据,即每一条视频的like、coin、favorite、share字段。这些字段是用户行为的聚合结果,而不是原始的用户级行为日志。如果以后接入实时行为流,可以在用户行为表里按video_id和behavior_type分组统计。
def user_behavior_analysis(request): result = ( Video.objects .values("zone") .annotate( total_play=Sum("play"), total_like=Sum("like"), total_coin=Sum("coin"), total_fav=Sum("favorite"), total_share=Sum("share"), avg_like_rate=Avg(F("like") * 1.0 / F("play"), output_field=FloatField()) ) .order_by("-total_play") )这段 SQL 按分区聚合出播放、点赞、投币、收藏、分享的总量,并计算平均点赞率。互动率、分享率都可以套同一个公式,只是分子分母换字段。需要注意的是Avg里如果直接写F("like") / F("play"),两个整数字段相除在部分数据库里返回值是整数,0.5会变成0,所以先乘1.0强制转浮点。另外play可能为 0,需要先过滤掉play__gt=0的数据,否则会出现除零错误。
4. 可视化实现与多维分析交互
4.1 ECharts 柱状图与折线图的配置要点
前端图表统一使用 ECharts。UP 主分析页面需要展示视频类型分布、视频标签分布、发布频率趋势,三种图分别对应柱状图、柱状图、折线图。图表数据来自后端 JSON 接口,前端通过 Ajax 拉取后填入option对应字段。
// static/js/up_analysis.js fetch('/api/up/video_zone/') .then(res => res.json()) .then(data => { const chart = echarts.init(document.getElementById('zoneChart')); const option = { tooltip: { trigger: 'axis' }, grid: { left: '10%', right: '10%', top: '15%', bottom: '15%' }, xAxis: { type: 'category', data: data.map(item => item.zone), axisLabel: { rotate: 30 } }, yAxis: { type: 'value', name: '视频数' }, series: [{ type: 'bar', data: data.map(item => item.count), barWidth: '45%', label: { show: true, position: 'top' } }] }; chart.setOption(option); window.addEventListener('resize', () => chart.resize()); });ECharts 的配置有三处容易踩坑。第一是axisLabel的rotate属性,当分类轴标签文本较长(比如“手机游戏”“知识-社科人文”),不旋转会互相遮挡;第二是grid的留白,左侧10%是为了给 Y 轴刻度值留空间,如果图表左侧被裁掉,优先检查这个值;第三是window.addEventListener('resize'),页面容器尺寸变化时图表不会自动更新,必须手动调用chart.resize()。折线图与柱状图的不同只在series的type字段,数据结构和配置项完全兼容,做「发布数量趋势」时直接把bar换成line即可。
4.2 多维分析散点图的实现:三维指标降维
多维分析模块的设计目标是同时观察视频量、播放量、粉丝量三个指标之间的关系。三维数据直接展示需要 3D 散点图,但在 Web 端 3D 图表性能较差且交互复杂。我的做法是把三个指标映射到二维散点图上:X 轴为总播放量,Y 轴为视频投稿量,点的大小代表粉丝数。这样通过位置和气泡大小就可以同时展示三个维度。
const option = { xAxis: { type: 'log', name: '总播放量' }, yAxis: { type: 'log', name: '视频投稿数' }, series: [{ type: 'scatter', data: data.map(item => ({ value: [item.total_play, item.video_count, item.fans], name: item.up_name })), symbolSize: function(val) { return Math.max(8, Math.log2(val[2] + 1) * 3); }, itemStyle: { opacity: 0.6 } }] };这里的关键技巧是坐标轴使用type: 'log'对数刻度。B 站 UP 主数据极度偏态分布——头部 UP 主粉丝几百万,尾部 UP 主只有几十,如果直接用线性坐标,大部分点会挤在左下角,完全看不出分布规律。symbolSize用对数函数映射粉丝数到气泡大小,保证差异大的数据也能在图上区分出层级。Math.log2计算出来的值如果不加Math.max做下限控制,粉丝数少的小点会小到看不见,交互时鼠标也不容易点中。
4.3 Django 视图返回 JSON 与前端图表接口对接
图表模块需要遵循一个约定:视图只返回 JSON 数据,不渲染模板,页面骨架由 Django 模板负责,图表渲染全部交给前端。这样前后端职责分离,接口可以复用给其他终端,也不需要为每个图表单独写模板页面。
def api_rank_players(request): data = list( Video.objects .values("up__name") .annotate(total=Sum("play")) .order_by("-total")[:20] ) return JsonResponse({"code": 0, "data": data})接口路径建议全部放在/api/前缀下,与页面路由区分开。前端拿到 JSON 后再做map操作提取字段,避免二次请求。如果图表展示的数据和后端接口数据不一致,先打开浏览器开发者工具看 Network 面板,确认请求返回的 JSON 结构是否和前端代码里取字段名一致,这是图表不显示时最常见的定位方法。
5. 数据预处理:解决因数据缺失导致的统计结果偏差
5.1 空值与脏数据的处理规则
数据分析系统跑出来的图表如果和直觉不符,八成不是 SQL 写错,而是数据源本身有问题。B 站接口返回的数据里常见三类脏数据:分区字段为空、发布时间为默认的 1970 年、播放量和点赞量为 0。如果不处理,聚合结果会失真——比如TruncWeek会把所有无时间数据聚到 1970 年的第一周,形成一个莫名其妙的尖峰。
清洗逻辑放在 Django 的management/commands里做独立脚本,不放进主业务流程。原因很简单:采集、清洗、分析三个环节耗时差别大,主流程的请求应在秒级返回,而清洗任务可能分钟级,做成定时任务或手动触发更合理。
# management/commands/clean_data.py from django.core.management.base import BaseCommand from video.models import Video class Command(BaseCommand): def handle(self, *args, **options): # 清理无效发布时间 fixed_time = Video.objects.filter(pub_time__year=1970) print(f"invalid pub_time: {fixed_time.count()}") # 空分区数据归入未知类型 empty_zone = Video.objects.filter(zone__isnull=True).update(zone="unknown") # 播放量为0且点赞不为0的异常数据 abnormal = Video.objects.filter(play=0, like__gt=0) print(f"abnormal data: {abnormal.count()}")这里的filter(pub_time__year=1970)是一个精准的脏数据识别方法。B 站接口对个别视频返回的时间戳可能是 0 或负数,转成 datetime 后恰好落在 1970 年,用年份过滤查一次就能确认数量级。空分区统一归入"unknown",后续分析时会单独作一类展示,而不是被聚合函数直接丢弃。发现带play=0但点赞大于 0 的记录,说明接口某个字段提取有遗漏,需要回源头检查抓取逻辑。
5.2 基于 pandas 的字段补全与异常值标记
当数据量大了之后,清洗逻辑逐步复杂,继续用 Django ORM 的链式过滤就有些吃力。此时可以改用 pandas 做数据清洗,Django 查出来的数据转成DataFrame后做向量化处理,性能更好。
import pandas as pd data = list(Video.objects.all().values("id", "play", "like", "coin", "favorite", "share")) df = pd.DataFrame(data) # 互动率字段 df["interact_rate"] = (df["like"] + df["coin"] + df["favorite"]) / df["play"].replace(0, 1) # 异常值标记:播放量为0的记录 df["is_abnormal"] = df["play"] == 0 # 按播放量分位数打标 df["level"] = pd.qcut(df["play"], q=4, labels=["低", "中", "高", "极高"])df["play"].replace(0, 1)这一步很实用,避免了除零同时不改变其他正常数据的计算结果。用pd.qcut把播放量按分位数分成四档,可以快速给 UP 主做分层分析——把「极高播放量」的 UP 主单独拎出来看他们的共性。pandas 的方式便于数据分析过程中探索,生产环境还是建议把清洗逻辑固化在批处理脚本中,不要频繁前段计算。
5.3 清洗前后的数据对比验证
清洗没有标准答案,但必须要有前后对比。最直接的方式是跑一段 SQL 统计同一指标清洗前后的差异:
SELECT COUNT(*) AS total, SUM(play) AS total_play, AVG(play) AS avg_play, COUNT(DISTINCT zone) AS zone_cnt FROM video_video WHERE pub_time >= '2020-01-01' AND pub_time <= '2025-01-01';SELECT COUNT(*) AS total, SUM(play) AS total_play, AVG(play) AS avg_play, COUNT(DISTINCT zone) AS zone_cnt FROM video_video;第一段加了时间范围过滤,排除了时间戳异常导致聚到 1970 年的数据;第二段不过滤。两条 SQL 的total和avg_play差距如果超过 5%,基本可以确定是脏数据污染严重,需要回到清洗脚本里增加过滤规则。这个验证过程应该写进文档,防止后续重新采集时再次引入同类问题。
6. 排名窗口算法:榜单动态与滑动时间窗口
6.1 按时间窗口实现「上升最快 UP 主」榜单
静态榜单是「谁最多」,动态榜单是「谁涨得最快」。前者用SUM和ORDER BY就能做,后者需要对比不同时间窗口的数据。传统做法是把当前周期数据与上一周期数据放在同一张表里,计算增长率后排序。
这类需求在数据分析系统里很常见:想看“近 7 天粉丝增量 TOP10”,不能直接对全量粉丝数排序,而要按 UP 主分组后取近期与之前的差值。由于粉丝数是状态字段,不是增量字段,需要周期性采集快照,用快照差值计算增量。
from django.db.models import Max, Min def rising_snapshot(request): days = int(request.GET.get("days", 7)) # 快照表里按 up 主和时间取最新与最早 latest = Snapshot.objects.values("up_id").annotate(fans_now=Max("fans")) earliest = Snapshot.objects.values("up_id").annotate(fans_prev=Min("fans")) result = [] latest_map = {item["up_id"]: item["fans_now"] for item in latest} for item in earliest: up_id = item["up_id"] if up_id not in latest_map: continue diff = latest_map[up_id] - item["fans_prev"] result.append({"up_id": up_id, "diff": diff}) result.sort(key=lambda x: x["diff"], reverse=True) return JsonResponse({"code": 0, "data": result[:10]})这里用Max和Min取同一时间窗口内最早和最晚的快照值,需要快照表按周期写入数据。严格情况下应取每个 UP 主时间最早与最晚的记录,而不是最大最小值——如果粉丝数波动较大,Max会污染结果。更严谨的写法是先按up_id和时间排序,用窗口函数取第一条和最后一条差值,但 Django ORM 对窗口函数支持有限,可以改用 pandas 读取全部快照后做groupby().first()和groupby().last()。
实际场景中,若快照每天采集一次,这种滑动窗口算法能支持周榜、月榜等多种排行,且复用同一个函数主体。榜单需求从「粉丝总量」扩展成「互动增量」「评论增速」时,只需替换快照字段名,逻辑保持不变。这个思路也是这套系统后期加需求时最值得保留的部分——指标定义一变,只要保证快照在持续采集,历史榜单都能回溯复算。
本文还有配套的精品资源,点击获取