简介:基于知识图谱的旅游景点推荐系统的Python源码项目,属于高评分类毕业设计资源,面向需要完成课程设计、期末大作业或毕业设计的计算机专业学生。系统以知识图谱为核心,实现旅游景点智能推荐,适合作为推荐系统或知识图谱方向的项目参考。压缩包共18个文件,以13个Python脚本为主,配合3个文本文件、1个模型权重文件和1个Markdown文档。Python脚本覆盖数据处理、图谱构建、推荐预测、路径生成与评估等环节,文本文件提供运行参考和用户数据,模型权重可直接加载使用。整体包大小约2.64MB,结构紧凑且便于部署。资源目前已有198人学习下载。源码均经过本地编译并稳定运行,评审分达到98分,难度适中,内容经过助教老师审定。通过该资源可获得完整可运行的推荐系统实现、知识图谱构建思路、关键算法代码以及配套数据文件,支持学习复现、功能扩展和论文撰写,是课程设计与毕业设计的高分参考。
1. 基于知识图谱的旅游景点推荐系统:一套值得逐行拆解的Python源码
第一次拿到这套基于知识图谱的旅游景点推荐系统的Python源码包时,我正卡在毕业设计选题上。市面上推荐系统的开源项目大多停留在“用户—物品”二部图加协同过滤的套路,而这套项目换了一条路:用知识图谱把景点、城市、类别、餐饮组织成一张语义网,再基于图谱嵌入和用户历史行为做混合推荐。对想拿推荐系统做毕设或期末大作业的同学,它最实在的地方是建图谱、训练向量、算相似度、出推荐结果的每一步都可运行、可演示。整个项目不是包装精美的demo,而是有完整建图流水线、可调参的推荐逻辑和Web接口的闭环。接下来我按代码的真实执行顺序拆几个重点:先讲本体建模和Neo4j写入,再拆TransE嵌入与混合打分,最后是复现命令和踩坑记录。
2. 知识图谱本体设计:把旅游数据变成三元组的建模过程
2.1 先定实体和关系:本体建模不是画流程图
知识图谱构建的第一步永远不是写代码,而是确定本体。这里有一个很多初学者容易忽略的点:本体建模不是给系统画一张好看的ER图,它直接决定了后续能走哪些推荐路径。这套项目里定义了四类实体、五类关系,见下表。
| 实体类型 | 说明 | 典型实例 |
|---|---|---|
| ScenicSpot | 景点,图谱核心节点 | 峨眉山、九寨沟、都江堰 |
| City | 城市,景点所属的地理维度 | 成都、乐山、阿坝 |
| Category | 景点类别,自然/人文/主题乐园 | 自然风光、历史古迹 |
| Restaurant | 餐饮,与景点关联的美食节点 | 乐山钵钵鸡、成都火锅 |
关系定义上,LOCATED_IN表示景点位于城市,CATEGORY_OF表示景点属于某个类别,HAS_FOOD连接景点和餐饮节点,NEIGHBOR表示城市之间的邻近关系,USER_FAVORITE连接用户和景点。值得注意的设计细节是NEIGHBOR关系:它看起来和推荐无关,但在做多跳路径扩展时,能用来发现“用户去过成都,也许对乐山的景点也有兴趣”这类跨城关联。
提示:
USER_FAVORITE关系的存在意味着用户节点也需要入图。如果你的用户量很大,可以把用户行为单独放MySQL,只在Neo4j里保留景点侧的语义关系。这套源码采用的是后者,后续推荐打分时再把行为数据读出来和图谱向量做拼接。
本体确定之后,数据才有约束条件。常见错误是一上来就爬一堆数据,字段名都对齐不了,最后做实体对齐时欲哭无泪。正确顺序是先把本体的属性字段定下来,再去清洗数据。项目里景点节点的核心属性包括名称、评分、门票价格、开放时间,其中名称是主键约束,评分和价格用于推荐打分时的附加权重。
2.2 数据清洗与实体对齐:同一景点不同名的处理方式
原始数据从旅游网站爬下来之后,问题集中在两个地方:一是景点名称不统一,“峨眉山”和“峨眉山风景区”在数据里是两个词;二是类别字段是文本混杂,比如“自然风光/5A景区/世界遗产”被挤在一个单元格里。清洗逻辑首先做标准化,然后依托城市和类别信息做归并。下面这段是源码里核心清洗逻辑的简化版:
import pandas as pd import re def preprocess_scenic(raw_path, save_path): df = pd.read_csv(raw_path) # 去掉名称里的全角半角空格,并移除括号备注 df["name"] = df["name"].astype(str).str.strip() df["name"] = df["name"].map(lambda x: re.sub(r"[((].*?[))]", "", x)) # 同一实名去重,保留评分更高的一条 df = df.sort_values("rating", ascending=False) df = df.drop_duplicates(subset=["name"], keep="first") # 类别字段拆出主类别 df["category"] = df["category"].astype(str).str.split("/").str[0] df.to_csv(save_path, index=False) return df这段代码做了三件事:清洗名称中的括号备注,按名称去重并保留评分更高的那条,然后提取类别第一段作为主分类。很多初学者会忽略正则里括号的全半角问题,结果“九寨沟(国家级自然保护区)”和“九寨沟”仍然分在两个节点里。我在复现时就栽过这个跟头,后面在避坑章节单独说。
提示:实体对齐的兜底方案是用城市+类别+名称模糊匹配做联合判断。单纯靠字符串相等会漏掉别名,单纯靠相似度又会产生误合并,需要靠业务规则兜底。
2.3 三元组生成与批量入库:用py2neo把数据写进Neo4j
清洗完成之后,下一步是把结构化的景点表转换成三元组。这里源码定义了一个统一的生成函数,把所有关系统一放进一张三元组表里:
def build_triples(scenic_df): triples = [] for _, row in scenic_df.iterrows(): triples.append(("ScenicSpot", row["name"], "LOCATED_IN", "City", row["city"])) triples.append(("ScenicSpot", row["name"], "CATEGORY_OF", "Category", row["category"])) if pd.notna(row.get("food")): triples.append(("ScenicSpot", row["name"], "HAS_FOOD", "Restaurant", row["food"])) return pd.DataFrame(triples, columns=["head_type", "head", "relation", "tail_type", "tail"])三元组表生成后,需要一条一条入库。常见做法是用py2neo的merge方法做幂等写入,节点存在则匹配,不存在则创建。下面这段是源码里批量写入Neo4j的核心逻辑:
from py2neo import Graph, Node, Relationship graph = Graph("bolt://localhost:7687", auth=("neo4j", "123456")) def load_triples_batch(graph, triples_df, batch_size=500): rows = triples_df.to_dict("records") for i in range(0, len(rows), batch_size): batch = rows[i:i + batch_size] for row in batch: head = Node(row["head_type"], name=row["head"]) tail = Node(row["tail_type"], name=row["tail"]) graph.merge(head, row["head_type"], "name") graph.merge(tail, row["tail_type"], "name") rel = Relationship(head, row["relation"], tail) graph.merge(rel, row["relation"], "name")这个写法在数据量小的时候没问题,但如果你导入几万条三元组,逐条请求会慢到怀疑人生。更高效的方式是用Cypher的UNWIND批量执行,源码里也提供了一段等价脚本:
UNWIND $rows AS row MERGE (h:ScenicSpot {name: row.head}) MERGE (t:City {name: row.tail}) MERGE (h)-[:LOCATED_IN]->(t)把这段Cypher配合graph.run("UNWIND $rows AS row ...", rows=batch)调用,能在几百毫秒内完成一个批次的写入。两种方式的取舍是:批量UNWIND对Neo4j内存要求高,数据量超过十万条时建议拆成两千条一批,避免事务过大。
2.4 图写完之后必须做的校验:节点数和关系数对不上
图谱导入完成后,直接跑推荐是多半会翻车,原因是你根本不知道图里漏了多少关系。源码里提供了三组校验Cypher,建议每个人都跑一遍。
-- 查看每类节点的数量 MATCH (n) RETURN labels(n)[0] AS type, count(*) AS num ORDER BY num DESC; -- 查看景点按城市分布 MATCH (s:ScenicSpot)-[:LOCATED_IN]->(c:City) RETURN c.name, count(*) AS cnt ORDER BY cnt DESC LIMIT 10; -- 查找没有任何关系的孤立景点 MATCH (s:ScenicSpot) WHERE NOT (s)--() RETURN count(s);第三句是重点。孤立景点在图谱里没有语义关联,训练嵌入时会退化成随机向量,推荐阶段它们永远不会被召回。这类节点需要在导入后做一次清理,要么补关系,要么从候选集中剔除。我在校验时发现过近千个孤立景点,原因都是城市字段为空导致LOCATED_IN关系缺失。
3. 推荐算法实现:TransE图谱嵌入与混合推荐打分
3.1 为什么纯协同过滤在旅游场景里撑不住
旅游推荐和电商推荐有一个关键差异:用户可能一辈子只去一次峨眉山,用户—物品矩阵极度稀疏,协同过滤很难找到相似用户。加上冷启动问题,新景点没有任何用户行为数据,就永远没有出头之日。知识图谱的价值在这里体现得很直接:即使某个景点没有被任何用户收藏过,它仍然通过LOCATED_IN、CATEGORY_OF、HAS_FOOD关系和其他景点产生语义连接,推荐系统就能基于这些路径做推断。
这里用到的核心技术是知识图谱嵌入。它把每个实体和关系映射成低维向量,保留“实体+关系≈尾实体”的结构约束。算法层面用了TransE,这是图谱嵌入里最经典的baseline,实现简单、训练快,对毕业设计来说比GCN、R-GCN更容易解释和展示。
3.2 TransE训练:把节点变成向量的核心代码
TransE的核心思想可以一句话概括:头实体向量加上关系向量,应该近似等于尾实体向量。以“峨眉山 -LOCATED_IN-> 乐山”为例,vec(峨眉山) + vec(LOCATED_IN)应该和vec(乐山)很接近。训练时随机替换头或尾实体构造负样本,让正样本的距离尽可能小,负样本的距离尽可能大。
import numpy as np from collections import defaultdict class TransE: def __init__(self, entities, relations, dim=50, margin=2.0, lr=0.01): self.ent_vec = {e: np.random.uniform(-0.5, 0.5, dim) for e in entities} self.rel_vec = {r: np.random.uniform(-0.5, 0.5, dim) for r in relations} self.margin = margin self.lr = lr self.l2_norm() def l2_norm(self): for e in self.ent_vec: self.ent_vec[e] /= np.linalg.norm(self.ent_vec[e]) for r in self.rel_vec: self.rel_vec[r] /= np.linalg.norm(self.rel_vec[r]) def neg_sample(self, head, tail, entity_pool): if np.random.random() < 0.5: return np.random.choice(entity_pool), tail return head, np.random.choice(entity_pool) def train_step(self, h, r, t, entity_pool): hn, tn = self.neg_sample(h, t, entity_pool) pos_score = np.linalg.norm(self.ent_vec[h] + self.rel_vec[r] - self.ent_vec[t]) neg_score = np.linalg.norm(self.ent_vec[hn] + self.rel_vec[r] - self.ent_vec[tn]) loss = max(0, self.margin + pos_score - neg_score) # 梯度回传 if loss > 0: grad_pos = (self.ent_vec[h] + self.rel_vec[r] - self.ent_vec[t]) / pos_score grad_neg = (self.ent_vec[hn] + self.rel_vec[r] - self.ent_vec[tn]) / neg_score self.ent_vec[h] -= self.lr * grad_pos self.rel_vec[r] -= self.lr * grad_pos self.ent_vec[t] += self.lr * grad_pos self.ent_vec[hn] += self.lr * grad_neg self.rel_vec[r] -= self.lr * grad_neg self.ent_vec[tn] -= self.lr * grad_neg self.l2_norm() return loss几个参数说明了:dim=50是嵌入维度,项目里从20到100都测过,50在效果和训练速度之间平衡较好;margin=2.0是正负样本距离的安全边界,值太大训练很难收敛,太小则向量区分度不够;lr=0.01是学习率,训练轮数在200左右loss基本稳定。
有一点值得注意:每次更新后必须对向量做L2归一化,否则训练过程中向量模长会不断膨胀,最后所有实体的余弦相似度都趋近于1,等于白训。这个问题在避坑章节还会细说。
3.3 混合推荐打分:语义相似度和行为偏好怎么融合
TransE训练完成后,每个景点节点都对应一个向量,语义上相近的景点在向量空间里距离较近。但只有图谱向量还不够,用户的历史行为同样重要。源码里的推荐器把两个信号拼在一起:用户历史访问景点的平均向量,以及候选景点在图谱中的一跳邻居相似度。
def hybrid_recommend(user_history, candidate_list, entity_vecs, kg_neighbors, alpha=0.6, top_k=10): # 用户兴趣向量 = 历史景点的平均嵌入 if not user_history: return [] user_vec = np.mean([entity_vecs[item] for item in user_history if item in entity_vecs], axis=0) scored = {} for cand in candidate_list: if cand in user_history or cand not in entity_vecs: continue # 去掉用户已经去过或不在图谱里的景点 behavior_sim = float(np.dot(user_vec, entity_vecs[cand])) # 图谱一跳邻居的语义加权 neighbor_sim = 0.0 neighbor_cnt = 0 for nb in kg_neighbors.get(cand, []): if nb in entity_vecs: neighbor_sim += float(np.dot(entity_vecs[nb], entity_vecs[cand])) neighbor_cnt += 1 neighbor_sim = neighbor_sim / max(neighbor_cnt, 1) # 行为相似度占大头,图谱语义相似度做冷启动补充 scored[cand] = alpha * behavior_sim + (1 - alpha) * neighbor_sim return sorted(scored.items(), key=lambda x: x[1], reverse=True)[:top_k]注意这里的融合方式和常规加权不一样的地方:behavior_sim是用户历史和候选景点的直接向量内积,体现的是“用户去过哪类景点”;neighbor_sim通过一跳邻居把“和当前候选景点同城市、同类别的景点”也拉进打分。alpha=0.6的含义是行为信号占六成,图谱语义占四成。冷启动场景下用户历史很短,behavior_sim不稳定,可以适当调低alpha到0.4左右,让图谱语义主导。
提示:如果你想让推荐结果更有解释性,可以把
kg_neighbors换成二跳路径计数。比如“峨眉山位于乐山,乐山是都江堰的邻近城市”,这种推理在知识图谱里就是一条二跳路径,计入加权后推荐理由更充分。
4. 源码结构拆解与复现流程:从依赖安装到推荐接口跑通
4.1 代码包目录结构与职责划分
拿到源码包后第一件事不是运行,而是先看目录划分。这套项目的代码结构很清晰,按“数据处理→图谱构建→嵌入训练→推荐服务”四个阶段拆成了独立模块,好处是每个阶段可以单独调试。
| 文件路径 | 职责 | 关键输出 |
|---|---|---|
data/raw/ | 原始数据存放目录 | 景点CSV、用户行为CSV |
data/processed/ | 清洗后的中间数据 | scenic_clean.csv |
src/preprocess.py | 数据清洗与实体对齐 | 三元组临时表 |
src/build_graph.py | 构建本体并写入Neo4j | 图数据库 |
src/train_transe.py | 训练TransE嵌入 | 实体向量字典 |
src/recommender.py | 混合推荐打分逻辑 | Top-N推荐列表 |
src/app.py | Flask API入口 | 推荐结果JSON |
从依赖角度看,核心是py2neo和pandas,Web服务用Flask,嵌入训练只用NumPy就能实现,没有硬依赖TensorFlow或PyTorch,这对毕设环境部署来说非常友好。
4.2 环境配置:Python版本、Neo4j和依赖安装
项目对Python版本的要求是3.7到3.10之间,太高或太低都可能遇到依赖冲突。Neo4j建议装4.x版本,社区版就够用,不需要企业版。安装依赖和启动Neo4j的完整命令如下:
# 创建虚拟环境,避免污染系统Python python3 -m venv .venv source .venv/bin/activate pip install -r requirements.txt # 启动Neo4j(如果用的是Docker方式) docker run -d --name neo4j-travel \ -p 7474:7474 -p 7687:7687 \ -e NEO4J_AUTH=neo4j/123456 \ neo4j:4.4requirements.txt里主要锁定这几个库:pandas、numpy、py2neo、flask。安装时如果py2neo和neo4j版本不匹配,会报Unsupported driver version错误,这个在避坑章节有详细处理。
提示:如果本机已经有Neo4j服务,记得确认
conf/neo4j.conf里的dbms.connector.bolt.listen_address监听的是0.0.0.0:7687,否则py2neo连接时会被拒绝。
4.3 跑通完整链路:建图、训练、启动API
环境就绪后,按下面的顺序依次执行,前面的输出是后面的输入。这一步我建议每一步执行完都确认一下日志,不要一鼓作气全跑完再回头看。
# 第一步:清洗数据并生成三元组 python src/preprocess.py --raw data/raw/scenic.csv --out data/processed/scenic_clean.csv # 第二步:把三元组写入Neo4j python src/build_graph.py --user neo4j --password 123456 # 第三步:训练TransE嵌入并保存向量结果 python src/train_transe.py --dim 50 --epochs 200 --margin 2.0 # 第四步:启动Flask推荐服务 python src/app.py --host 0.0.0.0 --port 5000启动后用curl验证接口:
curl "http://localhost:5000/recommend?user_id=U001&top_k=10"正常会返回一个JSON数组,里面是十个别名和对应的推荐分数。如果你在第四步返回的是空列表,优先检查train_transe.py是否有输出向量文件,以及recommender.py里读取向量的路径和实际保存路径是否一致。
{ "user_id": "U001", "items": [ {"name": "乐山大佛", "score": 0.8721}, {"name": "峨眉山", "score": 0.8134} ] }跑通接口后,这个项目已经具备了答辩演示的完整形态:一张知识图谱在Neo4j Browser里可视化展示,一个推荐API能实时返回结果。
5. 避坑实录:知识图谱推荐系统开发中的五个引爆点
5.1 Neo4j批量写入卡死,插入速度慢到怀疑人生
现象:用py2neo逐条执行graph.create导入两万条三元组,跑了半小时还在转圈,中途Neo4j直接无响应。
原因:逐条调用请求会有大量事务开销,Neo4j会在内存中累积未提交的事务,数据量大时直接撑爆内存。另外一个隐形原因是节点合并时没有指定唯一约束,MERGE会先全表扫描,造成性能雪崩。
解决:先给实体创建唯一约束,再改用UNWIND批量写入。约束创建用一行Cypher:CREATE CONSTRAINT ON (s:ScenicSpot) ASSERT s.name IS UNIQUE;。写入时每批次控制在两千条以内,事务提交后清空内存再处理下一批。
提示:如果数据量到十万条以上,建议优先考虑
neo4j-admin import命令行工具离线导入,先把三元组导出成CSV,再用工具一次性导入,速度能快一个量级。
5.2 实体对齐翻车:“峨眉山”和“峨眉山风景区”成了两个节点
现象:Neo4j Browser里查峨眉山,发现图谱里有两个节点,一个叫“峨眉山”,一个叫“峨眉山风景区”,各有一堆关系,页面里看起来像两条平行世界。
原因:数据清洗时只做了去空格和去括号,没有做别名归并。爬虫抓取的原始名称可能是“峨眉山(峨眉山风景区)”,清洗正则把括号内容删了,但有些源数据就只叫“峨眉山风景区”,两条记录没有被合并。
解决:在清洗阶段引入一个手工映射表或基于城市+类别的模糊匹配。我在复现时采用的做法是先用difflib.SequenceMatcher算相似度,阈值大于0.85且城市相同的归并为一条,把评分更高的名称作为主名。
from difflib import SequenceMatcher def dedupe_by_similarity(df, city_col="city", name_col="name", threshold=0.85): groups = [] for _, group in df.groupby(city_col): names = group[name_col].tolist() merged = [] for name in names: hit = False for m in merged: if SequenceMatcher(None, name, m).ratio() > threshold: hit = True break if not hit: merged.append(name) groups.extend([(city, n) for city, n in zip([group[city_col].iloc[0]] * len(merged), merged)]) return groups5.3 TransE训练loss降了,但所有实体的相似度都趋近于1
现象:训练了三百轮,loss确实在下降,但用余弦相似度计算两个完全不相关景点,相似度高达0.98,推荐结果全是同一类向量。
原因:这是最典型的L2归一化缺失问题。如果不约束向量模长,训练过程会让向量长度不断变大,最后所有向量都被推向同一个方向,内积相似度全部趋同。还有一个原因是负样本采样太简单,随机替换的实体和原实体差异巨大,模型学不到细粒度区分。
解决:每一轮参数更新后强制执行L2归一化,同时把负样本改成“伯努利采样”——有50%概率替换头实体,50%概率替换尾实体,而不是固定替换其中一侧。归一化这段代码务必写在训练循环里,不能只写在初始化阶段。
提示:检查模型是否学废了,最快的方法是随机挑十个景点向量,打印两两余弦相似度的均值和方差。正常训练后均值应在0.2到0.5之间,方差在0.05以上。如果方差趋近于0,恭喜你,又白训了。
5.4 用户行为矩阵太稀疏,推荐结果经常为空
现象:接口偶尔能返回推荐,但很多用户返回空列表,尤其新注册用户没有任何历史行为,直接走user_history平均向量的逻辑就崩了。
原因:hybrid_recommend函数的user_vector = np.mean(...)在行为列表为空时直接报错,返回空列表。这是代码里对冷启动处理不完善,不是算法本身的问题。
解决:给行为史为空或不足两条的用户走纯图谱推荐分支,用候选景点的邻居相似度作为唯一打分依据,代码里加一个分支判断即可。
if len(user_history_entities) < 2: scored = { cand: neighbor_sim(cand) for cand in candidate_list } else: scored = { cand: alpha * behavior_sim + (1 - alpha) * neighbor_sim }5.5 py2neo和Neo4j版本不匹配,连接直接报错
现象:安装requirements.txt后运行build_graph.py,报错内容是Unsupported driver version或者Cannot decode response。
原因:py2neo的版本和Neo4j服务端的Bolt协议不是一一兼容的。py2neo4.x对应的是Neo4j 3.5到4.x,py2neo2021.2之后的版本只支持Neo4j 4.4以上。用一个很老版本的py2neo连接新Neo4j,协议握手机制不匹配就会报这个错。
解决:先查Neo4j服务端版本,再按对应关系装py2neo。我用的是Neo4j 4.4配合py2neo==2021.2.4,稳定跑完整个流程。如果不想折腾版本,也可以直接用官方neo4j驱动。
提示:
.venv里用pip show py2neo能快速查看已安装版本,配合Neo4j Browser里的call dbms.components()确认服务端版本,两边对齐比盲目升级依赖靠谱得多。
6. 效果验证与调参经验:用离线指标和留一法衡量推荐质量
推荐系统做完之后,最怕被老师问一句“你的效果到底怎么样”。源码里带了一套离线评估逻辑,用留一法做验证:把每个用户的历史行为按时间切分,最后一条行为作为测试集,其余作为训练集,然后看推荐列表里能不能命中这条行为。下面这段代码是评估的简化核心:
def evaluate_by_leave_one_out(history_sets, rec_func, k=10): total_hits, total = 0, 0 for uid, item_list in history_sets.items(): test_item = item_list[-1] # 留出最近一条 train_items = item_list[:-1] recs = rec_func(uid, train_items, k) if test_item in recs: total_hits += 1 total += 1 return total_hits / max(total, 1)留一命中率是毕设答辩最有说服力的单指标,但也建议同时汇报另外两个主流指标:Precision@K和Recall@K。这三个指标的侧重点不同:命中率看推荐列表是否包含测试项,精确率看推荐列表里有多少是用户真正去过的,召回率看用户去过的景点有多少被推荐了。下面这张表是调参时的对照记录,可以照着自己跑一遍:
| alpha | Precision@10 | Recall@10 | 命中率@10 | 观察结论 |
|---|---|---|---|---|
| 0.8 | 0.042 | 0.126 | 0.181 | 行为主导,冷启动用户效果差 |
| 0.6 | 0.051 | 0.148 | 0.214 | 行为与图谱平衡最佳 |
| 0.4 | 0.046 | 0.137 | 0.209 | 图谱主导,新景点更容易被推荐 |
| 0.2 | 0.031 | 0.098 | 0.143 | 图谱信号主导,行为信息被淹没 |
从表里能明显看到,alpha=0.6时三个指标都处在峰值附近。这个配置对旅游场景有普适性:景点推荐不像商品推荐那样强依赖个人历史,图谱语义能在行为稀疏时提供有效补充。
调参时还有一个容易忽略的维度:嵌入训练的dim。我在这个项目上做了对比实验,dim=20时向量表达力不足,同类景点挤在一起;dim=100时训练时间翻倍但指标提升很小;dim=50在效果和耗时之间最均衡。冷启动线索是:如果你的候选集里有大量新景点,可以把dim调到80,让图谱结构信息表达得更充分。
从那以后,我每次拿到这类知识图谱推荐项目,都强制自己先跑一遍评估脚本拿到基线数字,再动参数。这个习惯帮我过滤掉了好几次“自我感觉良好”的调参,也让答辩时有实实在在的数据可以讲。希望这份笔记能帮你把整套源码跑通,并在自己的项目里做出同样扎实的效果。
本文还有配套的精品资源,点击获取