简介:基于Django框架开发的个性化文章推荐系统完整项目,面向计算机相关专业在校学生、教师及企业开发者,适用于毕业设计、课程设计或推荐系统入门进阶。系统综合运用数据库、机器学习、爬虫与网页交互技术,结合经典推荐算法,搭建基于网络热点的个性化文章推送平台,可帮助读者掌握从数据采集、特征处理到推荐引擎构建的完整流程。资源包共407个文件,以Python源码(63个py)、JavaScript脚本(79个js)、HTML页面(21个html)及CSS样式(29个css)为主,另含SQL数据库文件、图片素材、配置文件与说明文档,压缩包大小12.31MB,目录结构清晰,便于按模块学习与二次开发。目前已有209人学习下载,代码经测试运行成功。项目既可直接作为毕设答辩的完整方案,也可在此基础上扩展其他功能,对推荐系统感兴趣的初学者与进阶者均有参考价值。
1. 给 Django 项目加上个性化文章推荐的最短闭环
把 Django 内容站的主页从时间倒序改成“猜你喜欢”,最先出问题的不是算法,而是数据太薄:新用户只有几次点击,协同过滤矩阵几乎全空,公式再精确也排不出像样的结果。可行的做法是先把最小链路跑通,再逐步加复杂度。这套基于 Django 的个性化文章推荐系统,重点就在可交付的环环相扣:文章画像、用户偏好、召回排序、源代码和配套文档。
它解决的是中小型文章站最实际的诉求:只依赖文章标签和用户行为,在 Django 内部生成推荐列表,不需要引入外部算法服务。适合正在做软件综合实践作品的学生,也适合站点已经上线、想从“时间序”切换到“个性化排序”的工程师。默认你会建 Django 应用、能用 admin 和 ORM,但不要求有机器学习背景。
后文按工程主线推进:先定推荐策略,再落数据模型、推荐函数和接口,最后把源代码与文档整理成一套可复现的交付物。
2. 推荐策略选型:基于内容、协同过滤还是混合召回
在自建文章站里硬套公开推荐算法的常见误区,是拿 MovieLens 的规模来要求自己的数据。MovieLens 有几十万评分,文章站上线第一周可能只有几百条阅读点击。在这种场景下,一开始就做协同过滤,等价于用一个几乎全空的矩阵去算相似度,结果没法看。因此,第一版系统应该采用“基于内容召回为主,协同过滤为辅”的策略。
2.1 三种候选策略的适用场景
| 策略 | 需要的数据 | 冷启动表现 | 工程成本 | 什么时候别用它 |
|---|---|---|---|---|
| 基于内容 | 文章标签、用户阅读记录 | 较好 | 低 | 标签质量差时不建议 |
| 基于用户的协同过滤 | 大量用户行为 | 差 | 中 | 行为日志数量不足时 |
| 基于物品的协同过滤 | 行为记录和物品相似度 | 一般 | 中 | 文章更新快、矩阵频繁失效时 |
我的习惯是先把基于内容的方案跑通,再把基于用户的协同过滤作为补充分数叠上去。这样即使没有任何相似用户,也能依靠标签相似度给出可解释的推荐。
2.2 Jaccard 相似度以及它与余弦相似度的取舍
文章推荐的第一版不需要 TF-IDF,也不需要向量化。文章标签在落地时通常是字符串集合,用 Jaccard 系数算集合重叠比例最直接:两篇文章的标签交集越大,它们就越相似。相比余弦相似度,Jaccard 对“文章长度”和“高频词”不敏感,代码也更简单。
def jaccard_similarity(a: set, b: set) -> float: # 空集合没有交集,直接返回 0,避免除零 if not a or not b: return 0.0 union = a | b if not union: return 0.0 return len(a & b) / len(union)这段代码的逻辑很直白:a | b取并集,a & b取交集,最终得到 0 到 1 之间的相似度。调用前要确认a和b是集合而不是列表,否则&运算符会报错;从 Django ORM 拿到的标签列表需要先转换成set,例如set(article.tags.values_list("name", flat=True))。
文章内容如果只有纯文本而没有有效标签,这篇文章的标签集为空,算出来的相似度永远是 0。所以数据准备阶段要把“标签为空”的文章先过滤掉,或者给它们补一个默认分类,否则这些文章永远不会出现在候选池里。
2.3 混合推荐时的权重分配方法
基于内容召回的问题在于只会推荐和过去很像的文章,用户很快就会觉得重复。混合协同过滤可以缓解这个“信息茧房”问题,但直接改模型结构并不划算。常见做法是打分后加权合并:
def hybrid_score(content_score: float, cf_score: float, alpha: float = 0.7) -> float: # alpha 控制内容分数权重 if cf_score <= 0: return content_score return alpha * content_score + (1 - alpha) * cf_scorealpha是唯一需要调的参数。alpha=0.7时内容分数占七成,推荐结果偏保守,适合新用户;alpha=0.5时协同过滤贡献一半,活跃用户更容易发现新主题。注意cf_score可能为 0,这种情况直接返回content_score,保证候选列表不为空。
3. Django 数据层:文章标签、用户行为与画像存储
推荐系统的数据建模不需要设计成数仓那样复杂。在 Django 里只需要三个核心模型:文章、标签、用户点击行为。用户画像可以在每次推荐前实时计算,文章量不大的时候完全没有必要落一张画像表。
3.1 源码目录如何划分职责
| 文件或目录 | 核心职责 | 不需要做的事情 |
|---|---|---|
articles/models.py | 定义文章、标签、点击行为 | 不放推荐算法 |
recommender/engine.py | 画像、召回、排序、缓存 | 不直接写 ORM 查询 |
recommender/views.py | 接口参数解析、返回结果 | 不塞业务逻辑 |
config/settings.py | 注册应用、配置缓存 | 不写死环境差异 |
目录拆成这样,后续调试时能很快定位问题:推荐结果不对,去engine.py;数据没查到,去models.py;接口报错,去views.py。
3.2 三个核心模型如何支持个性化推荐
先建一个articles应用和一个recommender应用,模型主要在articles/models.py里。最基础的版本长这样:
# articles/models.py from django.conf import settings from django.db import models class Tag(models.Model): name = models.CharField("标签名", max_length=32, unique=True) def __str__(self): return self.name class Article(models.Model): title = models.CharField("标题", max_length=255) content = models.TextField("正文", default="") tags = models.ManyToManyField(Tag, blank=True, related_name="articles") published_at = models.DateTimeField("发布时间", auto_now_add=True) class Meta: ordering = ["-published_at"] class UserClick(models.Model): user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) article = models.ForeignKey(Article, on_delete=models.CASCADE, related_name="clicks") action = models.CharField("行为类型", max_length=16, default="read") created_at = models.DateTimeField("发生时间", auto_now_add=True) class Meta: indexes = [ models.Index(fields=["user", "created_at"]), ]逻辑说明:Article.tags是多对多关系,一个文章有多个标签,标签也能挂在多个文章上;UserClick记录用户行为,action字段可以扩展成read、like、collect,第一版只统计“读了”就够。on_delete=models.CASCADE表示用户删除后行为一并清理,避免脏数据。注意UserClick加了一条数据库索引Index(fields=["user", "created_at"]),因为推荐时总要先查某个用户最近的行为,没有索引则数据量稍大就会慢。
新用户清理测试数据时,很多人会逐条delete(),效率很低。只要行为数据不再需要,直接执行UserClick.objects.filter(user_id=target_user_id).delete()即可,一条 SQL 就能清掉该用户的所有行为,这也是 Django ORM 执行删除对象最直接的方式。
3.3 用 admin 录入标签并优化查询
标签质量直接影响推荐效果,所以后台录入体验要跟上。注册 admin 时用filter_horizontal,后台就能用双栏组件快速选择标签,不用在长列表里一个个找:
# articles/admin.py from django.contrib import admin from .models import Article, Tag, UserClick @admin.register(Article) class ArticleAdmin(admin.ModelAdmin): list_display = ("id", "title", "published_at") filter_horizontal = ("tags",) search_fields = ("title", "tags__name") @admin.register(Tag) class TagAdmin(admin.ModelAdmin): search_fields = ("name",) @admin.register(UserClick) class UserClickAdmin(admin.ModelAdmin): list_display = ("user", "article", "action", "created_at") list_filter = ("action",)输出推荐结果时最容易踩的坑是 N+1 查询:循环每篇文章去查 tags,文章一多接口就慢。正确做法是配合prefetch_related一次取全:
from django.contrib import admin from .models import Article def all_articles_with_tags(): return Article.objects.prefetch_related("tags").only("id", "title")prefetch_related("tags")会先把所有文章查出来,再一次性查关联标签,最后在 Python 内存里完成配对。
4. 推荐引擎落地:用户画像、相似度计算和结果 View
推荐引擎我建议放在独立的recommender/engine.py中。不用 pandas,也不用 NumPy,用字典和Counter就能在文章量五万以内跑出可接受的结果。推荐的本质可以拆成两步:给用户建画像,再给候选文章打分排序。
4.1 用字典组织标签画像
从用户最近的阅读记录中统计标签出现次数,就是最简单的用户画像。用户读过的文章中某个标签出现越频繁,代表他对这个主题越感兴趣。代码可以把时间窗口当参数传进来,避免一直算历史全量数据:
from collections import Counter from datetime import timedelta from django.utils import timezone from articles.models import UserClick def build_tag_index(): """返回 {文章ID: set(标签名)} 的映射,供推荐函数复用。""" index = {} articles = Article.objects.prefetch_related("tags").only("id") for article in articles: index[article.id] = set(article.tags.values_list("name", flat=True)) return index def build_user_profile(user_id, tag_index, recent_days=30): """统计用户最近 recent_days 天读过的标签。""" since = timezone.now() - timedelta(days=recent_days) clicks = UserClick.objects.filter( user_id=user_id, action="read", created_at__gte=since, ).only("article_id") profile = Counter() for click in clicks: for tag in tag_index.get(click.article_id, []): profile[tag] += 1 return profile这段代码把文章和标签的关系收敛到了一个字典里,Counter负责累加标签权重。recent_days=30的意义是只取最近 30 天的点击,太久远的行为会让用户画像越来越像历史画像,跟不上口味变化。
4.2 基于内容的候选打分函数
有了用户画像之后,遍历候选文章,计算每篇文章的标签与画像的重合程度,然后去掉已经读过的文章,得到候选排序。这里每篇文章的标签数量不同,用1 + len(tags)做分母做一点轻微惩罚,能避免超长标签文章因为标签多而占便宜:
def content_based_scores(user_id, tag_index, recent_days=30, top_k=50): profile = build_user_profile(user_id, tag_index, recent_days) if not profile: return {} clicked_ids = set( UserClick.objects.filter(user_id=user_id) .values_list("article_id", flat=True) ) scores = {} for article_id, tags in tag_index.items(): if article_id in clicked_ids: continue matched = 0.0 for tag in tags: if tag in profile: matched += profile[tag] / (1 + len(tags)) if matched > 0: scores[article_id] = matched return dict(sorted(scores.items(), key=lambda item: -item[1])[:top_k])top_k控制召回数量,不需要对全站文章排序,先取分数最高的 50 篇进入下一轮,减少后续计算量。这个分数目前只反映“用户和文章像不像”,不反映文章新旧。
4.3 叠加协同过滤分数
协同过滤这里选择基于用户的策略:找到与当前用户点击记录重叠最多的用户,把他们已读而当前用户没读的文章作为补充候选。用 Jaccard 相似度计算用户相似性,代码量很小:
from collections import defaultdict def similar_user_scores(user_id, tag_index, top_k=10): target_profile = build_user_profile(user_id, tag_index) if not target_profile: return {} rows = UserClick.objects.filter(action="read").values("user_id", "article_id") user_articles = defaultdict(set) for row in rows: user_articles[row["user_id"]].add(row["article_id"]) my_articles = user_articles.get(user_id, set()) neighbor_scores = [] for other_id, articles in user_articles.items(): if other_id == user_id: continue union = my_articles | articles if not union: continue score = len(my_articles & articles) / len(union) neighbor_scores.append((other_id, score)) result = defaultdict(float) for other_id, _ in sorted(neighbor_scores, key=lambda x: -x[1])[:top_k]: for aid in user_articles[other_id]: if aid not in my_articles: result[aid] += 1.0 return dict(result)用户相似度分数只取前 10 个“邻居”,降低噪声。计算出的result是其他用户推荐文章的加分项,每被一个相似用户读过就加 1.0。当用户行为很少时,这个函数返回空字典,不会影响基于内容的主流程。
最终在引擎入口合并两部分分数,并加上时间衰减。发布时间越久的文章,即使标签匹配也应该让位给新内容,衰减系数用0.5 ** (days / half_life),可以理解成 30 天后权重减半:
import math from datetime import datetime def time_decay(created_at, now, half_life_days=30): days = max((now - created_at).days, 0) return 0.5 ** (days / half_life_days) def engine_recommend(user_id, top_k=10, alpha=0.7): tag_index = build_tag_index() content = content_based_scores(user_id, tag_index) cf = similar_user_scores(user_id, tag_index) if alpha < 1.0 else {} merged = defaultdict(float) for article_id, score in content.items(): merged[article_id] += alpha * score for article_id, score in cf.items(): merged[article_id] += (1 - alpha) * score now = datetime.now() ranked = [] for article_id, score in merged.items(): article = Article.objects.get(pk=article_id) ranked.append((article_id, score * time_decay(article.published_at, now))) ranked.sort(key=lambda x: -x[1]) return [article_id for article_id, _ in ranked[:top_k]]engine_recommend里先调用内容召回,再按alpha决定是否补充协同过滤。注意这一段真实项目中不能用Article.objects.get(pk=...)逐条取文章,应该提前把Article对象放进字典,代码里保留最直白的写法是为了先跑通链路。
4.4 推荐结果的 API 视图
推荐接口用 JsonResponse 返回,方便前端和后台共同调用。参数top_k和alpha都从 URL 查询参数里读,方便调参时不改代码:
# recommender/views.py from django.http import JsonResponse from articles.models import Article, UserClick from .engine import engine_recommend def recommend_api(request): if not request.user.is_authenticated: return JsonResponse({"code": 401, "msg": "请先登录"}, status=401) try: top_k = min(int(request.GET.get("top_k", 10)), 50) alpha = float(request.GET.get("alpha", 0.7)) except ValueError: return JsonResponse({"code": 400, "msg": "参数格式不对"}, status=400) rec_ids = engine_recommend(request.user.id, top_k=top_k, alpha=alpha) items = [] for aid in rec_ids: article = Article.objects.only("id", "title", "published_at").get(pk=aid) items.append({ "id": article.id, "title": article.title, "published_at": article.published_at.isoformat(), }) return JsonResponse({"code": 0, "items": items})min(int(...), 50)避免客户端传入超大top_k拖垮接口;alpha的范围建议手动限制在 0.5 到 1.0 之间,小于 0.5 时内容召回权重过低,新用户推荐结果容易乱。实测阶段可以先用 admin 登录,然后访问/api/recommend/?top_k=10&alpha=0.7观察返回结果是否合理。
调参经验可以简单总结成一张表:
| 用户状态 | 建议 alpha | 原因 |
|---|---|---|
| 点击少于 10 次 | 0.9 | 协同过滤无数据,尽量靠标签匹配 |
| 点击 10 到 50 次 | 0.75 | 内容为主,协同为参考 |
| 活跃用户 | 0.5 到 0.6 | 引入更多探索性推荐 |
5. 源码交付时最关键的技巧:离线验证
系统的源代码和文档说明不仅要“能跑”,还要让另一个人按文档十分钟内复现。这里最重要的是在交付前用历史数据离线验证推荐参数,而不是直接上生产看反馈。
5.1 源码包里必须有什么
源码包的最简目录结构应该一眼能看懂:
article_recommend/ ├── config/ ├── articles/ ├── recommender/ ├── data/ │ └── sample_articles.json ├── scripts/ │ └── import_sample_data.py ├── docs/ │ └── 设计说明.md ├── requirements.txt └── README.mddata目录放手动构造的样例文章,避免新环境里没有数据无法验证;scripts专门放导入脚本和评估脚本;README.md里固定写清楚 Django 版本锁定方式、启动命令、数据导入步骤、推荐接口的访问地址。建应用时用的python3 manage.py startapp命令在 README 里也要体现,方便对照。
5.2 用时间切分法验证命中率
最常用的离线指标是hit_ratio@10:把用户行为按时间切分成前 80% 和后 20%,用前 80% 训练画像,用后 20% 里的文章去验证推荐列表是否命中。
# scripts/evaluate.py from collections import defaultdict def evaluate_hit_ratio(history, recommender, top_k=10): split_point = int(len(history) * 0.8) train = history[:split_point] test = history[split_point:] train_by_user = defaultdict(list) for row in train: train_by_user[row["user_id"]].append(row["article_id"]) test_by_user = defaultdict(set) for row in test: test_by_user[row["user_id"]].add(row["article_id"]) total = 0 hit = 0 for user_id, seen in test_by_user.items(): recs = recommender(user_id, top_k=top_k) hit += len(set(recs) & seen) total += len(seen) return hit / total if total else 0.0评估脚本调用recommender(user_id, top_k=top_k)而不是直接调用某个函数,就是为了能方便地替换不同算法版本做对比。跑完一次后,把alpha从 0.9 调到 0.5,再跑一次,观察命中率如何变化,比在黑盒里猜参数可靠得多。
最终交付前检查这几个细节:样例数据导入后每篇文章是否有标签;新用户首次访问接口是否返回最新文章兜底;把alpha=0.7时的推荐列表和alpha=0.5时的列表并排对比,确认结果有明显差异。这样,复制或评审项目的人不仅能复现结果,也知道该动哪个参数、在哪里看效果。
本文还有配套的精品资源,点击获取