先说结论:这套东西不是让你去搞什么顶会论文,而是把一个真正能跑的“非遗推荐”小系统从零搭出来。Python负责算,Flask负责把算法结果变成网页接口,协同过滤负责“猜你喜欢”,最后再用ECharts把数据分布和推荐结果画成图表,一眼能看懂系统在干什么。
很多朋友做推荐系统项目,要么只写算法跑个离线精度,要么只写前端页面没算法支撑。这套方案的好处在于:算法、后端、可视化全链路打通,而且非遗这个领域特别适合做推荐系统练手——数据量不大、语义明确、分类标准齐全,更重要的是有真实的“长尾兴趣”场景。戏曲、手工艺、民俗、传统医药这些项目,用户兴趣差异极大,恰好是协同过滤能发挥价值的地方。本文适合三种人看:一是课程作业或毕设需要完整项目的学生,二是想系统上手Flask+推荐算法的后端开发,三是做文化数字化相关产品的从业者。我会直接分享可复现的代码、表结构、接口设计,以及我踩过的坑。
1. 需求与项目设计思路:非遗推荐到底在推什么
1.1 非遗领域的数据特点与推荐逻辑
先理解一下场景:非物质文化遗产,包括民间文学、传统音乐、传统舞蹈、传统戏剧、曲艺、传统体育、传统美术、传统技艺、传统医药、民俗这十大类。每一类下面的项目差异非常大——喜欢昆曲的人,不一定对传统木结构营造技艺感兴趣;爱看皮影戏的人,可能也会喜欢剪纸,但未必会去关注针灸。这种“兴趣分散、语义丰富、个性化强”的特点,决定了推荐系统在这里有明确的用武之地。
传统的信息展示方式是什么?按分类罗列、按地区筛选、按热度排序。这有几个问题:第一,用户要主动搜索才能找到感兴趣的项目,而多数人对非遗的认知是很模糊的,根本不知道搜什么关键词;第二,热度排序造成马太效应,头部项目如昆曲、京剧、剪纸永远霸榜,大量冷门但极具地方特色的项目长期沉底;第三,分类浏览没有跨类推荐能力,用户明明对传统戏剧感兴趣,系统却不知道该把传统美术里的皮影也推给他。
协同过滤解决的就是这个问题。它的核心逻辑是:找到和你行为相似的用户,或者找到和你喜欢过的物品相似的物品,然后把它们推荐给你。放在非遗场景下,就是“喜欢昆曲的人也喜欢古琴”这种推理路径。这套逻辑不依赖对项目内容的理解,完全从用户行为数据出发,对文本语义复杂的非遗项目尤其合适——你不用去给每个项目打一大堆内容标签,只要用户有浏览、点赞、收藏、评分行为,就能构建出兴趣关联。
这个项目的功能目标很清晰:用户登录或选择身份标识后,系统返回一组个性化推荐的非遗项目;管理员或研究员可以在可视化页面上查看项目分类分布、用户行为统计、推荐热度变化。实际拆解下来有四个核心模块:
- 数据层:非遗项目基础信息、用户行为记录,存在SQLite或直接读CSV;
- 算法层:基于物品的协同过滤,计算项目相似度矩阵,生成推荐列表;
- 接口层:Flask提供REST API,前端页面通过fetch或AJAX调用;
- 展示层:推荐结果卡片、分类分布图表、行为统计图表。
1.2 技术选型:为什么是Flask + 协同过滤 + ECharts
选型这件事,我直接说结论:这个组合是目前做小型推荐系统落地最舒服的组合之一,没有之一。
先说Python。推荐算法的核心是矩阵运算和相似度计算,pandas处理表格、numpy做向量计算都是顺手的工具。你用Java或Go当然也能写协同过滤,但代码量至少翻一倍,而且pandas对CSV、Excel、SQLite的读取几乎是零成本,数据清洗效率极高。对于非遗这种数据源本来就不规整的领域,Python的灵活性是刚需。
然后说Flask。为什么不选Django?Django自带Admin后台、ORM、模板系统,功能强大,但对这种一个推荐系统核心加几个页面的项目来说太笨重了。Flask的轻量体现在两个地方:一是启动成本低,一个app.py加几个路由就能跑;二是灵活,你要用SQLite也好、直接用pandas读CSV也罢,完全没有ORM的束缚。特别是算法部分,你只需要在视图函数里调用一个纯Python函数就能返回JSON,这对推荐系统项目来说是极大的便利。热词里有人提到flask如何绑定到网页元素,这个问题的本质其实就是两条路:后端render_template渲染模板,或者前端用JavaScript fetch接口动态渲染,我会在第四章详细讲。
ECharts做可视化,理由就更直白了:中文文档友好、图表类型全、浏览器性能好、不需要花钱买授权,而且对个人项目来说完全够用。你在前端画一个饼图展示非遗项目的分类占比,画一个柱状图展示用户活跃度,画一个横向条形图展示热门推荐项目,全部是配置式开发。
关于推荐算法选型,我专门对比一下三种常见方案,看表格更清楚:
| 方案 | 核心思想 | 优势 | 劣势 | 适用场景 |
|---|---|---|---|---|
| 基于用户的协同过滤 | 找相似用户,推荐相似用户喜欢的东西 | 社交解释性强,适合用户量大于物品量的小系统 | 用户量大时相似度计算成本高,冷启动严重 | 用户少、物品变化快的场景 |
| 基于物品的协同过滤 | 找相似物品,推荐与历史喜欢物品相似的物品 | 稳定、可离线计算、适合长尾推荐,能解释“因为你喜欢A,所以推荐B” | 物品间相似度需预先计算,新物品入库后要重新计算 | 物品量适中、用户行为稳定的内容型系统 |
| 基于内容的推荐 | 用物品标签/属性做相似度 | 没有冷启动问题,不需要用户行为 | 依赖标签质量,推荐结果单调,缺乏惊喜度 | 文本特征清晰的项目 |
我做的是ItemCF,也就是基于物品的协同过滤,原因有两条。第一,用户的兴趣往往是一段时间内稳定的,非遗领域的用户行为表也不会太大,离线算好物品相似矩阵,请求时直接查,性能完全够;第二,推荐结果的可解释性好——“你喜欢昆曲,所以推荐古琴艺术”,这种逻辑容易被用户接受,在做可视化展示的时候也直观。当然,后面如果想优化,可以在ItemCF基础上叠加类别权重、时间衰减,甚至混合一个简单的基于内容的推荐来解决冷启动,这些我都会在后面的章节展开。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 数据准备与预处理:把非遗数据变成算法能吃的形状
2.1 数据来源与字段设计
做推荐系统,最理想的状态当然是接一个真实平台的日志数据。但实际做项目,尤其是一个课题或练手项目,数据往往需要自己构造。这套系统的数据分两张核心表:非遗项目表(items)和用户行为表(interactions)。
非遗项目表需要覆盖几个维度:项目ID、名称、所属类别(十大类)、所在地域、级别(人类非遗/国家级/省级)、简介。对于需要可视化展示的分类分布,类别和地域是两个最关键的维度;对于推荐页面的卡片展示,图片URL和简介文本是刚需。
我建议字段设计如下:
python复制# items.csv
item_id, name, category, region, level, intro, image_url
1,昆曲,传统戏剧,江苏,人类非遗,……
2,古琴艺术,传统音乐,北京,人类非遗,……
3,京剧,传统戏剧,北京,人类非遗,……
4,剪纸,传统美术,陕西,国家级,……
5,皮影戏,传统戏剧,河北,国家级,……
用户行为表是这个系统的灵魂,协同过滤完全依赖它。四个字段就够:user_id、item_id、behavior_type、timestamp。behavior_type我会设置成三种——view(浏览)、like(点赞)、collect(收藏)。为什么不直接放评分?因为在真实的非遗浏览场景里,用户很难像电商一样打分,浏览、点赞、收藏才是天然的行为信号。所以在预处理时,要把行为换算成伪评分:view=1,like=2,collect=3。
到这一步有人会问,行为数据从哪里来?我提供一个自己能快速落地的思路:脚本模拟。按照真实场景分布生成一批用户,再按照“每个用户关注1到3个类别,在类别内再随机浏览若干项目”的逻辑生成行为记录。虽然数据是造的,但生成逻辑贴近真实,算法层面的验证完全足够。
python复制# data/generate_items.py
import pandas as pd
import numpy as np
items_data = {
"item_id": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
"name": ["昆曲", "古琴艺术", "京剧", "剪纸", "皮影戏",
"中国剪纸", "南音", "太极拳", "景德镇手工制瓷", "二十四节气"],
"category": ["传统戏剧", "传统音乐", "传统戏剧", "传统美术", "传统戏剧",
"传统美术", "传统音乐", "传统体育", "传统技艺", "民俗"],
"region": ["江苏", "北京", "北京", "陕西", "河北",
"浙江", "福建", "河南", "江西", "全国"],
"level": ["人类非遗", "人类非遗", "人类非遗", "国家级", "国家级",
"人类非遗", "人类非遗", "国家级", "人类非遗", "人类非遗"],
}
df_items = pd.DataFrame(items_data)
df_items.to_csv("data/items.csv", index=False, encoding="utf-8-sig")
print(df_items)
这里有个细节:写CSV时我用的是utf-8-sig而不是utf-8。原因在于Windows上的Excel打开utf-8编码的CSV直接乱码,加了BOM头反而没问题。这种坑你踩过一次就不会忘了。
2.2 数据预处理:把行为日志变成评分矩阵
原始行为日志不能直接拿来算相似度,需要先聚合,这一步我称之为“行为到评分的映射”。核心代码非常短:
python复制# data/preprocess.py
import pandas as pd
interactions = pd.read_csv("data/interactions.csv")
behavior_weight = {"view": 1, "like": 2, "collect": 3}
interactions["rating"] = interactions["behavior_type"].map(behavior_weight)
# 同一个用户可能对同一物品有多次行为,取最大值避免重复计数
ratings = interactions.groupby(["user_id", "item_id"], as_index=False)["rating"].max()
# 构建用户-物品评分矩阵
matrix = ratings.pivot_table(index="user_id", columns="item_id", values="rating").fillna(0)
print(matrix.shape)
这里有两个容易被忽略的坑。第一,同一个用户对同一个项目既浏览又收藏,如果直接groupby求和,评分会被放大到4分甚至更高,这不符合原始行为的语义——最高行为等级就是收藏。所以用max而不是sum。第二,pivot_table出来的矩阵一定是稀疏的,用户很多、项目很多但交互很少,绝大多数单元格是0。在内存里直接用稠密矩阵算,项目几百个时没问题;如果项目上万了,就要用scipy.sparse做稀疏存储和稀疏矩阵运算。小项目阶段先用稠密矩阵,后面我会提升级方案。
预处理还有一个关键操作:过滤无效数据。比如行为表里出现item_id在项目表里找不到的项目,直接丢弃;user_id为空的记录、timestamp异常的数据,都要清掉。非遗项目的名称可能偶尔有错别字,但ID是准的,所以尽量基于ID关联。
预处理完成后,把这个矩阵保存成npy或者直接塞进pickle:
python复制matrix.to_pickle("data/rating_matrix.pkl")
在Flask启动时直接加载这个pickle,就不用每次启动都重新加工数据了。这也是一个提升启动效率的小技巧。
3. 协同过滤算法核心实现:ItemCF完整代码拆解
3.1 相似度计算原理:用“口味相似”的思路推荐非遗
基于物品的协同过滤,核心思想用一句话概括:物以类聚。它假设喜欢项目A的用户群体和喜欢项目B的用户群体重合度越高,A和B就越相似。然后,当用户对某个项目表现出兴趣时,系统就把和它最相似的几个项目推荐给用户。
生活化类比一下:你去一家私房菜馆吃饭,告诉老板你喜欢吃红油抄手。老板说,常点红油抄手的客人通常会再点一份甜水面,因为两者的口味标签大量重合。这个“菜单上的隐性关联”就是协同过滤发现的。老板不需要懂川菜的理论,只需要统计客人的点餐行为。
数学表达上,物品i和物品j之间的相似度,通常用余弦相似度计算:
code复制sim(i, j) = cos(R_i, R_j) = (R_i · R_j) / (|R_i| × |R_j|)
其中R_i是一个向量,表示所有用户对物品i的评分。如果两个物品在相同的用户群体上都有较高的评分,它们的向量方向就越接近,余弦值越接近1。
为什么要用余弦相似度而不是欧氏距离?有一个很关键的原因:评分矩阵的稀疏性导致向量里大量是0。欧氏距离受向量长度影响很大,比如一个热门项目有200个用户评过分,冷门项目只有5个用户评过分,它们的向量长度天然不同,欧氏距离算出来完全不具有可比性。余弦相似度只关心方向,对向量长度不敏感,适合这种场景。
3.2 从矩阵到推荐:完整Python实现
下面是这套系统的算法核心,我会把每一步的作用讲明白。先看计算物品相似度矩阵:
python复制# recommender/itemcf.py
import numpy as np
import pandas as pd
class ItemCF:
def __init__(self, rating_matrix: pd.DataFrame):
self.matrix = rating_matrix
self.item_sim = None
self._build_sim_matrix()
def _build_sim_matrix(self):
# 矩阵转置:得到物品-用户矩阵
item_users = self.matrix.T.values.astype(np.float64) # shape: (n_items, n_users)
# 分母处理:防止除零错误
item_norm = np.linalg.norm(item_users, axis=1, keepdims=True)
item_norm[item_norm == 0] = 1
# 余弦相似度
normalized = item_users / item_norm
self.item_sim = np.dot(normalized, normalized.T)
# 把对角线(物品与自身)置0,因为推荐时需要排除自身
np.fill_diagonal(self.item_sim, 0)
def recommend(self, user_id, top_n=10, exclude_seen=True):
if user_id not in self.matrix.index:
return [] # 冷启动用户单独处理
user_vec = self.matrix.loc[user_id].values.astype(np.float64)
# 加权求和:每个物品对用户的总推荐得分
scores = self.item_sim.dot(user_vec)
if exclude_seen:
# 用户已经交互过的项目不再推荐
seen = user_vec > 0
scores[seen] = -1
# 按得分降序,保持原始索引
item_indices = np.argsort(scores)[::-1]
result = []
for idx in item_indices[:top_n]:
if scores[idx] <= 0:
continue
item_id = self.matrix.columns[idx]
result.append((item_id, round(float(scores[idx]), 4)))
return result
代码量很少,但里面有三个细节值得单独说。
第一个细节是归一化的方式。求余弦相似度时,先对物品向量做L2范数归一化,再算点积,比直接套公式快很多,而且省去循环。第二个细节是self.item_sim.dot(user_vec)这一步:user_vec是用户对每个物品的评分向量,item_sim是物品间的相似度矩阵,点积的结果,就是每个候选物品相对于用户所有历史行为物品的加权相似度总和,这就是推荐得分。第三个细节是exclude_seen,推荐系统如果不排除用户已经看过的项目,推荐列表里会出现一堆用户早就看过的内容,体验极差。
实测一下这个类的效果。假设用户101浏览过《昆曲》、收藏过《古琴艺术》,那么他的user_vec中对应位置分别是1和3,其余为0。计算得分时,《京剧》和《昆曲》的相似度如果较高,《皮影戏》和《古琴艺术》的相似度较高,它们就会排进推荐列表。这个逻辑完全符合直觉。
再来说冷启动,这是实际落地时一定绕不开的问题。我把冷启动拆成两部分:
- 新用户冷启动:矩阵里没有这个用户的行。处理办法很简单,推荐全局热门项目,也就是被收藏和点赞最多的前N个项目。
- 新项目冷启动:新项目刚入库,没有任何用户行为,相似度矩阵里它和所有物品的相似度都是0。处理办法是先用类别兜底,比如新项目属于“传统戏剧”,那就先归入传统戏剧类别的候选池,等积累了一定行为数据后再进协同过滤。
下面是带冷启动处理的完整调用逻辑:
python复制# recommender/recsys.py
import pandas as pd
from recommender.itemcf import ItemCF
def get_hot_items(interactions, top_n=10):
# 收藏权重高,点赞次之
weight = {"view": 1, "like": 2, "collect": 3}
interactions["w"] = interactions["behavior_type"].map(weight)
hot = interactions.groupby("item_id")["w"].sum().sort_values(ascending=False)
return hot.head(top_n).index.tolist()
def recommend_for_user(user_id, model: ItemCF, interactions, items_df, top_n=10):
if user_id not in model.matrix.index:
# 冷启动:热门兜底
hot_ids = get_hot_items(interactions, top_n)
return items_df[items_df["item_id"].isin(hot_ids)].to_dict("records")
rec_ids = [item_id for item_id, _ in model.recommend(user_id, top_n)]
return items_df[items_df["item_id"].isin(rec_ids)].to_dict("records")
这一步项目里已经有80%的核心算法了。后面要做的,就是让Flask把这个推荐结果吐给前端。
4. Flask后端搭建与推荐接口设计
4.1 项目结构与路由规划
Flask部分我习惯用分层但不重的结构:app.py放启动和路由,recommender目录放算法,data目录放数据,templates和static放前端页面。目录结构如下:
text复制nonheritage_recsys/
├── app.py
├── recommender/
│ ├── __init__.py
│ ├── itemcf.py
│ └── recsys.py
├── data/
│ ├── items.csv
│ ├── interactions.csv
│ └── rating_matrix.pkl
├── static/
│ └── js/
│ └── main.js
├── templates/
│ └── index.html
└── requirements.txt
Flask路由我只写必要的几个:
- GET /:首页,渲染index.html模板
- GET /api/recommend/<user_id>:返回推荐结果JSON
- GET /api/stats:返回全局统计数据,供可视化图表使用
- GET /api/item/<item_id>:项目详情
不写用户登录注册模块,是刻意为之。这个系统的核心目的不是做一个完整业务平台,而是展现推荐和可视化能力。用户身份用一个下拉框或输入框模拟,选择用户ID即可查看专属推荐。如果你想改成完整的注册登录体系,后续在user_id这里接入session即可。
4.2 接口实现:算法和Web之间的桥梁
以下是app.py的核心代码:
python复制# app.py
import pandas as pd
from flask import Flask, render_template, jsonify, request
from recommender.itemcf import ItemCF
from recommender.recsys import recommend_for_user
app = Flask(__name__)
# 全局加载一次模型,不重复加载
items_df = pd.read_csv("data/items.csv", encoding="utf-8-sig")
interactions_df = pd.read_csv("data/interactions.csv", encoding="utf-8-sig")
rating_matrix = pd.read_pickle("data/rating_matrix.pkl")
model = ItemCF(rating_matrix)
@app.route("/")
def index():
users = rating_matrix.index.tolist()
return render_template("index.html", users=users)
@app.route("/api/recommend/<int:user_id>")
def api_recommend(user_id):
recs = recommend_for_user(user_id, model, interactions_df, items_df)
return jsonify({"user_id": user_id, "recommendations": recs})
@app.route("/api/stats")
def api_stats():
# 分类分布
category_count = items_df["category"].value_counts()
# 流行度:行为数量统计
behavior_count = interactions_df["behavior_type"].value_counts()
# 热门项目Top5(按收藏数)
collect_count = interactions_df[interactions_df["behavior_type"] == "collect"] \
.groupby("item_id").size().sort_values(ascending=False).head(5)
# 需要把item_id映射为名称
item_map = dict(zip(items_df["item_id"], items_df["name"]))
hot_items = [{"name": item_map.get(iid, str(iid)), "value": int(cnt)}
for iid, cnt in collect_count.items()]
return jsonify({
"category": [{"name": k, "value": int(v)} for k, v in category_count.items()],
"behavior": [{"name": k, "value": int(v)} for k, v in behavior_count.items()],
"hot_items": hot_items
})
if __name__ == "__main__":
app.run(debug=True, host="127.0.0.1", port=5000)
有两个地方我是故意这样写的,需要提醒你。第一,model这个全局变量在Flask启动时就加载完毕,每个请求直接用,不会重复构建相似度矩阵。如果你把相似度计算放在每个请求里,当项目数量和用户数量大了以后,接口响应时间会指数级上升。第二,jsonify返回的默认是ASCII编码,如果结果里有中文,会在浏览器里看到\u67d8这种转义字符。虽然功能没问题,但调试时很难看。可以加一个Flask配置:
python复制app.config["JSON_AS_ASCII"] = False
Flask 2.3之后这个配置项改成了:
python复制app.json.ensure_ascii = False
两种写法我都列出来,因为网上很多旧教程还在用前者,新版本已经不再推荐。
4.3 模板渲染与前后端数据交互
热词里有“flask如何绑定到网页元素”,这是个很典型的问题。我在项目里用了两种方式,分别对应不同场景:
第一种是服务端模板渲染。比如首页需要在下拉框里展示所有用户ID,我直接在render_template时传入users列表:
python复制return render_template("index.html", users=users)
模板里这样渲染:
html复制<select id="userSelect">
{% for uid in users %}
<option value="{{ uid }}">{{ uid }}</option>
{% endfor %}
</select>
第二种是API接口+前端fetch渲染。推荐结果、图表数据都是动态加载的,不适合在页面初始渲染时一次性拿全。做法是在页面加载完后,用JavaScript请求接口:
html复制<div id="recList" class="rec-card-list"></div>
<script src="{{ url_for('static', filename='js/main.js') }}"></script>
javascript复制async function loadRecommendations(userId) {
const res = await fetch(`/api/recommend/${userId}`);
const data = await res.json();
const container = document.getElementById('recList');
container.innerHTML = '';
data.recommendations.forEach(item => {
const card = document.createElement('div');
card.className = 'rec-card';
card.innerHTML = `
<h4>${item.name}</h4>
<p>${item.category} · ${item.region}</p>
<span>${item.level}</span>
`;
container.appendChild(card);
});
}
这套“后端给接口、前端做渲染”的模式,在可视化项目里是标配,也是从Flask入门走向前后端分离的必经之路。很多人问我Flask怎么和Javascript交互,本质上就是通过接口交换JSON,Flask只管把它计算的结果序列化成JSON返回,剩下都是前端的事。
5. 可视化大屏:把推荐结果和数据分布画出来
5.1 页面布局与可视化方案设计
可视化不是花架子。这套系统的信息展示目标有三个:第一,让用户直观看到推荐结果,和“热门推荐”做对比,感知个性化推荐的差异;第二,让开发者或研究者看到数据底层结构,比如非遗项目在十大类中的分布是否均衡;第三,通过行为数据分布评估推荐系统的效果,比如收集了多少浏览、点赞、收藏行为,热门项目的收藏占比是否过于集中。
基于这三个目标,我把可视化大屏分成四个区域:
- 区域一:用户选择器与推荐结果卡片流,放在页面中央偏左,核心交互区
- 区域二:非遗项目分类分布饼图,放在右上方,让用户一眼看到项目库的构成
- 区域三:用户行为类型分布柱状图,放在右下方,展示view/like/collect的比例
- 区域四:热门非遗项目Top5横向条形图,配合推荐结果展示“全局热度 vs 个性化推荐”的差异
这样的布局是有讲究的:个性化推荐是主角,置于视觉中心;分类分布和数据行为统计是辅助信息,放在侧边。可视化大屏不是把所有图表堆在一起,而是要分清主次。前期我自己就吃过亏,五六个图表平铺在页面上,用户根本不知道先看哪里,后来聚焦成一个主推荐区加三个辅助图表,体验立刻改善了。
5.2 核心图表实现:ECharts接入Flask数据
ECharts的接入方式很简单:在模板中引入CDN,然后写一个初始化函数,从Flask接口拉数据填充图表。我直接给出可用的代码。
饼图展示非遗项目分类分布:
html复制<div id="categoryChart" style="width: 100%; height: 320px;"></div>
javascript复制// 在模板页面的script标签中或static/js/main.js中
const categoryChart = echarts.init(document.getElementById('categoryChart'));
fetch('/api/stats')
.then(res => res.json())
.then(data => {
categoryChart.setOption({
title: { text: '非遗项目分类分布', left: 'center' },
tooltip: { trigger: 'item' },
series: [{
type: 'pie',
radius: '60%',
data: data.category
}]
});
});
柱状图展示行为分布:
javascript复制const behaviorChart = echarts.init(document.getElementById('behaviorChart'));
fetch('/api/stats')
.then(res => res.json())
.then(data => {
const names = data.behavior.map(b => b.name);
const values = data.behavior.map(b => b.value);
behaviorChart.setOption({
title: { text: '用户行为类型分布', left: 'center' },
xAxis: { type: 'category', data: names },
yAxis: { type: 'value' },
series: [{
type: 'bar',
data: values,
itemStyle: { color: '#5470c6' }
}]
});
});
热门项目Top5条形图:
javascript复制const hotChart = echarts.init(document.getElementById('hotChart'));
fetch('/api/stats')
.then(res => res.json())
.then(data => {
const sorted = data.hot_items.sort((a, b) => a.value - b.value);
hotChart.setOption({
title: { text: '热门非遗项目Top5(按收藏)', left: 'center' },
yAxis: { type: 'category', data: sorted.map(i => i.name) },
xAxis: { type: 'value' },
series: [{
type: 'bar',
data: sorted.map(i => i.value),
label: { show: true, position: 'right' }
}]
});
});
这三个图表写下来,你应该能看出模式:Flask只负责把统计数据整理成JSON,剩下全是ECharts的配置项。这种模式的好处是,Flask后端和前端展示彻底解耦,后续你即使换掉整个机器学习算法,前端一行不用改,只要保证API返回结构不变。
可视化对推荐系统还有一个隐蔽的价值:发现数据质量问题。我在调试的时候从饼图上发现类别分布严重不均,传统戏剧占了40%以上,传统医药几乎没有项目。这个现象反映的是数据构造阶段对类别覆盖不全面,后来我在生成模拟数据时增加了冷门类别,推荐效果也跟着变好了。图表不只是给用户看的,更是给开发者校验数据用的,这一点在文档里很难讲清楚,实际跑一遍才有体感。
6. 系统部署与常见问题排查
6.1 本地部署流程:从零跑起来
整个项目跑起来,按照下面的顺序操作,基本十分钟内能启动成功。
第一步,准备环境。Python版本推荐3.8以上,建议用虚拟环境隔离项目依赖:
bash复制python -m venv venv
source venv/bin/activate # Windows: venv\Scripts\activate
第二步,安装依赖。requirements.txt内容如下:
text复制flask==3.0.0
pandas==2.1.4
numpy==1.26.3
安装命令:
bash复制pip install -r requirements.txt
第三步,生成数据。先运行数据生成脚本生成items.csv和interactions.csv,再运行预处理脚本生成rating_matrix.pkl:
bash复制python data/generate_items.py
python data/preprocess.py
这里有个坑要提前说:两个脚本的编码要保持一致。generate_items.py写入时用了utf-8-sig,preprocess.py读取时用的utf-8-sig,如果两边不一致,中文名称就全部乱码,后面推荐列表里全是乱码项目名。
第四步,启动服务:
bash复制python app.py
看到Running on http://127.0.0.1:5000之后,浏览器打开这个地址即可。首次访问的时候,页面加载速度会比较快,因为模型在启动阶段已经预加载好了。
6.2 排查实录与踩坑速查表
我把实际调试过程中遇到的问题整理成一个速查表,这些坑你在做Flask+推荐系统项目的时候大概率会碰到。
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 浏览器中文显示乱码 | CSV文件编码不一致,或Flask JSON返回ASCII转义 | 统一使用utf-8-sig编码;设置app.json.ensure_ascii = False |
| 推荐结果全部为空 | 用户没有足够行为,或相似度矩阵全为0 | 检查该用户在评分矩阵中是否有非零行;冷启动用户返回热门列表 |
| 首页加载特别慢 | 每次请求都重复计算相似度矩阵 | 将相似度矩阵计算放在模块加载阶段,使用全局变量缓存 |
| 端口被占用 | 5000端口已被其他程序使用 | 启动时指定其他端口,如app.run(port=5001) |
| JSON接口直接返回500 | 代码内部异常,比如item_id类型不一致 | 打开debug=True查看完整堆栈;检查item_id在两张表中类型是否一致 |
| 推荐结果里有重复项目 | npm的top_n截取前没有排重 | 在推荐生成处维护一个已推荐集合,排除之前已加入的item_id |
还有一个高频问题:为什么在前端点击用户下拉框后,推荐的卡片不刷新?这个通常是事件绑定问题。如果你用模板渲染生成下拉框,必须等页面加载完再挂事件;如果你用JavaScript动态生成option,那事件代理要绑定在select的父级上。我的做法是把监听事件绑定在select元素上,用change事件触发loadRecommendations。
6.3 可扩展方向:从Demo走向真实系统
本地跑通之后,这个项目还留了很多扩展空间,我根据自己的经验给几个建议方向,按优先级排序。
第一个方向是混合推荐。目前系统只有ItemCF,冷启动和不热门项目都存在明显短板。在ItemCF之上加一个基于内容的推荐模块,用非遗项目的类别、地域、级别做标签相似度,这样新项目一入库就有推荐出口。两个算法以加权方式融合,比如0.6乘ItemCF得分加0.4乘内容相似度得分,整体鲁棒性会大幅提升。
第二个方向是引入时间衰减。用户兴趣是会变化的,一年前收藏的项目对今天的行为影响力应该降低。改造方式很简单,在构建评分矩阵时,对行为评分乘以一个时间衰减系数,比如:
python复制# 衰减公式:score = base_score * exp(-decay * days_ago)
decay = 0.002
interactions["days_ago"] = (reference_date - interactions["timestamp"]).dt.days
interactions["rating"] = interactions["rating"] * np.exp(-decay * interactions["days_ago"])
这样近期的浏览行为权重更高,推荐结果对用户最新的兴趣变化更敏感。
第三个方向是把数据规模放大。当项目量达到几千、用户量达到几万时,pandas的稠密矩阵会撑爆内存。这时候需要把矩阵换成scipy.sparse.csr_matrix,把相似度计算改成批量矩阵乘法,再加上物品索引到名称的映射。如果规模进一步扩大,就需要上Spark或者用Faiss做向量相似性检索,这些是更远的扩展方向,但不是这个项目当前的重点。
最后分享两点实际体会
这个项目做完,我最大的感触是:推荐系统的门槛不在算法,而在数据和场景的契合度。ItemCF的实现代码总共就二十行,但要让它在非遗这个场景下跑得顺,你得想清楚行为怎么定义、伪评分怎么定、冷启动怎么兜底、可视化怎么辅助调试。这些细节才是项目中真正花时间的地方。
另外一个技巧是:尽量先用CSV把全链路跑通,再考虑上数据库。很多人一上来就接MySQL或者MongoDB,搞了半天连环境都没配好,算法还没跑起来。CSV方案的好处是数据可读、调试方便、环境依赖少。等确认算法和接口都正常了,再把数据层替换成SQLite或MySQL,这样成本最低。
如果你正在做类似的推荐系统课题,建议先把这个项目的骨架搭起来,跑通之后在这个基础上添加业务逻辑。非遗类的推荐场景还远没有到竞争红海,无论做毕设还是实际产品,都是值得投入的方向。
